Compare commits

...

16 Commits

Author SHA1 Message Date
bryanthaboi d2a86cd258 Merge pull request #1873 from bryanthaboi/dev 2026-08-27 07:42:16 -04:00
bryanthaboi 8b56ec2d77 big egg energy 2026-08-27 07:40:02 -04:00
bryanthaboi f11724f08a Merge pull request #1841 from thibautbus/fix/gen2-pokedex-mod-text 2026-08-27 07:25:03 -04:00
bryanthaboi bd3e02986d test fix and iOS close hide 2026-08-27 07:11:43 -04:00
bryanthaboi eed246f645 CLOSES #1831, CLOSES #1834, CLOSES #1836, CLOSES #1860 2026-08-27 06:09:50 -04:00
bryanthaboi c9d06779d8 Merge pull request #1844 from colsonrice/fix/gen2-save-refusal-message
Refuse a Gen 2 cart save as a Gen 2 save, not as a broken Gen 1 one
2026-08-27 06:04:27 -04:00
bryanthaboi 48d8a4e922 CLOSES #1562, CLOSES #1804, CLOSES #1805, CLOSES #1810, CLOSES #1811, CLOSES #1814, CLOSES #1817, CLOSES #1818, CLOSES #1819, CLOSES #1821, CLOSES #1826, CLOSES #1827, CLOSES #1829, CLOSES #1833, CLOSES #1840, CLOSES #1842, CLOSES #1845, CLOSES #1846, CLOSES #1847, CLOSES #1848, CLOSES #1849, CLOSES #1853, CLOSES #1858, CLOSES #1862 2026-08-27 05:09:26 -04:00
github-actions 9a05de4474 chore(ios): update app-repo.json [skip ci] 2026-08-26 18:03:50 -04:00
bryanthaboi 5c833a3d73 Merge pull request #1864 from bryanthaboi/dev 2026-08-26 17:49:40 -04:00
bryanthaboi 5051cb902d Merge pull request #1863 from ShaneMcGovernIE/feat/handheld-osk-and-trigger-scroll 2026-08-26 17:47:04 -04:00
Shane McGovern 2720bfb5d8 feat(ui): scope on-screen VirtualKeyboard to Linux handhelds and restore L2/R2 trigger scrolling 2026-08-26 22:45:09 +01:00
github-actions 54ba0f04ff chore(ios): update app-repo.json [skip ci] 2026-08-26 17:34:24 -04:00
Colson Rice 3a0a72102f Teach the bug420 SaveConvert double the method importToSlot now calls
CI caught this: tests/engine/save_import_retry_bug420.lua replaces
SaveConvert with a minimal double, and importToSlot now asks it
importSupported before it measures the bytes, so the double answered nil
and the call died.

The double stands in for the real module, so it grows with it. Answering
true keeps that case about the thing it is testing, which is that
importToSlot names the game whose cache to read.

./scripts/test.sh passes end to end locally now, every ROM-free tier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 15:47:08 -04:00
Colson Rice fe31293566 Refuse a Gen 2 cart save as a Gen 2 save, not as a broken Gen 1 one
#1832 reports a Crystal .sav failing to import with "save data checksum
invalid (main data checksum mismatch)". The save is fine. It is being
measured with the wrong generation's ruler.

SaveFileIO.importToSlot sends anything that is not exactly SAVE_SIZE into
mainChecksumValid, which is pokered's main-data checksum. Gen 2 carts are
MBC3+TIMER, so a real Gold, Silver or Crystal battery save carries an RTC
footer: 32786 bytes, never 32768. It misses the size test, falls into the
checksum branch, and is told it is damaged by a rule written for a
different game. Every real Gen 2 cart save takes that path, every time.

The generation check already exists, it just lives too late, inside
importSav and behind the size gate that has already rejected the save.
This lifts it into SaveConvert.importSupported and exportSupported and
asks before the bytes are measured. Both existing guards route through
the same predicates now, so the early caller and the late one cannot
describe the same game two different ways.

Nothing changes for Gen 1, and Gen 2 is still refused, for the real
reason and in a sentence a player can do something with.

tests/gen2_save_import_message_test.lua covers it. Against the code as it
stands it is 20/26, and the failures are the report itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 14:52:44 -04:00
thibautbus c8eeeaa1f6 Cover the #DEX registry projection fix with a targeted test 2026-08-26 19:44:00 +02:00
thibautbus 273350d11e Route the Gen2 #DEX screen's kind/text through the pokemon registry
src/ui/gen2/PokedexMenu.lua reads its KIND label and both description
pages from data.gen2Pokedex.entries, loaded straight from disk before
mods:load runs -- a separate table from data.pokemon, the `pokemon`
registry's own merge target. mod.content.pokemon:patch(id, { dexEntry =
... }) therefore validated but never reached the screen. Adds
src/core/gen2/PokedexText.lua to project a patched dexEntry onto the
#DEX table after the merge (Game2:load, alongside the other Gen 2
post-merge registries), and a text2 field to the dexEntry schema for
the entry's second description page, which the screen already reads
but the registry had no field for. Also routes the OPTION/SEARCH panel
titles (PokedexMenu.lua) through Strings(), the same literal-wrapping
pattern already used elsewhere in this screen and its siblings.
2026-08-26 19:43:53 +02:00
103 changed files with 4999 additions and 468 deletions
+19 -12
View File
@@ -1251,19 +1251,26 @@ local function pokemonTower2FRivalScript(playerX)
and TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT
or TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN
return {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2
{ "jump_if_true", 12 }, -- 3
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4
{ "rival_battle", "OPP_RIVAL2", 4 }, -- 5
{ "jump_if_false", "end" }, -- 6 loss: stay
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
{ "face_player" },
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" },
{ "jump_if_true", "beaten" },
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" },
-- .DefeatedText rides BIT_PRINT_END_BATTLE_TEXT, so it prints on the
-- battle screen -- scripts/PokemonTower2F.asm:145-150
{ "save_end_battle_text", "_PokemonTower2FRivalDefeatedText" },
{ "rival_battle", "OPP_RIVAL2", 4 },
{ "jump_if_false", "end" }, -- loss: stay
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" },
-- the win re-runs DisplayTextID on the beaten branch
-- scripts/PokemonTower2F.asm:72-75, :137-140
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" },
{ "play_music", "Music_MeetRival", { start = "rival" } },
{ "walk_npc", 1, exitDirs }, -- 9
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
{ "jump", "end" }, -- 11
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 12
{ "walk_npc", 1, exitDirs },
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" },
{ "play_default_music" }, -- scripts/PokemonTower2F.asm:124
{ "jump", "end" },
{ "label", "beaten" },
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" },
}
end
+20 -21
View File
@@ -167,13 +167,11 @@ M.POKEMON_TOWER_6F = {
-- PlayCry RESTLESS_SOUL (EQU MAROWAK, constants/pokemon_constants
-- .asm:209) + WaitForSoundToFinish + DelayFrames 30 before the
-- calmed line; the port dropped the first text and the cry
-- (#867). play_cry arms the next show_text, so the cry rides
-- the calmed box's open with the button prompt kept, and the
-- wait row stands in for the asm's 30-frame gap.
-- (#867). text/PokemonTower6F.asm:1-5 (#1849)
local rows = {
{ "play_cry", "MAROWAK" },
{ "show_text", t._PokemonTower6FGhostWasCubonesMotherText
or "The GHOST was the\nrestless soul of\vCUBONE's mother!" },
{ "play_cry", "MAROWAK", true },
{ "wait", 30 },
{ "show_text", t._PokemonTower6FSoulWasCalmedText
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!" },
@@ -191,7 +189,8 @@ M.POKEMON_TOWER_6F = {
end
ow:afterBattle(result, battle)
end
game.stack:push(battle)
-- InitWildBattle runs the wipe before the disguise (core.asm:6695-6702)
ow:pushBattle(battle)
end))
return true
end,
@@ -443,6 +442,9 @@ M.ROCKET_HIDEOUT_B4F = {
or "I hope we meet\nagain..."
game.stack:push(TextBox.new(game, impressed, function()
local battle = BattleState.newTrainer(game, "OPP_GIOVANNI", 1)
-- SaveEndBattleTextPointers (scripts/RocketHideoutB4F.asm:99-120):
-- PrintEndBattleText prints it on the battle screen (#1817)
battle.endBattleText = TextBox.substitute(game, cannotBe)
battle.onFinish = function(result)
if result ~= "win" then
ow:afterBattle(result, battle)
@@ -451,22 +453,19 @@ M.ROCKET_HIDEOUT_B4F = {
end
game.save.defeatedTrainers[npc.id] = true
game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI = true
-- End-battle "WHAT!" then BeatGiovanniScript's hope text,
-- fade, HideObject Giovanni, ShowObject Silph Scope.
game.stack:push(TextBox.new(game, cannotBe, function()
game.stack:push(TextBox.new(game, hope, function()
local Transition = require("src.render.Transition")
game.stack:push(Transition.new(game, function()
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "ROCKET_HIDEOUT_B4F",
"ROCKETHIDEOUTB4F_GIOVANNI")
Commands.show_object(ctx, "ROCKET_HIDEOUT_B4F",
"ROCKETHIDEOUTB4F_SILPH_SCOPE")
end, function()
ow:afterBattle(result, battle)
done()
end))
-- BeatGiovanniScript (scripts/RocketHideoutB4F.asm)
game.stack:push(TextBox.new(game, hope, function()
local Transition = require("src.render.Transition")
game.stack:push(Transition.new(game, function()
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "ROCKET_HIDEOUT_B4F",
"ROCKETHIDEOUTB4F_GIOVANNI")
Commands.show_object(ctx, "ROCKET_HIDEOUT_B4F",
"ROCKETHIDEOUTB4F_SILPH_SCOPE")
end, function()
ow:afterBattle(result, battle)
done()
end))
end))
end
+1 -1
View File
@@ -821,7 +821,7 @@ mod.content.phone_contacts:patch("PHONE_YOUNGSTER_JOEY", { map = "ROUTE_31" })
| `catchRate` | integer 0..255 | yes |
| `cry` | cries id | no |
| `dex` | integer >= 1 | yes |
| `dexEntry` | {heightFt, heightIn, heightM?, kind, text, weight, weightKg?} | no |
| `dexEntry` | {heightFt, heightIn, heightM?, kind, text, text2?, weight, weightKg?} | no |
| `evolutions` | list of {item?, level?, method, species} | yes |
| `frontSize` | integer 1..7 | yes |
| `growthRate` | growth_rates id | yes |
+14
View File
@@ -12,6 +12,20 @@
"tintColor": "3b5ca8",
"category": "games",
"versions": [
{
"version": "0.2.29",
"date": "2026-08-26",
"size": 21366635,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.29/gen1recomp++-0.2.29-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi\n- @ShaneMcGovernIE"
},
{
"version": "0.2.28",
"date": "2026-08-26",
"size": 21366087,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.28/gen1recomp++-0.2.28-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.27",
"date": "2026-08-26",
+11 -9
View File
@@ -104,7 +104,9 @@ local SE_FRAMES = {
SE_FLASH_MON_PIC = 4, SE_FLASH_ENEMY_MON_PIC = 4,
SE_TRANSFORM_MON = 4,
SE_SUBSTITUTE_MON = 3,
SE_WAVY_SCREEN = 255, -- AnimationWavyScreen: ld c, $ff frames
-- AnimationWavyScreen: `ld c, $ff` counts outer passes, and the inner
-- loop exits twice per displayed frame (animations.asm:1884-1903)
SE_WAVY_SCREEN = 128,
}
-- data/battle_anims/special_effects.asm AnimationIdSpecialEffects:
@@ -502,15 +504,10 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts)
local transform = resolveTransform(sub.type, attackerIsPlayer)
local first, last, dir = 1, #sub.blocks, 1
if transform == "REVERSE" then first, last, dir = last, first, -1 end
-- DoBallShakeSpecialEffects: each ball shake opens with SFX_TINK
-- and a 40-frame pause, then rewinds the same subanimation; the
-- mode-4 frame blocks persist, so the resting ball stays visible
-- through the pauses between wobbles
-- DoBallShakeSpecialEffects: animations.asm:739-747, :623-627
local pendingTink = false
for _ = 1, (opts and opts.shakes) or 1 do
if opts and opts.shakes then
events[#events + 1] = { effect = "SFX_TINK", frame = frame }
emit(40)
end
pendingTink = opts and opts.shakes ~= nil
local dest = 1 -- PlaySubanimation resets the OAM cursor per row
local nblocks = math.abs(last - first) + 1
local played = 0
@@ -568,6 +565,11 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts)
-- DoSpecialEffectByAnimationId runs after every frame
-- block with wSubAnimCounter = blocks remaining
played = played + 1
if pendingTink then
pendingTink = false
events[#events + 1] = { effect = "SFX_TINK", frame = frame }
emit(40)
end
if ballFlicker then obp0Flip = not obp0Flip end
if idFx then
local counter = nblocks - played + 1
+43 -10
View File
@@ -340,6 +340,22 @@ local function monPalette(data, species)
return { name = name, colors = colors }
end
-- MarowakAnim OBJ pics under OAM_PAL1:
-- engine/battle/ghost_marowak_anim.asm:3-5,77
local function objPicPalette()
local PaletteFX = require("src.render.PaletteFX")
if not PaletteFX.usesSpriteObp() then return nil end
local colors, group = PaletteFX.ogObj()
if not colors then return nil end
return { name = "obp1:" .. tostring(group), colors = colors }
end
local function objPic(path, trueColor)
local pal = objPicPalette()
if not pal then return nil end
return getImage(path, pal, trueColor)
end
-- a named palette from the active COLORS pack as a getImage pal
local function namedPalette(data, name)
local PaletteFX = require("src.render.PaletteFX")
@@ -897,7 +913,8 @@ local function disguiseAsGhost(self)
self.enemy.name = "GHOST"
self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png",
monPalette(self.data, self.enemy.mon.species))
self.introText = Strings("The GHOST\nappeared!")
-- _EnemyAppearedText (data/text/text_2.asm:1251-1255) has no article
self.introText = self:romText("_EnemyAppearedText", "%s\nappeared!", self.enemy.name)
end
-- Pokémon Tower ghosts (engine/battle/core.asm): without the Silph Scope
@@ -941,7 +958,11 @@ function BattleState:queueScopeReveal()
local unveiled = self.data.text and self.data.text._UnveiledGhostText
self:say(unveiled
or Strings("SILPH SCOPE\nunveiled the\vGHOST's identity!"))
self:act(function() self.ghostReveal = { t = 0 } end)
self:act(function()
self.ghostReveal = { t = 0 }
local ghostObj = objPic("assets/generated/battle/front/ghost.png")
if ghostObj then self.enemy.sprite = ghostObj end
end)
table.insert(self.queue, { wait = BattleState.GHOST_REVEAL_FRAMES })
self:say(self:romText("_WildMonAppearedText", "Wild %s\nappeared!",
self.ghostReal and self.ghostReal.name or self.enemy.name))
@@ -3264,9 +3285,9 @@ function BattleState:applyAnimEffect(ev)
end
fx.hudShakeProg = prog
elseif e == "SE_WAVY_SCREEN" then
-- AnimationWavyScreen: 255 frames of per-scanline SCX offsets
-- walking WavyScreenLineOffsets
fx.wavy = { left = 255, phase = 0 }
-- AnimationWavyScreen: 255 outer passes, two per displayed frame
-- (animations.asm:1884-1903), walking WavyScreenLineOffsets
fx.wavy = { left = 128, phase = 0 }
-- ---------------------------------------------- mon pic effects
elseif e == "SE_SLIDE_MON_OFF" then
@@ -3650,7 +3671,7 @@ function BattleState:updateFx()
end
if fx.wavy then
fx.wavy.left = fx.wavy.left - 1
fx.wavy.phase = fx.wavy.phase + 1
fx.wavy.phase = fx.wavy.phase + 2
if fx.wavy.left <= 0 then fx.wavy = nil end
end
end
@@ -3704,12 +3725,22 @@ function BattleState:updateFx()
local real = self.ghostReal
if real then
self.enemy.name = real.name or self.enemy.name
self.enemy.sprite = real.sprite or self.enemy.sprite
gr.bgSprite = real.sprite or self.enemy.sprite
local objReal
if objPicPalette() then
local Sprites = require("src.pokemon.Sprites")
local path, tc = Sprites.path(self.data, self.enemy.mon.species,
"front", { mon = self.enemy.mon, kind = "battle" })
objReal = path and objPic(path, tc)
end
self.enemy.sprite = objReal or gr.bgSprite
end
end
pf.fade = math.min(1, math.ceil((gr.t - outEnd) / 10) / 4)
end
if gr.t >= BattleState.GHOST_REVEAL_FRAMES then
-- home/clear_sprites.asm:1
if gr.bgSprite then self.enemy.sprite = gr.bgSprite end
self.ghostReveal, self.scopeReveal, pf.fade = nil, nil, nil
end
end
@@ -3825,6 +3856,9 @@ function BattleState:executeAction(user, target, action)
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
clearTrapping(self.player)
self:syncSides()
-- EnemySendOut (core.asm:1276-1289): only the mon on the field stays flagged
self.participants = {}
self:markParticipant()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[2], battler = self.enemy,
previous = previous,
@@ -5950,8 +5984,7 @@ local WAVY_OFFSETS = { 0, 0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2, 2, 1, 1, 1,
0, 0, 0, 0, 0, -1, -1, -1, -2, -2, -2, -2, -2,
-1, -1, -1 }
-- wave the BG canvas one scanline at a time; the offset table walks
-- one entry per frame like the asm's advancing pointer
-- WavyScreen_SetSCX: animations.asm:1916-1927
function BattleState:applyWavy(src)
local wavy = self.fx and self.fx.wavy
if not wavy then return src end
@@ -5964,7 +5997,7 @@ function BattleState:applyWavy(src)
for line = 0, 143 do
self.waveQuad:setViewport(0, line, 160, 1)
g.draw(src, self.waveQuad,
WAVY_OFFSETS[(line + wavy.phase) % 32 + 1], line)
WAVY_OFFSETS[(line * 2 + wavy.phase) % 32 + 1], line)
end
g.setCanvas(prev)
return self.waveCanvas
+1
View File
@@ -88,6 +88,7 @@ function AnimRunner.new(opts)
}
self.objects = AnimObjects.new(self.data, self.constants, self.env)
self.bg = BgEffects.new(self.constants, self.env)
self.animId = opts.animId -- wFXAnimID
self.gfxOrder = self.constants.battleAnimGfxOrder or {}
self.sfxOrder = opts.sfxOrder or {}
+8 -2
View File
@@ -186,7 +186,8 @@ function Game:makeTitleState()
onContinue = function()
local loaded, recovered = SaveData.load()
if loaded then
self:restoreSave(loaded, recovered, { freshBoot = true })
self:restoreSave(loaded, recovered,
{ freshBoot = true, continued = true })
end
end,
onExit = self.onExit,
@@ -1322,6 +1323,9 @@ function Game:restoreSave(loaded, recovered, opts)
report.recovered = recovered
report.modsDiff = modsDiff
self.save = loaded
-- the START cursor lives outside sGameData (ram/sram.asm:17-21)
loaded.startMenuIndex = nil
self.startMenuIndex = nil
self:adoptSave(loaded)
-- SaveData.load already attached the standalone options.lua table
self:applyOptions(loaded.options)
@@ -1336,8 +1340,10 @@ function Game:restoreSave(loaded, recovered, opts)
-- freshBoot threads through from the caller (onContinue and F2 both set
-- it); a future caller that doesn't ask for it keeps the ordinary
-- crossfade by default.
-- engine/menus/main_menu.asm:110 (CONTINUE forces PLAYER_DIR_DOWN)
local facing = (opts and opts.continued) and "down" or loaded.player.facing
self.stack:push(self.overworld, loaded.player.map,
loaded.player.x, loaded.player.y, loaded.player.facing,
loaded.player.x, loaded.player.y, facing,
{ via = "boot", freshBoot = opts and opts.freshBoot })
self.saveReport = report
if not SaveData.emptyReport(report) then
+5
View File
@@ -1024,6 +1024,11 @@ function Game2:load()
require("src.core.gen2.Phone").useRegistry(self.data)
require("src.core.gen2.Decorations").useRegistry(self.data)
require("src.core.gen2.Apricorns").useRegistry(self.data)
-- data.gen2Pokedex is a separate table from the `pokemon` registry's own
-- merge target (data.pokemon): a translation mod's
-- mod.content.pokemon:patch(id, { dexEntry = ... }) would otherwise never
-- reach the #DEX screen. See src/core/gen2/PokedexText.lua.
require("src.core.gen2.PokedexText").apply(self.data)
-- Rendering pipelines: the engine half of the render_pipelines registry
-- (src/render/Pipelines.lua). install() points it at GOLD's merged dataset
+3 -1
View File
@@ -40,8 +40,10 @@ GamepadMap.NX_RAW_BUTTON_BINDINGS = {
-- Raw index -> gamepad button *name* for RomImporter (then NX face swap applies).
GamepadMap.RAW_TO_GAMEPAD_BUTTON = {
[1] = "a", [2] = "b",
[1] = "a", [2] = "b", [3] = "x", [4] = "y",
[5] = "leftshoulder", [6] = "rightshoulder",
[7] = "back", [8] = "start", [9] = "back", [10] = "start",
[11] = "triggerleft", [12] = "triggerright",
}
GamepadMap.NX_RAW_TO_GAMEPAD_BUTTON = {
+4 -2
View File
@@ -1101,8 +1101,10 @@ function Breeding.hatch(data, save, index, nickname, where)
hatched.experience = egg.experience
-- `ld a, [de] / ld [hli], a` twice: HP := MaxHP.
hatched.hp = hatched.maxHp
hatched.ot = egg.ot or (save.player and save.player.name)
hatched.otId = egg.otId or (save.player and save.player.id)
-- HatchEggs overwrites OT unconditionally, so the ODD_EGG's "ODD" and its
-- table id do not survive -- engine/pokemon/breeding.asm:299-309
hatched.ot = (save.player and save.player.name) or egg.ot
hatched.otId = (save.player and save.player.id) or egg.otId
hatched.caughtLevel = egg.level or Breeding.EGG_LEVEL
-- SetEggMonCaughtData swaps wCurPartyLevel for CAUGHT_EGG_LEVEL around the
-- shared setter -- engine/pokemon/caught_data.asm:235-246.
+39
View File
@@ -0,0 +1,39 @@
-- Projects a mod's `pokemon` registry dexEntry onto the #DEX screen's own
-- data table.
--
-- data.gen2Pokedex.entries[species] (data/generated/pokedex.lua,
-- RomExtractorGen2:extractPokedex) is what src/ui/gen2/PokedexMenu.lua
-- actually reads for the KIND label and the two description pages
-- (entry.kind, entry.text, entry.text2) -- a table loaded straight from
-- disk in Game2:load, BEFORE mods:load runs, and never routed through a
-- registry. The `pokemon` registry's own merge target is the separate
-- data.pokemon table (src/mods/Schemas.lua, R.pokemon: `target = "pokemon"`),
-- which mod.content.pokemon:patch(id, { dexEntry = { kind = ..., text = ...,
-- text2 = ... } }) already reaches -- so a translation mod's #DEX text was
-- silently invisible in-game despite validating against the registry.
--
-- This is the missing link: called once after the merge (src/core/Game2.lua),
-- the same way ItemEffects.applyHeldItems projects held_items onto
-- data.gen2HeldItems. height/weight/dex stay untouched -- game state, not
-- text a translation carries.
local PokedexText = {}
function PokedexText.apply(data)
local dex = data and data.gen2Pokedex
local pokemon = data and data.pokemon
if not (dex and dex.entries and pokemon) then return 0 end
local count = 0
for species, entry in pairs(dex.entries) do
local def = pokemon[species]
local override = def and def.dexEntry
if override and (override.kind or override.text or override.text2) then
if override.kind then entry.kind = override.kind end
if override.text then entry.text = override.text end
if override.text2 then entry.text2 = override.text2 end
count = count + 1
end
end
return count
end
return PokedexText
+6 -4
View File
@@ -1394,7 +1394,9 @@ local function buildHeader(imp, m)
-- under the gear and the quit X -- "the settings is covering the logo".
-- Reserving the space on both sides costs a little width and cannot
-- overlap at any window size.
local clusterW = 2 * gear + math.floor(6 * m.s) + m.pad
-- iOS has no quit button (the OS owns app exit), so the cluster is the
-- gear alone and the wordmark gets that width back
local clusterW = (imp.ios and gear or 2 * gear + math.floor(6 * m.s)) + m.pad
local boxX = m.x + clusterW
local boxW = math.max(0, m.w - 2 * clusterW)
if imp.logo and boxW > 0 then
@@ -1428,8 +1430,8 @@ local function buildHeader(imp, m)
-- inboard of it -- but the two are REGISTERED gear first, because the first
-- focusable of the first frame adopts the keyboard ring and that must not be
-- the button that exits the app.
local quitX = rx - gear
rx = quitX - math.floor(6 * m.s)
local quitX = not imp.ios and rx - gear or nil
if quitX then rx = quitX - math.floor(6 * m.s) end
-- Settings gear. It now also owns the CONTROL settings (touch overlay
-- editor, reset rebinds), which used to be buttons stacked in the game
@@ -1441,7 +1443,7 @@ local function buildHeader(imp, m)
chrome.gear.image = imp._gearIcon
btn(imp, rx, by, gear, gear, "gear", "", chrome.gear)
btn(imp, quitX, by, gear, gear, "quit", "", chrome.quit)
if quitX then btn(imp, quitX, by, gear, gear, "quit", "", chrome.quit) end
-- The self-update control lives in the FOOTER next to the BCG mark (small,
-- out of the wordmark's way -- it used to overlap the logo on a phone). It
+2 -1
View File
@@ -2753,7 +2753,8 @@ function RomExtractorGen2:extractAudio(maps)
local speciesOrder = self.manifest.constants.speciesOrder or {}
local cries = {}
for index, species in ipairs(speciesOrder) do
if species ~= "UNOWN" and not tostring(species):match("^UNUSED") then
-- data/pokemon/cries.asm:209 gives UNOWN a real cry row
if not tostring(species):match("^UNUSED") then
local row = self.rom:bytes(
pokeCryPtr.bank, pokeCryPtr.address + (index - 1) * 6, 6)
local cryIndex = row[1] + row[2] * 256
+53 -1
View File
@@ -3193,8 +3193,13 @@ function RomImporter:gamepadpressed(_, button)
return
end
-- Select button: toggle Virtual Keyboard
-- Select button: toggle Virtual Keyboard on handhelds
if button == "back" or button == "select" or action == "select" then
local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1"
or os.getenv("POKEPORT_SBC") == "1" or os.getenv("ANBERNIC") == "1"
if not isHandheld then return end
if okKit and Kit.VirtualKeyboard then
if Kit.VirtualKeyboard.active then
Kit.VirtualKeyboard.close(false)
@@ -3277,6 +3282,17 @@ function RomImporter:gamepadpressed(_, button)
return
end
-- Triggers (L2 / R2): fast scroll content / lists up and down
if button == "triggerleft" or button == "lefttrigger" or button == "l2"
or action == "triggerleft" or action == "l2" then
require("src.import.LauncherView").wheelmoved(self, 0, 4)
return
elseif button == "triggerright" or button == "righttrigger" or button == "r2"
or action == "triggerright" or action == "r2" then
require("src.import.LauncherView").wheelmoved(self, 0, -4)
return
end
-- Start button: Play / Choose ROM
if button == "start" then
if self.workState == "working" then return end
@@ -3353,6 +3369,24 @@ function RomImporter:gamepadaxis(_, axis, value)
elseif axis == "lefty" and math.abs(value) <= PAD_DEAD then
self._padStickCentered = true
end
elseif axis == "triggerleft" or axis == "lefttrigger" then
if value > 0.4 then
if not self._l2TriggerActive then
require("src.import.LauncherView").wheelmoved(self, 0, 4)
self._l2TriggerActive = true
end
elseif value < 0.2 then
self._l2TriggerActive = false
end
elseif axis == "triggerright" or axis == "righttrigger" then
if value > 0.4 then
if not self._r2TriggerActive then
require("src.import.LauncherView").wheelmoved(self, 0, -4)
self._r2TriggerActive = true
end
elseif value < 0.2 then
self._r2TriggerActive = false
end
end
end
@@ -3380,6 +3414,24 @@ function RomImporter:joystickaxis(joystick, axis, value)
self:gamepadaxis(joystick, "leftx", value)
elseif axis == 2 then
self:gamepadaxis(joystick, "lefty", value)
elseif axis == 3 or axis == 5 then
if value > 0.4 then
if not self._l2RawActive then
require("src.import.LauncherView").wheelmoved(self, 0, 4)
self._l2RawActive = true
end
elseif value < 0.2 then
self._l2RawActive = false
end
elseif axis == 4 or axis == 6 then
if value > 0.4 then
if not self._r2RawActive then
require("src.import.LauncherView").wheelmoved(self, 0, -4)
self._r2RawActive = true
end
elseif value < 0.2 then
self._r2RawActive = false
end
end
end
+6
View File
@@ -78,6 +78,12 @@ function SaveFileIO.importToSlot(source, version, force)
version = version or GameVersion.get()
local bytes, readErr = readSource(source)
if not bytes then return false, readErr end
-- The GAME decides before the BYTES do. Everything below this line judges a
-- save by Gen 1's rules -- the size test, and mainChecksumValid, which is
-- pokered's checksum -- so a Gen 2 cart save reaching it is measured against
-- a rule that cannot match and comes back "checksum invalid" (#1832).
local supported, unsupportedWhy = SaveConvert.importSupported(version)
if not supported then return false, unsupportedWhy end
if #bytes ~= SAVE_SIZE then
local check = SaveConvert.mainChecksumValid(bytes)
if check == nil then
+5 -2
View File
@@ -94,9 +94,11 @@ function ItemEffects.healsHP(id)
end
-- .useRareCandy prints over the still-drawn party menu
-- (engine/items/item_effects.asm:1392-1418)
-- (engine/items/item_effects.asm:1392-1418); .useVitamin ends at
-- RemoveUsedItem the same way (engine/items/item_effects.asm:1315-1322)
function ItemEffects.keepsPartyMenuOpen(id)
return ItemEffects.healsHP(id) or id == "RARE_CANDY"
or VITAMINS[id] ~= nil
end
function ItemEffects.isBattleMedicine(id)
@@ -526,8 +528,9 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- _VitaminStatRoseText's slot order is localization-dependent (the
-- Spanish ROM puts the stat before the name), so the extracted line
-- cannot be filled positionally; the engine wording stands
-- engine/items/item_effects.asm:1313
return "consumed", { Strings("%s's %s\nrose!", monName(data, target),
Strings(STAT_LABEL[vitaminStat])) }
Strings(STAT_LABEL[vitaminStat])) }, { useJingle = true }
end
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
+26 -4
View File
@@ -23,6 +23,7 @@ local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime")
local TurnOrder = require("src.battle.TurnOrder")
local Strings = require("src.core.Strings")
local Timing = require("src.core.Timing")
local LinkBattle = {}
@@ -473,11 +474,22 @@ function LinkBattle.new(game, net, opts)
-- switches happen before attacks (both may switch)
if myMsg.kind == "switch" then
local idx = myMsg.index
s:act(function() sendOutPlayer(s, myParty[idx]) end)
-- SwitchPlayerMon (engine/battle/core.asm:2419-2423); a post-faint
-- replacement shares sendOutPlayer and prints neither line
s:act(function()
s:sayNextAuto(s:withdrawText(s.player.name), Timing.SWITCH_PLAYER_MON)
s:queueRetreatAnim()
s:actNext(function() sendOutPlayer(s, myParty[idx]) end)
end)
myAction = nil
end
if theirSwitch then
s:act(function() sendOutEnemy(s, theirParty[theirSwitch]) end)
-- SwitchEnemyMon (engine/battle/trainer_ai.asm:596-599)
s:act(function()
s:sayNext(s:romText("_AIBattleWithdrawText", "%s with-\ndrew %s!",
theirName, s.enemy.name))
s:actNext(function() sendOutEnemy(s, theirParty[theirSwitch]) end)
end)
end
s:act(function()
@@ -885,11 +897,21 @@ function LinkBattle.newSpectator(game, net, opts)
if hostMsg.kind == "switch" then
local idx = hostMsg.index
s:act(function() sendOutHost(s, hostParty[idx]) end)
-- SwitchPlayerMon (engine/battle/core.asm:2419-2423)
s:act(function()
s:sayNextAuto(s:withdrawText(s.player.name), Timing.SWITCH_PLAYER_MON)
s:queueRetreatAnim()
s:actNext(function() sendOutHost(s, hostParty[idx]) end)
end)
end
if guestMsg.kind == "switch" then
local idx = guestMsg.index
s:act(function() sendOutGuest(s, guestParty[idx]) end)
-- SwitchEnemyMon (engine/battle/trainer_ai.asm:596-599)
s:act(function()
s:sayNext(s:romText("_AIBattleWithdrawText", "%s with-\ndrew %s!",
guestName, s.enemy.name))
s:actNext(function() sendOutGuest(s, guestParty[idx]) end)
end)
end
s:act(function()
+3 -3
View File
@@ -1711,8 +1711,8 @@ local function buildStartMenu()
function overrides.new(game)
local g = live() or game
local save = g and g.save
if save and save.startMenuIndex then
Start2.lastIndex = save.startMenuIndex
if g and g.startMenuIndex then
Start2.lastIndex = g.startMenuIndex
end
return newOrig(g, {
save = save,
@@ -1741,7 +1741,7 @@ COVERAGE["src.ui.StartMenu"] = {
new = "synthesises onClose (stack:pop) and onChoose "
.. "(Game2:openStartMenuItem); without them the menu cannot be left",
index = "the cursor is menu.list.index on Gold (a Chrome.List)",
["save.startMenuIndex"] = "copied INTO StartMenu.lastIndex on construct "
["game.startMenuIndex"] = "copied INTO StartMenu.lastIndex on construct "
.. "and never copied back: Gold's cursor is a class field, not save data",
tx = "the box is fixed at Chrome.box(10, 0, 10, h); tx/ty/tw/th/anchor/"
.. "maxVisible/startCloses/noSound do not exist and writes are inert",
+5 -1
View File
@@ -826,10 +826,14 @@ R.pokemon = {
item = f.opt(f.id("items")),
species = f.id("pokemon") }),
spriteFront = f.path, spriteBack = f.path, frontSize = f.int(1, 7),
-- text2 is the #DEX entry's second description page (Pokedex_asm's bare
-- `page` macro, engine/pokedex/pokedex.asm): PokedexMenu:drawEntryBody
-- shows `entry.text` on page 1 and `entry.text2` on page 2, so a
-- translation needs both to cover the whole entry.
dexEntry = f.opt(f.rec{ kind = f.str, heightFt = f.int(0),
heightIn = f.int(0, 11), weight = f.num,
heightM = f.opt(f.num), weightKg = f.opt(f.num),
text = f.str }),
text = f.str, text2 = f.opt(f.str) }),
icon = f.opt(f.union{ f.str, f.rec{ image = f.path,
frames = f.opt(f.int(1)) } }),
cry = f.opt(f.id("cries")), palette = f.opt(f.id("palettes")),
+13
View File
@@ -332,6 +332,7 @@ end
-- transparent: the world pass shows through (UI pass draws overlays only)
function Renderer:beginFrame(transparent)
self.uiOpaque = not transparent
self.worldActive = false
self.uprightActive = false
self.worldOverride = nil
@@ -699,6 +700,18 @@ function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
return
end
love.graphics.setShader(shader)
-- data/sgb/sgb_packets.asm:123-127: zone 1 is the whole-screen ATTR_BLK,
-- so the underpaint is only drawn when it leaves part of the box bare (#1866)
local first = zoneList[1]
if canvas == self.canvas and self.uiOpaque and first.colors
and not (bx + first.x * zoneSx <= boxX
and by + first.y * zoneSy <= boxY
and bx + (first.x + first.w) * zoneSx >= boxX + boxW
and by + (first.y + first.h) * zoneSy >= boxY + boxH) then
PaletteFX.sendColors(shader, first.colors)
love.graphics.setScissor(boxX, boxY, boxW, boxH)
love.graphics.draw(canvas, bx, by, 0, sx, sy)
end
-- a colors == false zone is the trueColor opt-out: its rect draws with
-- no shader at all. Nothing sets one without a mod, so a vanilla zone
-- list never toggles and issues exactly the calls it always did.
+87
View File
@@ -28,6 +28,52 @@ local MAX_COLS = 18
-- pokegold constants/ram_constants.asm: TEXT_DELAY_FAST/MED/SLOW = 1/3/5
local NAME_DELAYS = { FAST = 1, MID = 3, SLOW = 5 }
-- TextCommand_PAUSE (home/text.asm:492-504): a mid-string wait callers
-- embed as TextBox.PAUSE, stripped here and re-anchored after pagination.
TextBox.PAUSE = "\1"
local PAUSE_FRAMES = 30
local function glyphs(str)
return #Font.split((str:gsub("[\n\v\f]", "")))
end
local function stripPauses(text)
if not text:find(TextBox.PAUSE, 1, true) then return text, nil end
local out, marks, count, pos = {}, {}, 0, 1
while true do
local i = text:find(TextBox.PAUSE, pos, true)
if not i then
out[#out + 1] = text:sub(pos)
break
end
local chunk = text:sub(pos, i - 1)
out[#out + 1] = chunk
count = count + glyphs(chunk)
marks[#marks + 1] = count
pos = i + 1
end
return table.concat(out), marks
end
-- glyph offsets into the whole text -> [page][line][char]
local function mapPauses(pages, marks)
local at, acc, mi = {}, 0, 1
for pi, page in ipairs(pages) do
for li, line in ipairs(page) do
local n = #Font.split(line)
while mi <= #marks and marks[mi] <= acc + n do
local ci = marks[mi] - acc
at[pi] = at[pi] or {}
at[pi][li] = at[pi][li] or {}
at[pi][li][ci] = mi
mi = mi + 1
end
acc = acc + n
end
end
return at
end
-- opts.choice: when the last page has typed out, a YES/NO ChoiceBox pops
-- up over the still-visible text (YesNoChoicePokeCenter and friends);
-- the box then closes and choice(yes) runs instead of onDone.
@@ -66,6 +112,8 @@ function TextBox.new(game, text, onDone, opts)
self.stay = opts and opts.stay
-- engine/events/hidden_events/cinnabar_gym_quiz.asm:119
self.preSound = opts and opts.preSound
-- pokegold engine/overworld/scripting.asm:485 WaitSFX
self.sfxWait = opts and opts.sfxWait
-- opts.instant: put the LAST page up already typed, with no typewriter and
-- no page waits. A `yesorno` follows a `writetext` that has already been
-- read, so re-typing the line under the YES/NO box would be wrong -- the
@@ -81,7 +129,13 @@ function TextBox.new(game, text, onDone, opts)
self.line1Y = (self.boxTy + 2) * 8
self.line2Y = (self.boxTy + 4) * 8
text = TextBox.substitute(game, text)
local marks
text, marks = stripPauses(text)
self.pages = TextBox.paginate(text, self.maxCols)
-- opts.pauseSounds[i] is the sfx the i-th marker fires once its wait is
-- over (text_asm SFX_SWAP, engine/pokemon/learn_move.asm:210-213)
self.pauseSounds = opts and opts.pauseSounds
self.pauseAt = marks and mapPauses(self.pages, marks) or nil
self.pageIndex = 1
self.lineIndex = 1
self.charIndex = 0
@@ -276,6 +330,14 @@ function TextBox:visibleText()
return #out > 0 and out or nil
end
-- pokegold engine/overworld/scripting.asm:484-485 PlaySFX / WaitSFX
function TextBox:sfxHeld()
if not self.sfxWait then return false end
if require("src.core.Sound").sfxBusy() then return true end
self.sfxWait = nil
return false
end
function TextBox:update(dt)
local input = self.game.input
self.blink = (self.blink + 1) % 60
@@ -297,6 +359,20 @@ function TextBox:update(dt)
self.holdFrames = self.holdFrames - 1
return
end
-- home/text.asm:492
if self.pauseFrames then
if self.pauseFrames > 0 then
self.pauseFrames = self.pauseFrames - 1
return
end
self.pauseFrames = nil
local snd = self.pauseSounds and self.pauseSounds[self.pauseMark]
if type(snd) == "function" then
snd()
elseif snd then
require("src.core.Sound").play(self.game.data, snd)
end
end
if self.done then
-- opts.stay: the box is finished but stays up under whatever the caller
-- pushed over it; StateStack updates the top state only, so this runs
@@ -378,6 +454,7 @@ function TextBox:update(dt)
end
return
end
if self:sfxHeld() then return end
if input:wasPressed("a") or input:wasPressed("b") then
require("src.core.Sound").play(self.game.data, "Press_AB")
self.game.stack:pop()
@@ -393,6 +470,7 @@ function TextBox:update(dt)
self.preWait = self.preWait - 1
return
end
if self:sfxHeld() then return end
if input:wasPressed("a") or input:wasPressed("b") then
require("src.core.Sound").play(self.game.data, "Press_AB")
self.waiting = false
@@ -430,6 +508,15 @@ function TextBox:update(dt)
self.charIndex = self.charIndex + 1
local line = self.shown[#self.shown]
line[#line + 1] = self.codes[self.charIndex]
local marks = self.pauseAt and self.pauseAt[self.pageIndex]
marks = marks and marks[self.lineIndex]
if marks and marks[self.charIndex] then
self.pauseMark = marks[self.charIndex]
-- TextCommand_PAUSE reads hJoyHeld, so a held A/B skips the wait
self.pauseFrames = (input:isDown("a") or input:isDown("b"))
and 0 or PAUSE_FRAMES
break
end
else
-- line finished
local page = self.pages[self.pageIndex]
+3 -6
View File
@@ -150,13 +150,10 @@ end
-- true while the spinner arrow tiles should show the 'blur' graphic; false
-- means draw nothing extra (the static window tile shows through,
-- matching the asm's restore-to-original behavior). The 8-tick
-- half-period approximates one GB movement step (2px/frame); this is a
-- deliberate approximation of wSimulatedJoypadStatesIndex bit-0 parity, not
-- a cycle-accurate replication -- the port's tweened scriptMove has no
-- direct equivalent discrete step counter.
-- matching the asm's restore-to-original behavior).
-- spinners.asm:17-22, home/overworld.asm:1844-1846, :49-52
function TileRenderer.spinBlurActive()
return spinning and (math.floor(animFrame / 8) % 2 == 0)
return spinning and (math.floor(animFrame / 16) % 2 == 0)
end
-- ------------------------------------------------------------------
+33 -8
View File
@@ -235,6 +235,35 @@ local function gen2CartName(gameVersion)
return GameVersion.info(gameVersion).displayName
end
-- Can a cart save for this game cross in or out at all? Public because the
-- launcher has to ask about the GAME before it measures the BYTES.
--
-- SaveFileIO.importToSlot judges anything that is not exactly SAVE_SIZE with
-- mainChecksumValid, which is pokered's main-data checksum. A Gen 2 cart is
-- MBC3+TIMER, so a real Gold/Silver/Crystal .sav carries an RTC footer and is
-- 32786 bytes: it misses the size test and is then measured against a rule
-- that was never going to match it. The player is told "save data checksum
-- invalid" about a save that is perfectly good (#1832).
--
-- Returns true, or false plus the same sentence importSav/exportSav would have
-- answered with, so a caller that asks early and a caller that does not cannot
-- describe the same game two different ways.
function SaveConvert.importSupported(gameVersion)
local gen2Name = gen2CartName(gameVersion)
if gen2Name then
return false, gen2Name .. " uses a Gen 2 cart save; importing one is not supported yet."
end
return true
end
function SaveConvert.exportSupported(gameVersion)
local gen2Name = gen2CartName(gameVersion)
if gen2Name then
return false, gen2Name .. " uses a Gen 2 cart save; exporting one is not supported yet."
end
return true
end
-- importSav(bytes, version, gameVersion) -> saveTable, err
-- bytes: the raw 32768-byte SRAM string. Validates size and the main-data
-- checksum, decodes through GenSave, and returns a save table fully merged
@@ -247,10 +276,8 @@ function SaveConvert.importSav(bytes, version, gameVersion)
if type(bytes) ~= "string" then
return nil, "expected raw save bytes as a string"
end
local gen2Name = gen2CartName(gameVersion)
if gen2Name then
return nil, gen2Name .. " uses a Gen 2 cart save; importing one is not supported yet."
end
local supported, unsupportedWhy = SaveConvert.importSupported(gameVersion)
if not supported then return nil, unsupportedWhy end
if #bytes ~= GenSave.SAVE_SIZE then
return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes)
end
@@ -283,10 +310,8 @@ function SaveConvert.exportSav(saveTable, gameVersion)
if type(saveTable) ~= "table" then
return nil, "expected a save table"
end
local gen2Name = gen2CartName(gameVersion)
if gen2Name then
return nil, gen2Name .. " uses a Gen 2 cart save; exporting one is not supported yet."
end
local supported, unsupportedWhy = SaveConvert.exportSupported(gameVersion)
if not supported then return nil, unsupportedWhy end
local data, derr = ensureData(gameVersion)
if not data then return nil, derr end
local ok, bytes = pcall(GenSave.encode, saveTable, data, nil)
+6 -5
View File
@@ -451,12 +451,13 @@ function H.CutDownTreeOrGrass(ctx)
end
-- DisappearWhirlpool is CutDownTreeOrGrass with PlayWhirlpoolSound in place of
-- OWCutAnimation: the same block write, the same redraw, then the surf wash the
-- port owns as World:playWhirlpoolSound. #1717
-- OWCutAnimation, and the block only reaches the screen after the sound
-- -- engine/events/overworld.asm:1157-1164 (#1717, #1862)
function H.DisappearWhirlpool(ctx)
local ret = H.CutDownTreeOrGrass(ctx)
call(ctx, "playWhirlpoolSound")
return ret
local index = ctx and ctx.cutWhirlpoolBlockIndex
local blockId = ctx and ctx.cutWhirlpoolReplacement
call(ctx, "playWhirlpoolSound", index, blockId)
return nil
end
-- BlindingFlash sets STATUSFLAGS_FLASH_F and reloads the palettes. Setting
+12 -6
View File
@@ -152,6 +152,12 @@ local function answer(vm, value)
vm.scriptVar = value or 0
end
-- WaitSFX (pokegold home/audio.asm); a test stub that calls a handler off
-- the coroutine has no sfx to drain.
local function drainSfx()
if coroutine.running() then coroutine.yield({ kind = "waitsfx" }) end
end
-- Every routine that ends `call GetPokemonName / jp
-- CopyPokemonName_Buffer1_Buffer3` puts a name where the next writetext's
-- {STRBUF} will find it.
@@ -452,8 +458,10 @@ H.BugContestJudging = function(vm)
local what = (h.monName and h.monName(entry.species)) or entry.species
or ""
vm:showRaw(Strings(row.page, who, what))
-- pokegold engine/events/bug_contest/judging.asm:29-32
drainSfx()
if h.playSfxNamed then h.playSfxNamed(row.sfx) end
vm:showRaw(Strings(row.score, entry.score or 0))
vm:showRaw(Strings(row.score, entry.score or 0), nil, nil, true)
end
end
answer(vm, place)
@@ -2033,12 +2041,10 @@ H.ProfOaksPCBoot = function(vm)
vm:showRaw(Strings(OAK_PC_TEXT.counts, seen, caught))
local rating = findOakRating(caught)
local h = hooks(vm)
-- pokegold engine/events/prof_oaks_pc.asm:18-20 PlaySFX / JoyWaitAorB / WaitSFX
drainSfx()
if h.playSfxNamed then h.playSfxNamed(rating.sfx) end
vm:showRaw(Strings(rating.text))
-- `call PlaySFX / call JoyWaitAorB / call WaitSFX`: the fanfare is left
-- playing under the rating text, and the caller (OaksLab's own script)
-- waits it out before closing the box.
coroutine.yield({ kind = "waitsfx" })
vm:showRaw(Strings(rating.text), nil, nil, true)
end
-- The whose-PC menu's PROF.OAK's PC row (src/ui/gen2/CenterPcMenu.lua) runs
+10 -6
View File
@@ -700,8 +700,10 @@ local function runCmd(self, cmd, op)
-- GetPocketName fills from ItemPocketNames: KEY ITEMs, BALLs and TMs
-- name their own pocket, not the ITEM one (data/text/common_2.asm
-- :1351, data/items/pocket_names.asm:10-13).
-- Script_specialsound's WaitSFX (scripting.asm:485): the box holds
-- its press until the jingle ends.
self:showRaw(Strings("{PLAYER} put the\n%s in\nthe %s.",
name, self:pocketName(item)))
name, self:pocketName(item)), nil, nil, true)
else
self:showRaw(Strings("The %s\nis full…", self:pocketName(item)))
end
@@ -1510,8 +1512,8 @@ local function runCmd(self, cmd, op)
self.playSoundFn(SFX_ITEM)
end
-- FruitTreeScript's tail is `specialsound / itemnotify` with NOTHING
-- between them (engine/events/fruit_trees.asm:23-24), and Script_specialsound
-- is a bare PlaySFX -- it does not wait either (scripting.asm:476-483).
-- between them (engine/events/fruit_trees.asm:23-24);
-- Script_specialsound ends PlaySFX / WaitSFX (scripting.asm:484-485)
-- The port used to park here on a `waitsfx`, which is the same seam
-- GiveItemScript's did: this port's box takes its own button and pops on
-- it, so the park ran with an EMPTY state stack and the bare overworld
@@ -1523,7 +1525,7 @@ local function runCmd(self, cmd, op)
-- noun still comes from ItemPocketNames rather than from a third copy of
-- the literal (data/items/pocket_names.asm:10-13).
self:showRaw(Strings("{PLAYER} put the\n%s in\nthe %s.",
name, self:pocketName(item)))
name, self:pocketName(item)), nil, nil, true)
return "end"
elseif op == "describedecoration" then
-- `describedecoration byte` picks one of five DECODESC_* arms
@@ -2427,7 +2429,7 @@ end
-- the port's world does not tick while a box is on the stack (Game2:update
-- stops at the top state), so the only clock that can count it is the box's;
-- World:showText is where it lands.
function Vm:showRaw(body, stay, hold)
function Vm:showRaw(body, stay, hold, sfxWait)
if not body or body == "" then body = "..." end
if self.stringBuffer and self.stringBuffer ~= "" then
body = body:gsub("{STRBUF}", self.stringBuffer)
@@ -2438,6 +2440,8 @@ function Vm:showRaw(body, stay, hold)
text = body,
stay = (stay or self:textStays()) and true or false,
hold = hold,
-- pokegold engine/overworld/scripting.asm:485 WaitSFX
sfxWait = sfxWait and true or nil,
})
end
end
@@ -2646,7 +2650,7 @@ function Vm:resume(resumeValue)
if req and req.kind == "text" and self.showTextFn then
self.showTextFn(req.text, function()
self:resume()
end, req.stay, req.hold)
end, req.stay, req.hold, req.sfxWait)
elseif req and req.kind == "wait" then
self.waitLeft = req.frames or 0
elseif req and req.kind == "waitbutton" then
+3 -1
View File
@@ -360,6 +360,8 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
-- .useItem_partyMenu jumps back to StartMenu_Item once UseItem
-- returns, cursor still on the candy (start_sub_menus.asm) -- so
-- mashing A burns through a stack of them (#796)
-- RareCandyText carries sound_get_item_1
-- (engine/menus/party_menu.asm:289-293)
showMessages(game, payload, function()
local StatBox = require("src.battle.BattleState").StatBox
game.stack:push(StatBox.new(game, target, function()
@@ -400,7 +402,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
end
nextStep()
end))
end)
end, TextBox.soundOpts(game, "Get_Item1"))
return
end
-- HP medicine: fill the bar in the still-open picker first, then print
+85 -41
View File
@@ -13,9 +13,21 @@ local Strings = require("src.core.Strings")
local BoxMenu = {}
local function monLabel(game, mon)
-- PrintListMenuEntries prints the nickname at hlcoord 6,4 and PrintLevel
-- one row down, 8 columns right (home/list_menu.asm:364-365, 459-461)
local function monRow(game, mon, value)
local def = game.data.pokemon[mon.species]
return Strings("%s :L%d", mon.nickname or def.name, mon.level)
return {
label = mon.nickname or def.name,
sub = Strings(":L%d", mon.level),
value = value,
}
end
-- the $ff terminator's row (home/list_menu.asm:371-372, 523-528)
local function withCancel(items)
items[#items + 1] = { cancel = true, label = Strings("CANCEL") }
return items
end
local function monName(game, mon)
@@ -61,14 +73,15 @@ local function withdraw(game)
return
end
local items = {}
for i, mon in ipairs(box) do
table.insert(items, { label = monLabel(game, mon), value = i })
end
game.stack:push(ListMenu.new(game,
Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, {
for i, mon in ipairs(box) do table.insert(items, monRow(game, mon, i)) end
game.stack:push(ListMenu.new(game, nil, withCancel(items), {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
kind = "pc_box_withdraw",
-- DisplayMonListMenu draws LIST_MENU_BOX over the PC menu, which stays
-- visible around it (home/list_menu.asm:29-31)
itemBox = true,
onChoose = function(item, list)
if item.cancel then list:close() return end
local mon = box[item.value]
if not mon then return end
monSubmenu(game, "WITHDRAW", mon, function()
@@ -111,12 +124,14 @@ local function deposit(game)
end
local items = {}
for i, mon in ipairs(game.save.party) do
table.insert(items, { label = monLabel(game, mon), value = i })
table.insert(items, monRow(game, mon, i))
end
game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", items, {
game.stack:push(ListMenu.new(game, nil, withCancel(items), {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
kind = "pc_box_deposit",
itemBox = true,
onChoose = function(item, list)
if item.cancel then list:close() return end
local mon = game.save.party[item.value]
if not mon then return end
local Follower = require("src.world.PikachuFollower")
@@ -163,14 +178,13 @@ local function release(game)
return
end
local items = {}
for i, mon in ipairs(box) do
table.insert(items, { label = monLabel(game, mon), value = i })
end
game.stack:push(ListMenu.new(game,
Strings("BOX %d (RELEASE)", game.save.currentBox), items, {
for i, mon in ipairs(box) do table.insert(items, monRow(game, mon, i)) end
game.stack:push(ListMenu.new(game, nil, withCancel(items), {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
kind = "pc_box_release",
onChoose = function(_, list)
itemBox = true,
onChoose = function(item, list)
if item.cancel then list:close() return end
local mon = box[list.index]
if not mon then return end
local name = monName(game, mon)
@@ -202,37 +216,67 @@ local function release(game)
}))
end
local function changeBox(game)
-- DisplayChangeBoxMenu: engine/menus/save.asm:437-506
local function changeBoxMenu(game)
local boxes = Boxes.ensure(game.save)
local items = {}
for i = 1, Boxes.COUNT do
local mark = i == game.save.currentBox and "*" or " "
table.insert(items, {
label = Strings("%sBOX %2d", mark, i),
right = ("%d/%d"):format(#boxes[i], Boxes.CAPACITY),
value = i,
})
items[i] = {
label = Strings("BOX%2d", i),
onSelect = function()
game.save.currentBox = i
if game.writeSave then game:writeSave() end
-- ChangeBox (engine/menus/save.asm) rings SFX_SAVE after SaveGameData (#1044)
require("src.core.Sound").play(game.data, "Save")
end,
}
end
game.stack:push(ListMenu.new(game, "CHANGE BOX", items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
kind = "pc_box_change",
onChoose = function(item, list)
-- the original asks BEFORE switching ("When you change a #MON
-- BOX, data will be saved. OK?"); declining aborts the change
game.stack:push(TextBox.new(game,
Strings("When you change a\nPOKéMON BOX, data\nwill be saved. OK?"), nil, {
noSound = true,
choice = function(yes)
if not yes then return end
game.save.currentBox = item.value
if game.writeSave then game:writeSave() end
-- ChangeBox (engine/menus/save.asm) rings SFX_SAVE after SaveGameData (#1044)
require("src.core.Sound").play(game.data, "Save")
list:close()
end,
}))
local menu = Menu.new(game, items, {
tx = 11, ty = 0, tw = 9, th = 14, rowStep = 1, itemY = 1, noSound = true,
})
menu.kind = "pc_box_change" -- screen.render_visible identity
menu.index = math.max(1, math.min(Boxes.COUNT, game.save.currentBox or 1))
local t = game.data.text
local baseDraw = menu.draw
function menu:draw()
-- ChooseABoxText goes to the standard box, over BillsPCMenu's "What?"
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
local y = 112
for line in ((t._ChooseABoxText or Strings("Choose a\nPOKéMON BOX."))
.. "\n"):gmatch("([^\n]*)\n") do
Font.draw(line, 8, y)
y = y + 16
end
Font.drawBox(0, 0, 11, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("BOX No."), 8, 16)
local n = game.save.currentBox or 1
Font.draw(tostring(n), n >= 10 and 64 or 72, 16)
baseDraw(self)
love.graphics.setColor(0, 0, 0, 1)
for i = 1, Boxes.COUNT do
if #boxes[i] > 0 then ListMenu.drawBall(148, i * 8 + 4) end
end
love.graphics.setColor(1, 1, 1, 1)
end
game.stack:push(menu)
end
-- ChangeBox (engine/menus/save.asm:358-368) asks before showing the menu
-- and returns to the PC menu on No.
local function changeBox(game)
local t = game.data.text
local ask = TextBox.new(game, t._WhenYouChangeBoxText
or Strings("When you change a\nPOKéMON BOX, data\vwill be saved.\fIs that okay?"),
nil, {
noSound = true,
choice = function(yes)
if yes then changeBoxMenu(game) end
end,
}))
})
ask.kind = "pc_box_change"
game.stack:push(ask)
end
-- bills_pc.asm BillsPCMenu chrome: What? text box + BOX No. overlay
+37 -5
View File
@@ -87,6 +87,15 @@ local function frameSheet(game)
return frameCache[path]
end
-- one tile of the sheet, for the CONTENTS screen's divider column
-- (engine/menus/pokedex.asm:350, DrawPokedexVerticalLine)
function DexEntryMenu.tile(game, code, tx, ty)
local frame = frameSheet(game)
if not (frame and code) then return end
local quad = frame.quads[code - 0x60]
if quad then love.graphics.draw(frame.img, quad, tx * 8, ty * 8) end
end
-- engine/menus/pokedex.asm:601
local DIVIDER = {
0x68, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x6B,
@@ -106,14 +115,28 @@ function DexEntryMenu.new(game, speciesOrOpts, onDone)
self.sprite = ok and img or nil
self.spriteTrueColor = self.sprite and trueColor or false
self.page = 1
self.blink = 0
local pages = descPages(game, self.def, forceOwned)
self.pageCount = pages and #pages or 1
require("src.core.Sound").playCry(game.data, species)
-- engine/menus/pokedex.asm:500-506, home/pokemon.asm:145-148
self.crySrc = require("src.core.Sound").playCry(game.data, species)
return self
end
-- home/pokemon.asm:148 (jp WaitForSoundToFinish)
function DexEntryMenu:crying()
local src = self.crySrc
if not src then return false end
local ok, playing = pcall(src.isPlaying, src)
if ok and playing then return true end
self.crySrc = nil
return false
end
function DexEntryMenu:update(dt)
local input = self.game.input
self.blink = ((self.blink or 0) + 1) % 60
if self:crying() then return end
if input:wasPressed("a") or input:wasPressed("b") then
-- home/text.asm:245
if self.page < (self.pageCount or 1) then
@@ -127,15 +150,18 @@ end
function DexEntryMenu:draw()
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned,
self.spriteTrueColor, self.page)
self.spriteTrueColor, self.page,
{ crying = self:crying(),
arrow = (self.blink or 0) < 30 })
end
-- Static entry-page renderer, shared with the printer stand-in
-- (src/core/Printer.lua renders the same page into a PNG the way
-- PrintPokedexEntry rendered it to the Game Boy Printer).
-- engine/menus/pokedex.asm:399
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page)
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page, state)
page = page or 1
state = state or {}
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local frame = frameSheet(game)
@@ -185,6 +211,12 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page)
Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0),
16, 64)
local owned = ownedFor(game, def, forceOwned)
-- engine/menus/pokedex.asm:516: everything below the divider waits on the
-- cry the line above it started
if state.crying then
love.graphics.setColor(1, 1, 1, 1)
return
end
-- engine/menus/pokedex.asm:449, numbers only once owned
if owned and e.heightFt then
if e.heightM then
@@ -202,8 +234,8 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page)
for i, line in ipairs(lines) do
Font.draw(line, 8, 72 + i * 16)
end
-- home/text.asm:245
if page < #pages then
-- home/text.asm:245 places the '▼' and home/joypad2.asm:55-83 blinks it
if page < #pages and state.arrow ~= false then
Font.drawCode(Theme.moreArrow, 144, 128)
end
else
+41 -26
View File
@@ -120,8 +120,9 @@ function ListMenu.new(game, title, items, opts)
-- so their lists opt out of the A/B beep the same way Menu's noSound does
self.noSound = opts.noSound or false
-- the bag's item list: a partial box the map stays visible around, not a
-- screen of its own (home/list_menu.asm:29-31)
self.itemBox = opts.itemBox or false
-- screen of its own (home/list_menu.asm:29-31). Every DisplayListMenuID
-- caller gets the same box, the PC item lists included (#1845).
self.itemBox = opts.itemBox or opts.messageBox or false
if self.itemBox then
self.isOpaque = false
-- keep RunDefaultPaletteCommand's last palette: ItemMenuLoop never sets
@@ -256,6 +257,34 @@ function ListMenu:close()
if top == self then self.game.stack:pop() end
end
-- standard bottom text box (PrintText); long prompts wrap and keep
-- their last two lines, like the GB's scrolled box (#115/#174)
local function drawMessageBox(self)
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
if not self.footer then return end
local flat = {}
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
for _, line in ipairs(page) do flat[#flat + 1] = line end
end
local y = 112
for i = math.max(1, #flat - 1), #flat do
Font.draw(flat[i], 8, y)
y = y + 16
end
end
-- the Pokédex owned-ball marker tile, also the CHANGE BOX screen's
-- PokeballTileGraphics marker (engine/menus/save.asm:495-499)
function ListMenu.drawBall(x, y)
local r, g, b, a = love.graphics.getColor()
love.graphics.circle("fill", x, y, 3.5)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", x - 3.5, y - 0.5, 7, 1)
love.graphics.circle("fill", x, y, 1.2)
love.graphics.setColor(r, g, b, a)
end
-- PrintListMenuEntries, minus the price column StartMenu_Item never asks for
-- (wPrintItemPrices = 0, engine/menus/start_sub_menus.asm)
function ListMenu:drawItemBox()
@@ -274,7 +303,10 @@ function ListMenu:drawItemBox()
if item.cancel then sawCancel = true end
local y = ITEM_TOP_Y + (row - 1) * 16
Font.draw(item.label, ITEM_NAME_X, y)
if item.right then
if item.sub then
-- PrintLevel, one row down and 8 columns right (home/list_menu.asm:459-461)
Font.draw(item.sub, ITEM_QTY_X, y + 8)
elseif item.right then
-- '×' at column 14, PrintNumber's two right-aligned digits after it
-- (home/list_menu.asm:479-490)
local count = item.right:sub(2)
@@ -294,6 +326,9 @@ function ListMenu:drawItemBox()
if shown == self.rows and not sawCancel then
Font.drawCode(Theme.moreArrow, ITEM_MORE_X, ITEM_MORE_Y)
end
-- players_pc.asm:97/151/205 PrintText the prompt before DisplayListMenuID,
-- so the bottom box sits under the list from the first frame
if self.messageBox or self.footer then drawMessageBox(self) end
love.graphics.setColor(1, 1, 1, 1)
end
@@ -326,13 +361,7 @@ function ListMenu:draw()
-- one blank glyph after the name, measured in glyph advances rather
-- than bytes: NIDORAN♂/♀ carry a multi-byte charmap entry, so
-- `#item.label` overcounted by 2 and pushed their ball 16px right (#285)
local bx = 16 + Font.width(label) + 8 + 3
local by = y + 3
love.graphics.circle("fill", bx, by, 3.5)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", bx - 3.5, by - 0.5, 7, 1)
love.graphics.circle("fill", bx, by, 1.2)
love.graphics.setColor(unpack(textColor))
ListMenu.drawBall(16 + Font.width(label) + 8 + 3, y + 3)
end
if item.right then
Font.draw(item.right, 160 - 8 - Font.width(item.right), y)
@@ -360,22 +389,8 @@ function ListMenu:draw()
local money = ("¥%d"):format(self.money and self.money() or 0)
Font.draw(money, 152 - Font.width(money), 8)
end
if self.dialogue or (self.messageBox and self.footer) then
-- standard bottom text box (PrintText); long prompts wrap and keep
-- their last two lines, like the GB's scrolled box (#115/#174)
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
if self.footer then
local flat = {}
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
for _, line in ipairs(page) do flat[#flat + 1] = line end
end
local y = 112
for i = math.max(1, #flat - 1), #flat do
Font.draw(flat[i], 8, y)
y = y + 16
end
end
if self.dialogue then
drawMessageBox(self)
elseif self.footer then
-- bare footer (bag money line, etc.)
local flat = {}
+5 -2
View File
@@ -120,14 +120,17 @@ function MoveLearnMenu:finish(learned)
local opts
if learned then
-- pokered pages this as four texts in a row; _ForgotAndText carries
-- the "And..." tail
-- the "And..." tail. Each text_pause holds the box, and the first one
-- is followed by SFX_SWAP (engine/pokemon/learn_move.asm:208-222).
msg = romText(game.data, "_OneTwoAndText", "1, 2 and...")
.. TextBox.PAUSE
.. romText(game.data, "_PoofText", " Poof!")
.. TextBox.PAUSE
.. romText(game.data, "_ForgotAndText",
"\f%s forgot\n%s!\fAnd...", name, self.forgot)
.. "\f" .. romText(game.data, "_LearnedMove1Text",
"%s learned\n%s!", name, mdef.name)
opts = { auto = { sound = function()
opts = { pauseSounds = { "Swap" }, auto = { sound = function()
return require("src.core.Sound").play(game.data, self.learnedSound)
end, wait = true } }
else
+21 -5
View File
@@ -71,6 +71,12 @@ function PartyMenu:sgbPalettes(game)
return zones
end
-- data/moves/field_moves.asm: leftmost tile per field move name
local FIELD_MOVE_X = {
cut = 12, fly = 12, surf = 12, flash = 12, DIG = 12,
strength = 10, TELEPORT = 10, softboiled = 8,
}
local function sameItems(_, items) return items end
local function followerUnavailable(game, mon)
@@ -706,6 +712,9 @@ function PartyMenu:update(dt)
-- (text_box.asm .donePrintingNames). #768
items[#items + 1] = { label = Strings("STATS"), action = "stats" }
items[#items + 1] = { label = Strings("SWITCH"), action = "switch" }
-- CANCEL closes the whole party menu (start_sub_menus.asm:71-75
-- .exitMenu), where B returns to the party list. #1833
items[#items + 1] = { label = Strings("CANCEL"), action = "cancel" }
end
local ctx = { battle = self.battle, overworld = ow }
local hooked = Runtime.call("ui.party.submenu", sameItems,
@@ -873,12 +882,19 @@ function PartyMenu:draw()
end
if self.submenu then
local n = #self.subItems
Font.drawBox(9, 17 - n * 2 - 1, 11, n * 2 + 1)
local y0 = (17 - n * 2) * 8
for si, entry in ipairs(self.subItems) do
Font.draw(entry.label, 88, y0 + (si - 1) * 16)
-- engine/menus/text_box.asm:397-440, data/text_boxes.asm:33 #1819
local lx = 12
for _, entry in ipairs(self.subItems) do
local mx = FIELD_MOVE_X[entry.move or entry.action]
if mx and mx < lx then lx = mx end
end
Font.drawCode(Theme.cursor, 80, y0 + (self.subIndex - 1) * 16)
local top = n > 3 and math.max(0, 16 - n * 2) or 11
Font.drawBox(lx - 1, top, 21 - lx, 18 - top)
local y0 = (18 - n * 2) * 8
for si, entry in ipairs(self.subItems) do
Font.draw(entry.label, (lx + 1) * 8, y0 + (si - 1) * 16)
end
Font.drawCode(Theme.cursor, lx * 8, y0 + (self.subIndex - 1) * 16)
end
love.graphics.setColor(1, 1, 1, 1)
end
+46 -3
View File
@@ -11,6 +11,8 @@ local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu")
local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
local TextBox = require("src.render.TextBox")
local romText = require("src.core.RomText")
local PlayerPC = {}
@@ -36,9 +38,20 @@ local function buildItems(game, store, order)
})
end
end
-- the $ff terminator's row (home/list_menu.asm:371-372, 523-528)
items[#items + 1] = { cancel = true, label = Strings("CANCEL") }
return items
end
-- A on CANCEL leaves the list exactly like B (home/list_menu.asm:105-110)
local function leftOnCancel(item, list)
if item and item.cancel then
list:close()
return true
end
return false
end
-- Ask "How many?" (DepositHowManyText/WithdrawHowManyText →
-- DisplayChooseQuantityMenu) capped at the stack count. Key items and
-- HMs always move one, with no prompt (IsKeyItem in players_pc.asm).
@@ -76,11 +89,21 @@ end
local function withdraw(game)
local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), {
-- players_pc.asm:144-149: an empty list is never opened
if next(pc) == nil then
game.stack:push(TextBox.new(game, romText(game.data, "_NothingStoredText",
"There is nothing\nstored."), nil, { noSound = true }))
return
end
game.stack:push(ListMenu.new(game, nil, buildItems(game, pc), {
kind = "pc_item_withdraw",
messageBox = true,
-- players_pc.asm:151-152 WhatToWithdrawText, printed before the list
footer = romText(game.data, "_WhatToWithdrawText",
"What do you want\nto withdraw?"),
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
if leftOnCancel(item, list) then return end
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
local Bag = require("src.inventory.Bag")
if not Bag.add(game.save, item.value, qty, game.data) then
@@ -112,11 +135,21 @@ local function deposit(game)
local Bag = require("src.inventory.Bag")
-- engine/menus/players_pc.asm:99 wListPointer = wNumBagItems, so deposit order == bag order
local order = Bag.order(game.save, game.data)
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, inv, order), {
-- players_pc.asm:90-95: an empty bag never reaches the list
if #order == 0 then
game.stack:push(TextBox.new(game, romText(game.data, "_NothingToDepositText",
"You have nothing\nto deposit."), nil, { noSound = true }))
return
end
game.stack:push(ListMenu.new(game, nil, buildItems(game, inv, order), {
kind = "pc_item_deposit",
messageBox = true,
-- players_pc.asm:97-98 WhatToDepositText, printed before the list
footer = romText(game.data, "_WhatToDepositText",
"What do you want\nto deposit?"),
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
if leftOnCancel(item, list) then return end
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
if pcFull(game, pc, item.value) then
list.footer = Strings("No room left to\nstore items.")
@@ -134,11 +167,21 @@ end
local function toss(game)
local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), {
-- players_pc.asm:196-201: an empty list is never opened
if next(pc) == nil then
game.stack:push(TextBox.new(game, romText(game.data, "_NothingStoredText",
"There is nothing\nstored."), nil, { noSound = true }))
return
end
game.stack:push(ListMenu.new(game, nil, buildItems(game, pc), {
kind = "pc_item_toss",
messageBox = true,
-- players_pc.asm:205-206 WhatToTossText, printed before the list
footer = romText(game.data, "_WhatToTossText",
"What do you want\nto toss away?"),
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
if leftOnCancel(item, list) then return end
local def = game.data.items[item.value]
if (def and def.keyItem) or item.value:find("^HM_") then
list.footer = Strings("That's too impor-\ntant to toss!")
+273 -82
View File
@@ -1,9 +1,38 @@
-- Minimal Pokédex: dex-ordered list with seen/owned markers.
-- Pokédex CONTENTS screen: engine/menus/pokedex.asm:157-348
-- (Yellow: pokeyellow engine/menus/pokedex.asm:263-336).
local ListMenu = require("src.ui.ListMenu")
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local Theme = require("src.ui.Theme")
local PokedexMenu = {}
PokedexMenu.__index = PokedexMenu
PokedexMenu.isOpaque = true
-- engine/menus/pokedex.asm:226 (`ld d, 7`), :21 (top menu item Y 3, X 0)
local ROWS = 7
local FIRST_ROW_Y = 24
local NUM_X, BALL_X, NAME_X, CURSOR_X = 8, 24, 32, 0
-- engine/menus/pokedex.asm:163-199
-- (Yellow: pokeyellow engine/menus/pokedex.asm:266-306)
local RED_ROWS = { seen = 2, own = 5, rule = 8, items = 10 }
local YELLOW_ROWS = { seen = 1, own = 4, rule = 6, items = 8 }
-- engine/menus/pokedex.asm:350 (DrawPokedexVerticalLine): column 14, $71
-- toggling to $70 down each nine-tile run, both runs restarting on $71
local function dividerCodes()
local codes = { [0] = 0x71 }
for _, top in ipairs({ 1, 9 }) do
local code = 0x71
for i = 0, 8 do
codes[top + i] = code
code = code == 0x71 and 0x70 or 0x71
end
end
return codes
end
local DIVIDER = dividerCodes()
-- SGB: PalPacket_Pokedex, whole screen
function PokedexMenu:sgbPalettes(game)
@@ -12,108 +41,270 @@ end
function PokedexMenu.new(game, opts)
opts = opts or {}
local self = setmetatable({}, PokedexMenu)
self.game = game
self.onCancel = opts.onCancel -- B returns to the start menu when opened from it
self.index = 1
self.scroll = 0
self.rowsAt = require("src.core.GameVersion").isYellow()
and YELLOW_ROWS or RED_ROWS
local dex = game.save.pokedex or { seen = {}, owned = {} }
local byDex = {}
for species, def in pairs(game.data.pokemon) do
if def.dex then byDex[def.dex] = def end
end
local items = {}
local seen, owned = 0, 0
-- dex bound and number width come from constants; the fallbacks keep a
-- cache imported before those keys existed on the Kanto numbering
local constants = game.data.constants or {}
local numFmt = ("%%0%dd"):format(constants.dexDigits or 3)
for n = 1, constants.dexSize or 151 do
local dexSize = constants.dexSize or 151
-- engine/menus/pokedex.asm:200-216: the list stops at wDexMaxSeenMon, the
-- highest number the player has seen, not at the end of the roster
local maxSeen = 0
local seen, owned = 0, 0
for n = 1, dexSize do
local def = byDex[n]
if def then
local label
if dex.owned[def.id] then
label = (numFmt .. " %s"):format(n, def.name)
owned = owned + 1
seen = seen + 1
maxSeen = n
elseif dex.seen[def.id] then
label = (numFmt .. " %s"):format(n, def.name)
seen = seen + 1
else
label = (numFmt .. " -----"):format(n)
maxSeen = n
end
table.insert(items, {
label = label,
end
end
local items = {}
for n = 1, math.min(dexSize, maxSeen) do
local def = byDex[n]
if def then
local known = dex.owned[def.id] or dex.seen[def.id]
-- engine/menus/pokedex.asm:265 (.dashedLine) for anything unseen
local name = known and def.name or "----------"
items[#items + 1] = {
num = numFmt:format(n),
name = name,
label = (numFmt .. " %s"):format(n, name),
-- owned entries carry the pokéball marker like the original
-- list; seen-only entries are just the name
ball = dex.owned[def.id] or nil,
value = (dex.owned[def.id] or dex.seen[def.id]) and def.id or nil,
})
value = known and def.id or nil,
}
end
end
local list = ListMenu.new(game, "POKéDEX", items, {
-- SEEN / OWN in the original's fixed three-digit field: engine/menus/
-- pokedex.asm HandlePokedexListMenu prints both counts with
-- `lb bc, 1, 3` (hlcoord 16,3 and 16,6), labelled by PokedexSeenText
-- and PokedexOwnText ("OWN", not "OWNED"). The width is load bearing
-- here: a bare ListMenu footer goes through the 18-column text wrap,
-- so the old 19-glyph "SEEN 100 OWNED 100" split in two and its first
-- half landed on the list's last row at y=120 (#639).
footer = Strings("SEEN %3d OWN %3d", seen, owned),
pageJump = true, -- Left/Right page jumps like the original
onCancel = opts.onCancel, -- B returns to the start menu when opened from it
onChoose = function(item, dexList)
if not item.value then return end
-- the DATA / CRY / AREA / QUIT choice (engine/menus/pokedex.asm
-- PokedexMenuItemsText); CRY keeps the side menu open like the
-- original. B is what returns to the list: HandlePokedexSideMenu
-- hands back b=2 for B and b=1 for QUIT, and ShowPokedexMenu sends
-- b=1 to .exitPokedex, so QUIT closes the whole Pokédex (#571)
local Menu = require("src.ui.Menu")
local Screens = require("src.ui.Screens")
local entries = {
{ label = Strings("DATA"), onSelect = function()
Screens.push(game, "DexEntryMenu", item.value)
end },
{ label = Strings("CRY"), keepOpen = true, onSelect = function()
require("src.core.Sound").playCry(game.data, item.value)
end },
{ label = Strings("AREA"), onSelect = function()
Screens.push(game, "TownMap", { nestSpecies = item.value })
end },
}
-- Yellow's PRNT item (engine/menus/pokedex.asm PokedexMenuItemsText
-- _YELLOW branch -> PrintPokedexEntry): the Game Boy Printer job is
-- stood in for by a PNG of the entry page saved under prints/.
if require("src.core.GameVersion").isYellow() then
entries[#entries + 1] = { label = Strings("PRNT"), onSelect = function()
local DexEntryMenu = require("src.ui.DexEntryMenu")
local Printer = require("src.core.Printer")
local TextBox = require("src.render.TextBox")
local def = game.data.pokemon[item.value]
local path = require("src.pokemon.Sprites").path(
game.data, item.value, "front", { kind = "dex" })
local ok, sprite = false, nil
if path then ok, sprite = pcall(love.graphics.newImage, path) end
local saved, err = Printer.save("dex_" .. item.value, 160, 144,
function()
DexEntryMenu.render(game, def, ok and sprite or nil, false)
end)
game.stack:push(TextBox.new(game, saved
and Strings("Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder.",
def.name, saved)
or Strings("Printer error!\n%s", tostring(err))))
end }
end
entries[#entries + 1] = { label = Strings("QUIT"), onSelect = function()
-- .exitPokedex: drop the dex list too and hand back to whoever
-- opened it (the start menu, whose saved cursor is still on
-- POKéDEX -- wBattleAndStartSavedMenuItem)
dexList:close()
if opts.onCancel then opts.onCancel() end
end }
game.stack:push(Menu.new(game, entries,
{ tx = 12, ty = 8, tw = 8,
th = #entries * 2 + 2 }))
end,
})
list.sgbPalettes = PokedexMenu.sgbPalettes
return list
self.items = items
-- SEEN / OWN, `lb bc, 1, 3` at hlcoord 16,3 and 16,6: engine/menus/
-- pokedex.asm HandlePokedexListMenu (#639)
self.seenCount, self.ownedCount = seen, owned
self.footer = Strings("SEEN %3d OWN %3d", seen, owned)
return self
end
-- HandleMenuInput_ replays SFX_PRESS_AB for A and B (home/window.asm)
local function beep(self)
if not (self.game and self.game.data) then return end
require("src.core.Sound").play(self.game.data, "Press_AB")
end
function PokedexMenu:rows()
return math.min(ROWS, #self.items)
end
-- engine/menus/pokedex.asm:290-313
function PokedexMenu:syncScroll()
local rows = self:rows()
if rows == 0 then
self.index, self.scroll = 1, 0
return
end
self.index = math.max(1, math.min(#self.items, self.index))
if self.index - self.scroll > rows then self.scroll = self.index - rows end
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
end
-- engine/menus/pokedex.asm:314-342: Left and Right move wListScrollOffset
-- by seven and never touch wCurrentMenuItem, so the cursor keeps its row
function PokedexMenu:pageScroll(dir)
local n = #self.items
if n < ROWS then return end
local row = self.index - self.scroll
local scroll = self.scroll + dir * ROWS
scroll = math.max(0, math.min(n - ROWS, scroll))
self.scroll = scroll
self.index = math.min(n, scroll + row)
end
function PokedexMenu:close()
local top = self.game.stack:top()
if top == self then self.game.stack:pop() end
end
function PokedexMenu:update(dt)
-- back on top of the stack, so the side menu's hollow '▷' is gone
-- (engine/menus/pokedex.asm:61, PlaceUnfilledArrowMenuCursor)
self.hollowIndex = nil
local input = self.game.input
if #self.items == 0 then
if input:wasPressed("a") or input:wasPressed("b") then
beep(self)
self.game.stack:pop()
if self.onCancel then self.onCancel() end
end
return
end
if input:wasPressed("up") then
self.index = self.index - 1
elseif input:wasPressed("down") then
self.index = self.index + 1
elseif input:wasPressed("left") then
self:pageScroll(-1)
elseif input:wasPressed("right") then
self:pageScroll(1)
elseif input:wasPressed("b") then
beep(self)
self.game.stack:pop()
if self.onCancel then self.onCancel() end
return
elseif input:wasPressed("a") then
beep(self)
self.onChoose(self.items[self.index], self)
return
end
self:syncScroll()
end
-- engine/menus/pokedex.asm:371-375, :81-85
function PokedexMenu:sideItems()
local labels = { Strings("DATA"), Strings("CRY"), Strings("AREA") }
if require("src.core.GameVersion").isYellow() then
labels[#labels + 1] = Strings("PRNT")
end
labels[#labels + 1] = Strings("QUIT")
return labels
end
local function sideRowY(menu, row)
return (menu.ty + menu.th - 2 - (#menu.items - row) * 2) * 8
end
local function drawSideMenu(self)
love.graphics.setColor(0, 0, 0, 1)
for row = 1, #self.items do
Font.draw(self.items[row].label, (self.tx + 2) * 8, sideRowY(self, row))
end
Font.drawCode(Theme.cursor, (self.tx + 1) * 8, sideRowY(self, self.index))
love.graphics.setColor(1, 1, 1, 1)
end
-- engine/menus/pokedex.asm HandlePokedexSideMenu, ShowPokedexMenu
-- .exitPokedex (#571)
local function chooseEntry(item, dexList)
local game = dexList.game
-- engine/menus/pokedex.asm:77-80: an unseen row hands back b=2 at once
if not item.value then return end
local Menu = require("src.ui.Menu")
local Screens = require("src.ui.Screens")
local entries = {
{ label = Strings("DATA"), onSelect = function()
Screens.push(game, "DexEntryMenu", item.value)
end },
{ label = Strings("CRY"), keepOpen = true, onSelect = function()
require("src.core.Sound").playCry(game.data, item.value)
end },
{ label = Strings("AREA"), onSelect = function()
Screens.push(game, "TownMap", { nestSpecies = item.value })
end },
}
-- pokeyellow engine/menus/pokedex.asm PokedexMenuItemsText,
-- PrintPokedexEntry
if require("src.core.GameVersion").isYellow() then
entries[#entries + 1] = { label = Strings("PRNT"), onSelect = function()
local DexEntryMenu = require("src.ui.DexEntryMenu")
local Printer = require("src.core.Printer")
local TextBox = require("src.render.TextBox")
local def = game.data.pokemon[item.value]
local path = require("src.pokemon.Sprites").path(
game.data, item.value, "front", { kind = "dex" })
local ok, sprite = false, nil
if path then ok, sprite = pcall(love.graphics.newImage, path) end
local saved, err = Printer.save("dex_" .. item.value, 160, 144,
function()
DexEntryMenu.render(game, def, ok and sprite or nil, false)
end)
game.stack:push(TextBox.new(game, saved
and Strings("Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder.",
def.name, saved)
or Strings("Printer error!\n%s", tostring(err))))
end }
end
entries[#entries + 1] = { label = Strings("QUIT"), onSelect = function()
-- engine/menus/pokedex.asm .exitPokedex
dexList:close()
if dexList.onCancel then dexList.onCancel() end
end }
local ty = dexList.rowsAt.items - 2
local side = Menu.new(game, entries, { tx = 14, ty = ty, tw = 6 })
-- the block is already on screen, so the side menu is the cursor alone:
-- text at column 16, cursor at 15 (engine/menus/pokedex.asm:82-86)
side.tx, side.tw, side.ty, side.th = 14, 6, ty, #entries * 2 + 2
side.draw = drawSideMenu
dexList.hollowIndex = dexList.index
game.stack:push(side)
end
PokedexMenu.onChoose = chooseEntry
-- engine/menus/pokedex.asm:256 writes tile $72 in the column left of the
-- name; the port paints the same marker rather than carry a one-tile sheet
local function drawBall(x, y)
love.graphics.circle("fill", x + 4, y + 4, 3.5)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", x + 0.5, y + 3.5, 7, 1)
love.graphics.circle("fill", x + 4, y + 4, 1.2)
love.graphics.setColor(0, 0, 0, 1)
end
function PokedexMenu:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local at = self.rowsAt
local DexEntryMenu = require("src.ui.DexEntryMenu")
-- engine/menus/pokedex.asm:159-176: the divider column and the rule that
-- fences SEEN / OWN off from the side-menu block
for ty = 0, 17 do
DexEntryMenu.tile(self.game, DIVIDER[ty], 14, ty)
end
love.graphics.setColor(0, 0, 0, 1)
Font.draw((""):rep(5), 120, at.rule * 8)
Font.draw(Strings("CONTENTS"), 8, 8)
Font.draw(Strings("SEEN"), 128, at.seen * 8)
Font.draw(Strings("OWN"), 128, at.own * 8)
-- PrintNumber's fixed three-digit field ends on column 19
local function count(n, ty)
local text = tostring(n)
Font.draw(text, 152 - Font.width(text), ty * 8)
end
count(self.seenCount, at.seen + 1)
count(self.ownedCount, at.own + 1)
local labels = self:sideItems()
for i, label in ipairs(labels) do
Font.draw(label, 128, (at.items + (i - 1) * 2) * 8)
end
for row = 1, self:rows() do
local i = self.scroll + row
local item = self.items[i]
if not item then break end
local y = FIRST_ROW_Y + (row - 1) * 16
-- the number sits one row above its name (engine/menus/pokedex.asm:242-246)
Font.draw(item.num, NUM_X, y - 8)
if item.ball then drawBall(BALL_X, y) end
Font.draw(item.name, NAME_X, y)
if i == self.index then
Font.drawCode(self.hollowIndex == i
and Theme.cursorHollow or Theme.cursor, CURSOR_X, y)
end
end
love.graphics.setColor(1, 1, 1, 1)
end
return PokedexMenu
+14 -23
View File
@@ -192,9 +192,9 @@ function SlotMachine.new(game, lucky)
local self = setmetatable({}, SlotMachine)
self.game = game
self.wheels = game.data.field.slotWheels
-- intro | bet | spinup | spin | reroll | flash | message | payout | onemore
-- PromptUserToPlaySlots asks "Want to play?" before the session starts.
self.stage = "intro"
-- bet | spinup | spin | reroll | flash | message | payout | onemore
-- PromptUserToPlaySlots: engine/slots/slot_machine.asm:9-23
self.stage = "bet"
self.yesno = 1 -- YES/NO cursor (1 = YES); wCurrentMenuItem
-- CoinMultiplierSlotMachineText lists ×3/×2/×1 with the cursor defaulting to
-- the top (wCurrentMenuItem 0), i.e. bet = 3 - menuItem.
@@ -417,7 +417,7 @@ function SlotMachine:startPayout()
self.flash = false
end
-- YES/NO prompt shared by the intro ("Want to play?") and "One more go?".
-- the "One more go?" YES/NO prompt.
function SlotMachine:updateYesNo(onYes)
local input = self.game.input
if input:wasPressed("up") or input:wasPressed("down") then
@@ -435,12 +435,6 @@ function SlotMachine:update(dt)
local input = self.game.input
local save = self.game.save
if self.stage == "intro" then
-- PromptUserToPlaySlots: "A slot machine! Want to play?"
self:updateYesNo(function() self:enterBet() end)
return
end
if self.stage == "message" then
if self.exitTimer then
-- OutOfCoinsSlotMachineText: DelayFrames 60, then leave
@@ -638,9 +632,7 @@ end
-- menu on the right (like MainSlotMachineLoop's TextBoxBorder + menus).
function SlotMachine:drawBottom()
local lines
if self.stage == "intro" then
lines = { "A slot machine!", "Want to play?" }
elseif self.stage == "bet" then
if self.stage == "bet" then
lines = { "Bet how many", "coins?" }
elseif self.stage == "onemore" then
lines = { "One more", "go?" }
@@ -661,22 +653,21 @@ function SlotMachine:drawBottom()
Font.draw(lines[1] or "", 8, 14 * 8)
Font.draw(lines[2] or "", 8, 16 * 8)
if self.stage == "bet" then
Font.drawBox(14, 11, 6, 5)
-- hlcoord 14,11 with b=5 c=4 -- engine/slots/slot_machine.asm:83-86
Font.drawBox(14, 11, 6, 7)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("×3", 16 * 8, 12 * 8)
Font.draw("×2", 16 * 8, 13 * 8)
Font.draw("×1", 16 * 8, 14 * 8)
Font.drawCode(0xED, 15 * 8, (12 + self.betIndex) * 8)
elseif self.stage == "intro" or self.stage == "onemore" then
-- "One more go?" sits at the right of the box (hlcoord 14,12); the longer
-- "A slot machine!" prompt would clip against it, so the intro's YES/NO
-- floats above the reels instead.
local by = self.stage == "intro" and 6 or 11
Font.drawBox(13, by, 6, 5)
elseif self.stage == "onemore" then
-- hlcoord 14,12 with the YES_NO_MENU 4x3 body
-- engine/slots/slot_machine.asm:136-138, data/yes_no_menu_strings.asm:10
Font.drawBox(14, 12, 6, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("YES"), 15 * 8, (by + 1) * 8)
Font.draw(Strings("NO"), 15 * 8, (by + 2) * 8)
Font.drawCode(0xED, 14 * 8, (by + 1 + (self.yesno == 1 and 0 or 1)) * 8)
Font.draw(Strings("YES"), 16 * 8, 13 * 8)
Font.draw(Strings("NO"), 16 * 8, 14 * 8)
Font.drawCode(0xED, 15 * 8, (13 + (self.yesno == 1 and 0 or 1)) * 8)
end
love.graphics.setColor(1, 1, 1, 1)
end
+2 -2
View File
@@ -200,12 +200,12 @@ function StartMenu.new(game)
anchor = "topright" })
-- the cursor position survives closing the menu
-- (wBattleAndStartSavedMenuItem, home/start_menu.asm)
menu.index = math.min(game.save.startMenuIndex or 1, #items)
menu.index = math.min(game.startMenuIndex or 1, #items)
menu:clampScroll()
local baseUpdate = menu.update
menu.update = function(self, dt)
baseUpdate(self, dt)
game.save.startMenuIndex = self.index
game.startMenuIndex = self.index
end
-- inside the Safari Zone the start menu also shows remaining steps and
+43 -27
View File
@@ -130,6 +130,25 @@ local function markerXY(loc)
return loc.x * 8 + 16, loc.y * 8 + 8
end
-- the marker wears its own sheet's OBJ palette and shade-0 keying
-- engine/items/town_map.asm:342
local function markerSheet(def, seed)
local colors, group
if PaletteFX.usesGbcPack() then
colors, group = PaletteFX.spriteObp(def, seed)
end
if not colors then
if PaletteFX.usesSpriteObp() then
colors, group = PaletteFX.ogObj()
else
colors, group = PaletteFX.dmgObj()
end
end
local ok, img = pcall(SpriteRenderer.obpImage, def and def.image, colors, group)
if not (ok and img) then return nil, nil end
return img, love.graphics.newQuad(0, 0, 16, 16, img:getDimensions())
end
-- the row-0 name banner; fly mode prefixes "To " like LoadTownMap_Fly
-- (engine/menus/town_map.asm prints the destination as "To <NAME>")
function TownMap:bannerText(loc)
@@ -224,32 +243,18 @@ function TownMap.new(game, opts)
local mapId = game.overworld and game.overworld.map and game.overworld.map.id
self.playerLoc = mapId and self.byMap[mapId] or nil
-- engine/items/town_map.asm:347
do
local playerSprites = (game.data.field and game.data.field.playerSprites)
or {}
local sprites = game.data.sprites or {}
local red = sprites[playerSprites.walk or "SPRITE_RED"]
or sprites.SPRITE_RED
-- the marker is the overworld walking sheet, so it wears that sheet's OBJ
-- palette and shade-0 keying -- engine/items/town_map.asm:342
local colors, group
if PaletteFX.usesGbcPack() then
colors, group = PaletteFX.spriteObp(red, "player")
end
if not colors then
if PaletteFX.usesSpriteObp() then
colors, group = PaletteFX.ogObj()
else
colors, group = PaletteFX.dmgObj()
end
end
local ok, img = pcall(SpriteRenderer.obpImage,
red and red.image, colors, group)
if ok and img then
self.playerSheet = img
self.playerQuad = love.graphics.newQuad(0, 0, 16, 16,
img:getDimensions())
end
local playerSprites = (game.data.field and game.data.field.playerSprites)
or {}
local sprites = game.data.sprites or {}
self.playerSheet, self.playerQuad =
markerSheet(sprites[playerSprites.walk or "SPRITE_RED"] or sprites.SPRITE_RED,
"player")
-- LoadTownMap_Fly overwrites the cursor tiles with BirdSprite and marks
-- the destination with it -- engine/items/town_map.asm:146-149, 177-179
if self.fly then
self.birdSheet, self.birdQuad =
markerSheet(sprites[playerSprites.fly or "SPRITE_BIRD"]
or sprites.SPRITE_BIRD, "bird")
end
self.sel = 1
-- LoadTownMap_Fly always opens with hl on wFlyLocationsList[0], the FIRST
@@ -402,8 +407,19 @@ function TownMap:draw()
end
end
-- WriteTownMapSpriteOAM carry quirk: -4 X, -3 Y for cursor and player alike -- engine/items/town_map.asm:454
-- LoadTownMap_Fly's .inputLoop has no blinking animation
-- engine/items/town_map.asm:190-198
local showCursor = true
if GameVersion.generation() == 1 then
if self.fly and self.birdSheet then
if selected then
local x, y = markerXY(selected)
love.graphics.draw(self.birdSheet, self.birdQuad, x - 4, y - 3)
if PaletteFX.usesSpriteObp() or PaletteFX.usesGbcPack() then
PaletteFX.markUiSpriteRedraw(self.birdSheet, self.birdQuad, x - 4, y - 3)
end
end
showCursor = false
elseif GameVersion.generation() == 1 then
showCursor = self.blink < 25
else
showCursor = self.blink % 16 < 10
+47 -4
View File
@@ -605,23 +605,66 @@ function BattleState:animData(mon)
local def = self.pokemon and mon and self.pokemon[mon.species]
if not def then return nil end
if mon.species == Unown.SPECIES and def.letters then
local entry = def.letters[Unown.monLetter(mon)]
local entry = def.letters[Unown.name(Unown.monLetter(mon))]
if entry and entry.anim then return entry.anim end
end
return def.anim
end
-- ../pokecrystal/engine/gfx/load_pics.asm:105-131
function BattleState:frontPicReplaced(mon)
local def = self.pokemon and mon and self.pokemon[mon.species]
local vanilla = def and def.spriteFront
if mon and mon.species == Unown.SPECIES then
vanilla = Unown.formSprite(self.pokemon, Unown.monLetter(mon), false)
or vanilla
end
if type(vanilla) ~= "string" then return false end
local _, _, path = self:pic(mon, false)
if type(path) == "string" and path ~= vanilla then return true end
return Assets.resolve(vanilla) ~= vanilla
end
-- one cart asset, two files on the pokemon.sprite seam (#1827)
-- ../pokecrystal/engine/gfx/load_pics.asm:132-158
function BattleState:animSheetPath(mon, data)
local path = data and data.sheet
if type(path) ~= "string" then return nil, false end
if not Runtime.wantsHook("pokemon.sprite") then
return path, Assets.resolve(path) ~= path
end
local letter
if mon and mon.species == Unown.SPECIES then letter = Unown.monLetter(mon) end
local hooked = Runtime.call("pokemon.sprite",
function(value) return value end, path, {
species = mon and mon.species,
side = "front",
kind = "battle_anim",
mon = mon,
data = (self.game and self.game.data) or nil,
letter = letter,
shiny = mon and mon.shiny and true or false,
})
if type(hooked) == "string" and hooked ~= "" and hooked ~= path then
return hooked, true
end
return path, Assets.resolve(path) ~= path
end
-- ANIM_MON_NORMAL, the scene BattleStartMessage runs on the enemy's frontpic.
-- ../pokecrystal/engine/battle/core.asm:9112-9113
function BattleState:startFrontAnim(mon)
self.frontAnim = nil
local data = self:animData(mon)
if not data then return end
local cached = self.picCache[data.sheet]
local sheet, sheetReplaced = self:animSheetPath(mon, data)
if not sheet then return end
if not sheetReplaced and self:frontPicReplaced(mon) then return end
local cached = self.picCache[sheet]
if cached == nil then
local ok, image = pcall(Assets.image, data.sheet)
local ok, image = pcall(Assets.image, sheet)
cached = ok and image or false
self.picCache[data.sheet] = cached
self.picCache[sheet] = cached
end
if not cached then return end
local runner = MonAnim.new(data, "battle")
+9 -3
View File
@@ -37,6 +37,13 @@ local TileSheet = require("src.ui.gen2.TileSheet")
local Nests = require("src.core.gen2.Nests")
local Sound = require("src.core.Sound")
local Unown = require("src.core.gen2.Unown")
local Strings = require("src.core.Strings")
-- `db $3b, " OPTION ", $3c` / `db $3b, " SEARCH ", $3c"`: the panel titles
-- drawn by drawOption/drawSearch below, declared here (rather than inline)
-- so Strings.source puts them in the catalog harvest.
local OPTION_LABEL = Strings.source(" OPTION ")
local SEARCH_LABEL = Strings.source(" SEARCH ")
local PokedexMenu = {}
PokedexMenu.__index = PokedexMenu
@@ -776,7 +783,6 @@ function PokedexMenu:printEntry()
local row = self:current()
if not row then return end
local Printer = require("src.core.Printer")
local Strings = require("src.core.Strings")
local TextBox = require("src.render.TextBox")
local name = (self.pokemon and self.pokemon[row.species]
and self.pokemon[row.species].name) or tostring(row.species)
@@ -1385,7 +1391,7 @@ function PokedexMenu:drawOption()
-- `db $3b, " OPTION ", $3c`: the two end-cap tiles are the dex sheet's, not
-- font glyphs.
self:tile(0x3b, 0, 1)
self:text(" OPTION ", 1, 1)
self:text(Strings(OPTION_LABEL), 1, 1)
self:tile(0x3c, 9, 1)
local rows = self:optionRows()
for i, row in ipairs(rows) do
@@ -1405,7 +1411,7 @@ function PokedexMenu:drawSearch()
self:fill(TILE_BG, 0, 0, Chrome.SCREEN_W, Chrome.SCREEN_H)
self:border(0, 2, 14, 18)
self:tile(0x3b, 0, 1)
self:text(" SEARCH ", 1, 1)
self:text(Strings(SEARCH_LABEL), 1, 1)
self:tile(0x3c, 9, 1)
self:text("TYPE1", 3, 4)
self:text("TYPE2", 3, 6)
+11 -6
View File
@@ -336,7 +336,7 @@ function SummaryMenu:startPicAnim()
if not def then return end
local data = def.anim
if mon.species == Unown.SPECIES and def.letters then
local entry = def.letters[Unown.monLetter(mon)]
local entry = def.letters[Unown.name(Unown.monLetter(mon))]
if entry and entry.anim then data = entry.anim end
end
if not data then return end
@@ -1105,9 +1105,14 @@ function SummaryMenu:drawEggIconFallback(colors)
G.setColor(1, 1, 1, 1)
end
function SummaryMenu:drawPlacements(list)
-- engine/gfx/color.asm:342-359
function SummaryMenu:drawPlacements(list, palette)
for _, entry in ipairs(list) do
Chrome.print(entry.text, entry.x, entry.y)
if palette then
Chrome.printThrough(entry.text, entry.x, entry.y, palette)
else
Chrome.print(entry.text, entry.x, entry.y)
end
end
end
@@ -1168,7 +1173,7 @@ function SummaryMenu:drawPinkPage()
HpBar.drawWithLabel(self.palettes, mon.hp, maxHp, 0, 9, Font)
end
self:drawVerticalDivider(9)
self:drawPlacements(self:pinkPlacements())
self:drawPlacements(self:pinkPlacements(), self:lowerColors())
-- FillInExpBar is handed (11,16), adds 7 to reach the rightmost cell and
-- fills eight of them walking left, with the $40/$41 caps outside at (10,16)
@@ -1188,12 +1193,12 @@ function SummaryMenu:drawPinkPage()
end
function SummaryMenu:drawGreenPage()
self:drawPlacements(self:greenPlacements())
self:drawPlacements(self:greenPlacements(), self:lowerColors())
end
function SummaryMenu:drawBluePage()
self:drawVerticalDivider(10)
self:drawPlacements(self:bluePlacements())
self:drawPlacements(self:bluePlacements(), self:lowerColors())
end
function SummaryMenu:drawMoveDetail()
+2 -1
View File
@@ -934,9 +934,10 @@ function Kit.textfield(id, x, y, w, h, value, placeholder)
local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1"
or os.getenv("POKEPORT_SBC") == "1" or os.getenv("ANBERNIC") == "1"
local clickedOrActivated = (Kit._activateId == id) or (isHandheld and Kit.press(x, y, w, h))
if clickedOrActivated and not mobile() then
if isHandheld and clickedOrActivated and not mobile() then
VirtualKeyboard.open({
text = value,
targetId = id,
+17
View File
@@ -48,7 +48,23 @@ local ROWS_SYM = {
{ { id = "url_https", label = "https://", w = 3 }, { id = "url_http", label = "http://", w = 2 }, { id = "url_json", label = ".json", w = 2 }, { id = "url_zip", label = ".zip", w = 2 }, { id = "clear", label = "Clear", w = 3 } },
}
local function isLinuxHandheld()
return os.getenv("HANDHELD") == "1"
or os.getenv("PORTMASTER") == "1"
or os.getenv("POKEPORT_HANDHELD") == "1"
or os.getenv("TRIMUI") == "1"
or os.getenv("MUOS") == "1"
or os.getenv("KNULLI") == "1"
or os.getenv("POKEPORT_SBC") == "1"
or os.getenv("ANBERNIC") == "1"
end
function VirtualKeyboard.isSupported()
return isLinuxHandheld()
end
function VirtualKeyboard.open(opts)
if not isLinuxHandheld() then return false end
opts = opts or {}
VirtualKeyboard.active = true
VirtualKeyboard.text = tostring(opts.text or "")
@@ -58,6 +74,7 @@ function VirtualKeyboard.open(opts)
VirtualKeyboard.row = 1
VirtualKeyboard.col = 1
VirtualKeyboard.mode = 1
return true
end
function VirtualKeyboard.close(confirmed)
+51 -5
View File
@@ -48,6 +48,10 @@ local WILD_ENCOUNTER_GRACE_STEPS = 3
-- of the GB screen center: FLY_ANCHOR is the pair where the original has
-- the player's sprite, so path1 starts exactly on the player.
local FLY_ANCHOR = { 0x3C, 0x48 }
-- StopMusic's fade-out control byte and the 7 volume steps it waits out
-- engine/overworld/player_animations.asm:123, home/fade_audio.asm:36
local FLY_FADE_CONTROL = 4
local FLY_FADE_FRAMES = FLY_FADE_CONTROL * 7
local FLY_PATH1 = { -- FlyAnimationScreenCoords1: up and off to the right
{ 0x3C, 0x48 }, { 0x3C, 0x50 }, { 0x3B, 0x58 }, { 0x3A, 0x60 },
{ 0x39, 0x68 }, { 0x37, 0x70 }, { 0x37, 0x78 }, { 0x33, 0x80 },
@@ -1189,6 +1193,17 @@ function OverworldState:update(dt)
end
return
end
-- StopMusic busy-waits on the fade before LoadBirdSpriteGraphics, so the
-- world holds and the bird is not on screen yet -- home/overworld.asm:772
if self.flyFade then
self.flyFade = self.flyFade - 1
if self.flyFade <= 0 then
self.flyFade = nil
self.flyAnim = { phase = "flap", t = 0 }
end
self.player:update()
return
end
if self.flyAnim then
-- DoFlyAnimation runs one coord pair every Delay3 (3 frames); the
-- in-place flap is 8 pairs, then the two paths with a 40-frame beat
@@ -2070,7 +2085,9 @@ function OverworldState:flyTo(mapId)
-- then SFX_FLY and the up-right path, a 40-frame beat off screen, and
-- the exit over the top-left -- the warp fades only once the bird is
-- gone (#702). fxBird draws it; the player hides for the whole flight.
self.flyAnim = { phase = "flap", t = 0 }
-- engine/overworld/player_animations.asm:123, home/overworld.asm:772
require("src.core.Music").fadeOut(FLY_FADE_CONTROL)
self.flyFade = FLY_FADE_FRAMES
self.player.inputLocked = true
self.flyDest = { map = mapId, x = spot.x, y = spot.y }
end
@@ -2093,6 +2110,9 @@ function OverworldState:beginTeleportOut(onDone)
if onDone then onDone() end
return
end
-- StopMusic sits above both _LeaveMapAnim branches
-- engine/overworld/player_animations.asm:123
require("src.core.Music").fadeOut(FLY_FADE_CONTROL)
require("src.core.Sound").play(Game.data, "Teleport_Exit1")
self.player.surfing = false
self:syncSurfingPikachu()
@@ -2315,6 +2335,15 @@ function OverworldState.benchGuyText(data, save, label)
return data.text["_" .. label] or data.text[BENCH_GUY_TEXT[label] or ""]
end
-- HiddenCoins pays a BCD constant chosen by the argument, and the 40 case
-- falls into .bcd20 -- engine/events/hidden_items.asm:79
function OverworldState.hiddenCoinPayout(amount)
amount = tonumber(amount) or 0
if amount == 10 then return 10 end
if amount == 20 or amount == 40 then return 20 end
return 100
end
-- Hidden events at the faced cell (data/events/hidden_events.asm):
-- HiddenItems give their item once, HiddenCoins fill the COIN CASE,
-- StartSlotMachine seats open the minigame. Taken spots persist in
@@ -2359,9 +2388,10 @@ function OverworldState:tryHiddenObject(fx, fy)
if save.hiddenTaken[key] then return false end
if not save.inventory.COIN_CASE then return false end
save.hiddenTaken[key] = true
save.coins = math.min(9999, (save.coins or 0) + h.coins)
local paid = OverworldState.hiddenCoinPayout(h.coins)
save.coins = math.min(9999, (save.coins or 0) + paid)
Game.stack:push(TextBox.new(Game,
Strings("%s found\n%d coins!", save.player.name, h.coins),
Strings("%s found\n%d coins!", save.player.name, paid),
nil, TextBox.soundOpts(Game, "Get_Item2")))
return true
end
@@ -2391,7 +2421,19 @@ function OverworldState:tryHiddenObject(fx, fy)
else
-- one machine per visit is secretly lucky
-- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots.asm)
Screens.push(Game, "SlotMachine", seatIndex == self.luckySlot)
local lucky = seatIndex == self.luckySlot
-- PromptUserToPlaySlots: engine/slots/slot_machine.asm:9-23
Game.stack:push(TextBox.new(Game, txt._PlaySlotMachineText
or romText(Game.data, "_PlaySlotMachineText",
"A slot machine!\nWant to play?"), nil, {
choice = function(yes)
if not yes then return end
self.emote = {
npc = self.player, frames = 60, bubble = 3,
onDone = function() Screens.push(Game, "SlotMachine", lucky) end,
}
end,
}))
end
return true
end
@@ -3829,6 +3871,10 @@ function OverworldState:checkTrainerSight()
if self.player.moving or self.engaging then return end
if Game.stack:top() ~= self then return end
local p = self.player
-- a map contribution opts a trainer out of CheckFightingMapTrainers with
-- `noSight = { TEXT_... = true }` -- home/trainers.asm:129
local view = mapScripts.get and mapScripts.get(self.map.id)
local noSight = view and view.noSight
local cancelled = self.cancelledTrainerSight
if cancelled and (cancelled.playerX ~= p.cellX
or cancelled.playerY ~= p.cellY) then
@@ -3842,7 +3888,7 @@ function OverworldState:checkTrainerSight()
if d.trainerClass and not npc.moving
and not (cancelled and cancelled.npcId == npc.id)
and not self:trainerDefeated(npc)
and not mapScripts.talkScript(self.map.id, d.text)
and not (noSight and d.text and noSight[d.text])
and trainerSpriteOnScreen(npc, p) then
local header = Game.data:trainerHeader(self.map.def.label, d.index)
local range = header and header.range or 0
+5 -9
View File
@@ -182,6 +182,9 @@ function Player:update()
if self.turnTimer > 0 then
self.turnTimer = self.turnTimer - 1
end
if self.spinning then
self.spinTimer = (self.spinTimer or 0) + 1
end
if self.spinFrames then
self.spinFrames = self.spinFrames - 1
if self.spinFrames <= 0 then
@@ -261,11 +264,6 @@ local SPIN_ORDER = { "down", "left", "up", "right" }
--
-- The last return says the player is mid-ledge-hop, which is what the 2D
-- path draws the ground shadow from and a 3D path turns into vertical lift.
--
-- This ADVANCES the surf-bob and spinner timers, so exactly one of pose()
-- and draw() may run per frame -- and draw() is written in terms of pose()
-- to keep that true by construction. (hopFrames counts down in
-- Player:update, on the fixed step, so it is safe to read here.)
function Player:pose()
local py = self.py
local hopping = false
@@ -288,10 +286,8 @@ function Player:pose()
-- the leg cadence
local flip = math.floor((self.animClock or 0) / 16) % 2 == 1
if self.spinning then
-- spinner tiles whirl the sprite on its standing pose, one facing
-- per frame (LoadSpinnerArrowTiles runs every OverworldLoop frame)
self.spinTimer = (self.spinTimer or 0) + 1
facing = SPIN_ORDER[self.spinTimer % 4 + 1]
-- spinners.asm:1-11, home/overworld.asm:41-44, :268-272
facing = SPIN_ORDER[math.floor((self.spinTimer or 0) / 2) % 4 + 1]
phase, flip = 0, false
-- teleport arrivals spin the sprite down into place
-- (EnterMapAnim PlayerSpinWhileMovingDown)
+31 -24
View File
@@ -187,6 +187,11 @@ function FieldMoves.bindEngineFlags(order)
-- row, so every id from BUG_CONTEST_TIMER up shifts one.
FieldMoves.BUG_CONTEST_FLAG = byName["ENGINE_BUG_CONTEST_TIMER"] or 16
FieldMoves.BIKE_SHOP_CALL_FLAG = byName["ENGINE_BIKE_SHOP_CALL_ENABLED"] or 19
-- pokecrystal constants/engine_flags.asm:66-92 vs pokegold :65-91
for _, row in ipairs(FieldMoves.FLYPOINTS or {}) do
row.goldFlag = row.goldFlag or row.flag
row.flag = byName[row.name] or row.goldFlag
end
return flags
end
@@ -475,33 +480,35 @@ end
-- const_def count (0-based, ENGINE_RADIO_CARD is 0): the byte a town's own
-- MAPCALLBACK_NEWMAP callback sets with `setflag` the first time you walk in,
-- and what FieldMoves.hasVisitedSpawn below actually reads.
-- Ids are pokegold's; bindEngineFlags rebinds by name (pokecrystal
-- constants/engine_flags.asm:25 ENGINE_MOBILE_SYSTEM shifts them +1).
FieldMoves.FLYPOINTS = {
-- Johto
{ landmark = "LANDMARK_NEW_BARK_TOWN", spawn = "SPAWN_NEW_BARK", flag = 64 },
{ landmark = "LANDMARK_CHERRYGROVE_CITY", spawn = "SPAWN_CHERRYGROVE", flag = 65 },
{ landmark = "LANDMARK_VIOLET_CITY", spawn = "SPAWN_VIOLET", flag = 66 },
{ landmark = "LANDMARK_AZALEA_TOWN", spawn = "SPAWN_AZALEA", flag = 67 },
{ landmark = "LANDMARK_GOLDENROD_CITY", spawn = "SPAWN_GOLDENROD", flag = 69 },
{ landmark = "LANDMARK_ECRUTEAK_CITY", spawn = "SPAWN_ECRUTEAK", flag = 71 },
{ landmark = "LANDMARK_OLIVINE_CITY", spawn = "SPAWN_OLIVINE", flag = 70 },
{ landmark = "LANDMARK_CIANWOOD_CITY", spawn = "SPAWN_CIANWOOD", flag = 68 },
{ landmark = "LANDMARK_MAHOGANY_TOWN", spawn = "SPAWN_MAHOGANY", flag = 72 },
{ landmark = "LANDMARK_LAKE_OF_RAGE", spawn = "SPAWN_LAKE_OF_RAGE", flag = 73 },
{ landmark = "LANDMARK_BLACKTHORN_CITY", spawn = "SPAWN_BLACKTHORN", flag = 74 },
{ landmark = "LANDMARK_SILVER_CAVE", spawn = "SPAWN_MT_SILVER", flag = 75 },
{ landmark = "LANDMARK_NEW_BARK_TOWN", spawn = "SPAWN_NEW_BARK", flag = 64, name = "ENGINE_FLYPOINT_NEW_BARK" },
{ landmark = "LANDMARK_CHERRYGROVE_CITY", spawn = "SPAWN_CHERRYGROVE", flag = 65, name = "ENGINE_FLYPOINT_CHERRYGROVE" },
{ landmark = "LANDMARK_VIOLET_CITY", spawn = "SPAWN_VIOLET", flag = 66, name = "ENGINE_FLYPOINT_VIOLET" },
{ landmark = "LANDMARK_AZALEA_TOWN", spawn = "SPAWN_AZALEA", flag = 67, name = "ENGINE_FLYPOINT_AZALEA" },
{ landmark = "LANDMARK_GOLDENROD_CITY", spawn = "SPAWN_GOLDENROD", flag = 69, name = "ENGINE_FLYPOINT_GOLDENROD" },
{ landmark = "LANDMARK_ECRUTEAK_CITY", spawn = "SPAWN_ECRUTEAK", flag = 71, name = "ENGINE_FLYPOINT_ECRUTEAK" },
{ landmark = "LANDMARK_OLIVINE_CITY", spawn = "SPAWN_OLIVINE", flag = 70, name = "ENGINE_FLYPOINT_OLIVINE" },
{ landmark = "LANDMARK_CIANWOOD_CITY", spawn = "SPAWN_CIANWOOD", flag = 68, name = "ENGINE_FLYPOINT_CIANWOOD" },
{ landmark = "LANDMARK_MAHOGANY_TOWN", spawn = "SPAWN_MAHOGANY", flag = 72, name = "ENGINE_FLYPOINT_MAHOGANY" },
{ landmark = "LANDMARK_LAKE_OF_RAGE", spawn = "SPAWN_LAKE_OF_RAGE", flag = 73, name = "ENGINE_FLYPOINT_LAKE_OF_RAGE" },
{ landmark = "LANDMARK_BLACKTHORN_CITY", spawn = "SPAWN_BLACKTHORN", flag = 74, name = "ENGINE_FLYPOINT_BLACKTHORN" },
{ landmark = "LANDMARK_SILVER_CAVE", spawn = "SPAWN_MT_SILVER", flag = 75, name = "ENGINE_FLYPOINT_SILVER_CAVE" },
-- Kanto
{ landmark = "LANDMARK_PALLET_TOWN", spawn = "SPAWN_PALLET", flag = 52 },
{ landmark = "LANDMARK_VIRIDIAN_CITY", spawn = "SPAWN_VIRIDIAN", flag = 53 },
{ landmark = "LANDMARK_PEWTER_CITY", spawn = "SPAWN_PEWTER", flag = 54 },
{ landmark = "LANDMARK_CERULEAN_CITY", spawn = "SPAWN_CERULEAN", flag = 55 },
{ landmark = "LANDMARK_VERMILION_CITY", spawn = "SPAWN_VERMILION", flag = 57 },
{ landmark = "LANDMARK_ROCK_TUNNEL", spawn = "SPAWN_ROCK_TUNNEL", flag = 56 },
{ landmark = "LANDMARK_LAVENDER_TOWN", spawn = "SPAWN_LAVENDER", flag = 58 },
{ landmark = "LANDMARK_CELADON_CITY", spawn = "SPAWN_CELADON", flag = 60 },
{ landmark = "LANDMARK_SAFFRON_CITY", spawn = "SPAWN_SAFFRON", flag = 59 },
{ landmark = "LANDMARK_FUCHSIA_CITY", spawn = "SPAWN_FUCHSIA", flag = 61 },
{ landmark = "LANDMARK_CINNABAR_ISLAND", spawn = "SPAWN_CINNABAR", flag = 62 },
{ landmark = "LANDMARK_INDIGO_PLATEAU", spawn = "SPAWN_INDIGO", flag = 63 },
{ landmark = "LANDMARK_PALLET_TOWN", spawn = "SPAWN_PALLET", flag = 52, name = "ENGINE_FLYPOINT_PALLET" },
{ landmark = "LANDMARK_VIRIDIAN_CITY", spawn = "SPAWN_VIRIDIAN", flag = 53, name = "ENGINE_FLYPOINT_VIRIDIAN" },
{ landmark = "LANDMARK_PEWTER_CITY", spawn = "SPAWN_PEWTER", flag = 54, name = "ENGINE_FLYPOINT_PEWTER" },
{ landmark = "LANDMARK_CERULEAN_CITY", spawn = "SPAWN_CERULEAN", flag = 55, name = "ENGINE_FLYPOINT_CERULEAN" },
{ landmark = "LANDMARK_VERMILION_CITY", spawn = "SPAWN_VERMILION", flag = 57, name = "ENGINE_FLYPOINT_VERMILION" },
{ landmark = "LANDMARK_ROCK_TUNNEL", spawn = "SPAWN_ROCK_TUNNEL", flag = 56, name = "ENGINE_FLYPOINT_ROCK_TUNNEL" },
{ landmark = "LANDMARK_LAVENDER_TOWN", spawn = "SPAWN_LAVENDER", flag = 58, name = "ENGINE_FLYPOINT_LAVENDER" },
{ landmark = "LANDMARK_CELADON_CITY", spawn = "SPAWN_CELADON", flag = 60, name = "ENGINE_FLYPOINT_CELADON" },
{ landmark = "LANDMARK_SAFFRON_CITY", spawn = "SPAWN_SAFFRON", flag = 59, name = "ENGINE_FLYPOINT_SAFFRON" },
{ landmark = "LANDMARK_FUCHSIA_CITY", spawn = "SPAWN_FUCHSIA", flag = 61, name = "ENGINE_FLYPOINT_FUCHSIA" },
{ landmark = "LANDMARK_CINNABAR_ISLAND", spawn = "SPAWN_CINNABAR", flag = 62, name = "ENGINE_FLYPOINT_CINNABAR" },
{ landmark = "LANDMARK_INDIGO_PLATEAU", spawn = "SPAWN_INDIGO", flag = 63, name = "ENGINE_FLYPOINT_INDIGO_PLATEAU" },
}
-- spawn -> row, built once, so hasVisitedSpawn below does not walk the whole
+27 -11
View File
@@ -929,8 +929,8 @@ function World:load()
-- `hold` is the same story one argument along: the cart `pause` a held box
-- stands through (FindItemInBallScript's `pause 60`). Dropping it made the
-- box hand back the instant it finished typing.
showText = function(body, onDone, stay, hold)
self:showText(body, onDone, stay, hold)
showText = function(body, onDone, stay, hold, sfxWait)
self:showText(body, onDone, stay, hold, sfxWait)
end,
facePlayer = function()
if self.talkNpc and self.player then
@@ -4224,7 +4224,7 @@ function World:tryWildEncounter()
-- That is what keeps the Ruins chambers empty until a wall has been solved.
local monOpts = nil
if roll.species == Unown.SPECIES then
local flags = self:engineFlags()
local flags = self:unownUnlockFlags()
if not Unown.anyUnlocked(flags) then return false end
-- LoadEnemyMon's .GenerateDVs loop rerolls until CheckUnownLetter clears
-- the form, so a chamber only ever produces letters its own puzzle
@@ -4846,6 +4846,19 @@ function World:engineFlagId(name, goldId)
return ids[name] or goldId
end
-- Unown.UNLOCK_SETS keys the flags by pokegold's ids; Crystal's are one
-- higher (../pokecrystal/constants/engine_flags.asm:57-60 vs ../pokegold:56-59).
function World:unownUnlockFlags()
local engine = self:engineFlags()
local out = {}
for _, set in ipairs(Unown.UNLOCK_SETS) do
if engine[self:engineFlagId(set.name, set.flag)] then
out[set.flag] = true
end
end
return out
end
-- wBikeFlags' three bits are ENGINE_* ids like any other flag, so the map
-- callbacks that set them (Route16AlwaysOnBikeCallback,
-- Route17AlwaysOnBikeCallback) already land on save.engineFlags.
@@ -5338,7 +5351,7 @@ function World:sweetScentEncounter()
-- stays empty for SWEET SCENT too.
local monOpts = nil
if roll.species == Unown.SPECIES then
local flags = self:engineFlags()
local flags = self:unownUnlockFlags()
if not Unown.anyUnlocked(flags) then return false end
monOpts = { dvs = Unown.wildDVs(flags, Mon.randomDVs) }
end
@@ -5618,15 +5631,16 @@ end
function World:runWhirlpool(result)
self:setNickname(result.mon)
self:showText(Strings(result.text), function()
self:replaceBlock(result.blockIndex, result.replacement)
self:playWhirlpoolSound()
self:playWhirlpoolSound(result.blockIndex, result.replacement)
end)
end
-- PlayWhirlpoolSound is WaitSFX, SFX_SURF, WaitSFX, never a bare PlaySFX
-- -- engine/events/field_moves.asm:5-10 (#1717)
function World:playWhirlpoolSound()
self.fieldMove = { phase = "whirlpoolsfx", waiting = true, left = 180 }
-- -- engine/events/field_moves.asm:5-10 (#1717). The block swap lands after
-- it -- engine/events/overworld.asm:1157-1164
function World:playWhirlpoolSound(blockIndex, replacement)
self.fieldMove = { phase = "whirlpoolsfx", waiting = true, left = 180,
blockIndex = blockIndex, replacement = replacement }
end
-- PlayerMovementPointers' .force_turn arm and the Script_ForcedMovement it
@@ -6193,6 +6207,8 @@ function World:updateFieldMove()
end
if Sound.sfxBusy() and st.left > 0 then return end
self.fieldMove = nil
-- engine/events/overworld.asm:1163-1164
if st.blockIndex then self:replaceBlock(st.blockIndex, st.replacement) end
return
end
if st.phase == "strength" then
@@ -7277,7 +7293,7 @@ end
-- stack the overworld and the VM under it do not tick at all -- so the wait has
-- to be counted by the box itself. Frames, already doubled by Vm:pauseFrames
-- the way Script_pause's `ld c, 2 / call DelayFrames` doubles the operand.
function World:showText(body, onDone, stay, hold)
function World:showText(body, onDone, stay, hold, sfxWait)
local game = self.game
-- The box a PREVIOUS `stay` left standing (TextBox's contract is "whoever
-- pushed it owns the pop", src/render/TextBox.lua:40). `yesorno` consumes it
@@ -7327,7 +7343,7 @@ function World:showText(body, onDone, stay, hold)
game.stack:push(TextBox.new(game, body, function()
self.textbox = nil
if onDone then onDone() end
end))
end, sfxWait and { sfxWait = true } or nil))
end
function World:pooledNpc(mapId, obj)
+101
View File
@@ -0,0 +1,101 @@
-- Manual check of the FLY picker's bird marker and the departure fade (#1840).
-- LoadTownMap_Fly overwrites the cursor tiles with BirdSprite and marks the
-- destination with it, with no blink (engine/items/town_map.asm:146-149,
-- 177-179, 190-198); _LeaveMapAnim stops the music with fade control $4 before
-- the bird appears (engine/overworld/player_animations.asm:123, home/overworld.asm:772).
-- POKEPORT_DRIVER=tests/drivers/fly_bird_bug1840_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Screens = require("src.ui.Screens")
local Music = require("src.core.Music")
local TownMap = require("src.ui.TownMap")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local vol = game.save.options and game.save.options.musicVol
if vol == 0 then
U.log("music volume is 0 in options; raise it or the fade is inaudible")
end
if (game.save.options and game.save.options.sfxVol) == 0 then
U.log("sfx volume is 0 in options; the bird's SFX_FLY will be silent too")
end
-- BuildFlyLocationsList only offers visited towns
game.save.visited = game.save.visited or {}
for _, town in ipairs({ "PALLET_TOWN", "VIRIDIAN_CITY", "PEWTER_CITY",
"CERULEAN_CITY", "CELADON_CITY" }) do
game.save.visited[town] = true
end
U.teleport(game, "PALLET_TOWN", 5, 6, "down")
U.wait(30)
local flown = nil
Screens.push(game, "TownMap", { fly = true,
onFly = function(mapId) flown = mapId end })
U.wait(20)
local map = game.stack:top()
check("the FLY picker opened", getmetatable(map) == TownMap)
check("it is in fly mode", map.fly == true)
check("the destination marker is the bird sheet", map.birdSheet ~= nil)
check("the blinking cursor art is still loaded for the plain viewer",
map.bg == nil or map.bg.cursor ~= nil)
U.log("fly destinations:", #(map.locs or {}))
U.shot(game, SHOT_DIR .. "/bug1840_flypicker.png")
-- the marker must not blink: sample the same screen a few frames apart
map.blink = 0
U.wait(2)
U.shot(game, SHOT_DIR .. "/bug1840_flypicker_a.png")
map.blink = 30
U.wait(2)
U.shot(game, SHOT_DIR .. "/bug1840_flypicker_b.png")
U.log("compare", SHOT_DIR .. "/bug1840_flypicker_a.png",
"with _b.png: they must be identical")
U.tap(game, "up")
U.wait(20)
U.tap(game, "a")
U.wait(20)
check("A picks a destination", flown ~= nil)
U.log("flying to", tostring(flown))
local faded = false
local realFade = Music.fadeOut
Music.fadeOut = function(control, pending)
faded = control
return realFade(control, pending)
end
local ow = game.overworld
if flown then ow:flyTo(flown) end
U.wait(1)
check("departure asks the music to fade", faded ~= false)
check("with StopMusic's control byte $4", faded == 4)
check("the world holds during the fade", ow.flyFade ~= nil)
check("and the bird is not on screen yet", ow.flyAnim == nil)
U.shot(game, SHOT_DIR .. "/bug1840_fading.png")
local flapping = false
for _ = 1, 120 do
if ow.flyAnim then flapping = true break end
U.wait(1)
end
check("the bird appears once the fade is done", flapping)
Music.fadeOut = realFade
U.wait(6)
U.shot(game, SHOT_DIR .. "/bug1840_bird.png")
U.log("captured", SHOT_DIR .. "/bug1840_{flypicker,fading,bird}.png")
U.log("Right: the picker marks the town with a small bird that holds still,")
U.log("and pressing A drops the map theme to silence before the bird flaps.")
U.log("The near-miss is a square bracket cursor blinking on the town, or the")
U.log("theme still playing under the flap and cutting off at the warp.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,129 @@
-- Eye/ear check on the Pokemon Tower 6F ghost scene (#1849): entry wipe,
-- the "GHOST appeared!" wording, the SILPH SCOPE unveil colours, and the
-- cry landing on the CUBONE's-mother line.
-- pokered engine/battle/core.asm:6695-6702, data/text/text_2.asm:1251-1255,
-- engine/battle/ghost_marowak_anim.asm:3-5,77, scripts/PokemonTower6F.asm:137-148.
-- POKEPORT_DRIVER=tests/drivers/ghost_marowak_bug1849_test.lua POKEPORT_IDENTITY=bug1849 POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
-- PokemonTower6FMarowakCoords (scripts/PokemonTower6F.asm:43-45) is
-- (10, 16); the 7F stairs at (9, 16) sit right beside it, so the cell
-- north of the trigger is open floor.
local MAP = "POKEMON_TOWER_6F"
local TRIGGER = { x = 10, y = 16 }
local STAND = { x = 10, y = 15, facing = "down" }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local opts = game.save.options or {}
local sfxVol = opts.sfxVol or 7
if sfxVol == 0 then
U.log("FAIL sfx volume is 0: the MAROWAK cry is the audible half of this")
U.log(" check and will not be heard. Set SFX to 7 in OPTION first.")
end
check(("sfx volume %d"):format(sfxVol), sfxVol > 0)
game.save.party = { Pokemon.new(game.data, "CHARMANDER", 40) }
game.save.inventory = game.save.inventory or {}
game.save.inventory.SILPH_SCOPE = 1
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = nil
check("the SILPH SCOPE is in the bag (this is the unveil path)",
(game.save.inventory.SILPH_SCOPE or 0) > 0)
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(20)
local ow = game.overworld
check("standing on " .. MAP, ow.map.id == MAP)
if not ow.map:isWalkableCell(STAND.x, STAND.y) then
-- a map edit moved the floor: take any free neighbour of the trigger
for _, d in ipairs({ { 0, -1 }, { -1, 0 }, { 1, 0 }, { 0, 1 } }) do
local cx, cy = TRIGGER.x + d[1], TRIGGER.y + d[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
local facing = (d[2] == -1 and "down") or (d[2] == 1 and "up")
or (d[1] == -1 and "right") or "left"
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
cx, cy, "facing", facing)
U.teleport(game, MAP, cx, cy, facing)
U.wait(20)
ow = game.overworld
break
end
end
end
-- walk onto the trigger cell ourselves; the Be gone... box opens on arrival
local BattleTransition = require("src.render.BattleTransition")
local sawWipe = false
local battle
for _ = 1, 240 do
U.hold(game, "down", 2)
U.tap(game, "a")
U.wait(4)
local top = game.stack:top()
if getmetatable(top) == BattleTransition then
sawWipe = true
U.shot(game, DIR .. "/bug1849_entry_wipe.png")
end
if getmetatable(top) == BattleState then
battle = top
break
end
end
check("the ghost battle started", battle ~= nil)
check("an entry wipe ran before it (pushBattle, not a bare stack push)",
sawWipe)
if battle then
check("the foe is disguised as the GHOST", battle.enemy.name == "GHOST")
U.log("intro line reads:", (tostring(battle.introText):gsub("\n", " / ")))
check("the intro line is \"GHOST appeared!\", with no article",
battle.introText == "GHOST\nappeared!")
check("the scope unveil is queued", battle.scopeReveal == true)
-- ride the unveil: mash A through the boxes and shoot the anim
local shotFlash, shotFade, shotDone = false, false, false
for _ = 1, 900 do
U.tap(game, "a")
U.wait(2)
local gr = battle.ghostReveal
if gr then
if not shotFlash and gr.t >= 20 then
shotFlash = true
U.shot(game, DIR .. "/bug1849_unveil_flash.png")
end
if not shotFade and gr.t >= 100 then
shotFade = true
U.shot(game, DIR .. "/bug1849_unveil_fadein.png")
end
elseif shotFade and not shotDone then
shotDone = true
U.shot(game, DIR .. "/bug1849_unveiled.png")
break
end
end
check("the unveil animation played", shotFlash and shotFade)
check("the real name came back: " .. tostring(battle.enemy.name),
battle.enemy.name ~= "GHOST")
end
U.log("Shots are in " .. DIR .. ": bug1849_entry_wipe, _unveil_flash,")
U.log("_unveil_fadein, _unveiled.png. In OG RED mode MarowakAnim draws the")
U.log("pic as a sprite under OBJ palette 1, so the flashing ghost and the")
U.log("fading-in MAROWAK are green (pink in Blue) and only settle back to")
U.log("the red background palette once the animation ends. The near-miss is")
U.log("a pic that stays red the whole way through.")
U.log("Beat the MAROWAK and listen: the cry belongs on the CUBONE's-mother")
U.log("box, not on the one after it.")
while true do
coroutine.yield()
end
end
+131
View File
@@ -0,0 +1,131 @@
-- #1860: jingles cut or dropped by the text blip. GiveItemScript
-- (pokegold engine/overworld/scripting.asm:441-449), Oak's rating
-- (engine/events/prof_oaks_pc.asm:14-21), bug contest judging
-- (engine/events/bug_contest/judging.asm:29-32).
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_jingle_bug1860_test.lua love .
local U = require("tests.drivers.util")
local Sound = require("src.core.Sound")
return function(game)
U.wait(45)
local world = game.world
assert(world and world.map and world.vm, "gold world did not boot")
local opts = game.save.options or {}
if (opts.sfxVol or 7) == 0 then
U.log("WARNING: options.sfxVol is 0 -- every jingle below will be")
U.log("WARNING: silent; raise SFX volume before judging this by ear")
end
local results = {}
local function check(label, ok)
results[#results + 1] = (ok and "PASS " or "FAIL ") .. label
end
local function specialId(name)
for index, entry in ipairs(world.vm.specialOrder or {}) do
if entry == name then return index - 1 end
end
return nil
end
-- World:specialSound resolves the numeric index (itemIdByIndex), and only
-- a TM_HM-pocket item rings Sfx_GetTm.
local tm
for _, def in pairs(game.data.items or {}) do
if type(def) == "table" and def.pocket == "TM_HM" and def.index then
tm = def.index
break
end
end
check("cache names a TM_HM-pocket item", tm ~= nil)
-- Runs one scripted moment while mashing A every 4th frame the whole way.
-- everBusy: a gated sfx started at all (the drop half of the bug).
-- maxRun: longest unbroken stretch it kept sounding (the cut half).
-- popEarly: a box with the sfx hold popped while the jingle still rang.
local function runMoment(label, script, minRun)
U.wait(30)
world.vm:start(script)
U.wait(2)
local everBusy, maxRun, run = false, 0, 0
local sawHeld, popEarly = false, false
local pressIn = 4
for _ = 1, 1500 do
local busy = Sound.sfxBusy()
if busy then
everBusy = true
run = run + 1
if run > maxRun then maxRun = run end
else
run = 0
end
local top = game.stack:top()
if top and top.sfxWait and busy then sawHeld = true end
pressIn = pressIn - 1
if pressIn <= 0 then
pressIn = 4
game.input.pressQueue[#game.input.pressQueue + 1] = "a"
game.input.state.a = true
U.wait(1)
game.input.state.a = false
if top and top.sfxWait and busy and game.stack:top() ~= top then
popEarly = true
end
else
U.wait(1)
end
if not world:busy() and not game.stack:top() then break end
end
check(label .. ": a jingle started (not dropped by the blip)", everBusy)
check(("%s: it survived mashed A for %d frames (want >= %d)")
:format(label, maxRun, minRun), maxRun >= minRun)
check(label .. ": the held box refused A while it rang",
sawHeld and not popEarly)
end
if tm then
runMoment("verbosegiveitem TM", {
{ op = "opentext" },
{ op = "verbosegiveitem", args = { tm, 1 } },
{ op = "closetext" },
{ op = "end" },
}, 60)
end
local oak = specialId("ProfOaksPCBoot")
check("specialOrder names ProfOaksPCBoot", oak ~= nil)
if oak then
game.save.pokedex = game.save.pokedex or { seen = {}, caught = {} }
game.save.pokedex.caught = {}
for i = 1, 150 do game.save.pokedex.caught["DEX_SEED_" .. i] = true end
runMoment("Oak's dex rating fanfare", {
{ op = "opentext" },
{ op = "special", id = oak },
{ op = "closetext" },
{ op = "end" },
}, 40)
end
local judging = specialId("BugContestJudging")
check("specialOrder names BugContestJudging", judging ~= nil)
if judging then
runMoment("bug contest place fanfares", {
{ op = "opentext" },
{ op = "special", id = judging },
{ op = "closetext" },
{ op = "end" },
}, 30)
end
for _, line in ipairs(results) do U.log(line) end
U.log("Right sounds like: the TM jingle, the dex-rating fanfare and each")
U.log("place fanfare play out in full even while A is mashed; the box under")
U.log("each one only closes once its jingle has finished ringing.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,156 @@
-- #1858: stats-screen strings stamped white paper over the tinted lower half.
-- Rows 8-17 stay on BG palette 0, whose colour 0 LoadStatsScreenPals sets to
-- the page tint, so a glyph's blank pixels show that tint -- engine/gfx/
-- color.asm:342-359, engine/gfx/cgb_layouts.asm:167-192. Never POKEPORT_SPEED
-- here: the page redraw and its cry are being judged in order.
-- POKEPORT_IDENTITY=c10-gold POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/gold_stats_text_paper_bug1858_test.lua POKEPORT_SHOT_DIR=/tmp/gold-bug1858 love .
local U = require("tests.drivers.util")
local GbcPalette = require("src.render.GbcPalette")
local Mon = require("src.battle.gen2.Mon")
local SummaryMenu = require("src.ui.gen2.SummaryMenu")
-- The rows the lower half prints on: EXP POINTS / LEVEL UP / TO on the pink
-- page, ITEM and the move list on green, <ID>№. and OT/ on blue.
local TEXT_ROWS = {
[SummaryMenu.PINK_PAGE] = {
{ "the EXP POINTS row", 80, 72, 159, 79 },
{ "the STATUS/ row", 0, 96, 55, 103 },
{ "the LEVEL UP row", 80, 96, 159, 103 },
},
[SummaryMenu.GREEN_PAGE] = {
{ "the ITEM row", 0, 64, 159, 71 },
{ "the first move name", 64, 80, 159, 87 },
{ "the first PP row", 96, 88, 159, 95 },
},
[SummaryMenu.BLUE_PAGE] = {
{ "the ID No. row", 0, 72, 79, 79 },
{ "the OT/ row", 0, 96, 79, 103 },
{ "the ATTACK row", 88, 64, 159, 71 },
},
}
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1858"
local fails, lines = 0, {}
local function claim(ok, text)
if not ok then fails = fails + 1 end
lines[#lines + 1] = (ok and "PASS " or "FAIL ") .. text
return ok
end
local function tap(button)
U.tap(game, button)
U.wait(4)
end
local function top() return game.stack:top() end
U.wait(45)
if not (game.world and game.world.map) then
U.log("FAIL the gold world never booted, nothing to open a summary over")
while true do coroutine.yield() end
end
local save = game.save
save.player.name = "GOLD"
save.party = { Mon.new(game.data, "CYNDAQUIL", 22),
Mon.new(game.data, "TOTODILE", 18) }
local lead = save.party[1]
lead.item = "BERRY"
lead.hp = math.max(1, math.floor((lead.maxHp or 1) * 0.45))
claim(GbcPalette.available(), "the GBC shade-remap shader compiled")
game.options = game.options or {}
game.options.color = "gbc"
save.options = game.options
GbcPalette.applyOptions(game.options)
claim(GbcPalette.mode == "gbc", "COLOR is GBC for the run")
-- The summary draws at GB coordinates, so a 160x144 canvas at scale 1 makes
-- a GB pixel a pixel and the paper countable.
local function frameOf(screen)
local G = love.graphics
local canvas = G.newCanvas(160, 144)
G.setCanvas(canvas)
G.clear(0, 0, 0, 1)
G.setColor(1, 1, 1, 1)
screen:draw()
G.setCanvas()
G.setColor(1, 1, 1, 1)
return canvas:newImageData()
end
local function whitePct(img, rect)
local white, total = 0, 0
for y = rect[3], rect[5] do
for x = rect[2], rect[4] do
local r, g, b = img:getPixel(x, y)
total = total + 1
if r * 255 > 250 and g * 255 > 250 and b * 255 > 250 then
white = white + 1
end
end
end
return white / total * 100
end
tap("start")
local menu = top()
if not claim(menu and menu.screenId == "Gen2StartMenu",
"START opened the menu") then
for _, line in ipairs(lines) do U.log(line) end
while true do coroutine.yield() end
end
for _ = 1, 10 do
if menu.list:current().value == "pokemon" then break end
tap("down")
end
tap("a")
local party = top()
if not claim(party and party.screenId == "Gen2PartyMenu",
"that opened the party list") then
for _, line in ipairs(lines) do U.log(line) end
while true do coroutine.yield() end
end
tap("a")
tap("a")
local summary = top()
if not claim(summary and summary.screenId == "Gen2SummaryMenu",
"STATS opened the summary") then
for _, line in ipairs(lines) do U.log(line) end
while true do coroutine.yield() end
end
local NAMES = { "pink", "green", "blue" }
local function inspect(shot)
local img = frameOf(summary)
local page = summary.page
for _, rect in ipairs(TEXT_ROWS[page] or {}) do
local pct = whitePct(img, rect)
U.log((" %-22s %5.1f%% white"):format(rect[1], pct))
claim(pct <= 1, ("%s (%s page) has no white paper behind it"):format(
rect[1], NAMES[page] or "?"))
end
U.shot(game, ("%s/%s.png"):format(out, shot))
end
inspect("01-pink-page")
tap("right")
claim(summary.page == SummaryMenu.GREEN_PAGE, "right reached the green page")
inspect("02-green-page")
tap("right")
claim(summary.page == SummaryMenu.BLUE_PAGE, "right reached the blue page")
inspect("03-blue-page")
tap("right")
for _, line in ipairs(lines) do U.log(line) end
U.log(("%d checks, %d failed"):format(#lines, fails))
if fails > 0 then
U.log("a FAIL above means the run is not showing what it claims to")
end
U.log("what right looks like: everything under the rule is one flat wash of")
U.log("the page colour, with the letters and numbers sitting straight on it.")
U.log("a white box the width of each string is the bug; a white box behind")
U.log("only the numbers, or only the upper half tinted, is a half fix.")
U.log("left and right turn the page; the pad is yours.")
while true do coroutine.yield() end
end
@@ -0,0 +1,141 @@
-- #1862: the whirlpool must stay on screen for the whole surf wash.
-- DisappearWhirlpool blanks hBGMapMode before the redraw, plays the sound and
-- only then buffers the screen -- engine/events/overworld.asm:1150-1165,
-- engine/events/field_moves.asm:5-10. No POKEPORT_SPEED: audio runs on its
-- own real-time accumulator and the ordering is the whole point.
-- POKEPORT_IDENTITY=c10-gold POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/gold_whirlpool_block_bug1862_test.lua POKEPORT_SHOT_DIR=/tmp/gold-bug1862 love .
local U = require("tests.drivers.util")
local FieldMoves = require("src.world.gen2.FieldMoves")
local Mon = require("src.battle.gen2.Mon")
local Permissions = require("src.world.gen2.Permissions")
-- data/generated/maps.lua, ROUTE_41: whirlpools at (22,12), (42,24), (6,30)
-- and (28,48). Stand one cell north of the first and face it.
local MAP = "ROUTE_41"
local WHIRL = { x = 22, y = 12 }
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1862"
local fails, lines = 0, {}
local function claim(ok, text)
if not ok then fails = fails + 1 end
lines[#lines + 1] = (ok and "PASS " or "FAIL ") .. text
return ok
end
local function report()
for _, line in ipairs(lines) do U.log(line) end
U.log(("%d checks, %d failed"):format(#lines, fails))
end
U.wait(45)
local world = game.world
if not (world and world.map) then
U.log("FAIL the gold world never booted, nothing to drain")
while true do coroutine.yield() end
end
local vol = game.save.options and game.save.options.sfxVol
claim(vol ~= 0, ("SFX VOL is %s"):format(tostring(vol)))
if vol == 0 then
U.log("SFX VOL is ZERO, so the wash this is timed against is inaudible;")
U.log("turn it up in OPTION before judging anything by ear.")
end
local badges = game.save.player.badges or {}
game.save.player.badges = badges
for _, badge in pairs(FieldMoves.BADGE) do badges[badge] = true end
local swimmer = Mon.new(game.data, "LAPRAS", 30,
{ moves = { { id = "SURF" }, { id = "WHIRLPOOL" } } })
claim(swimmer ~= nil, "a LAPRAS that knows SURF and WHIRLPOOL")
game.save.party = { swimmer }
world:applyPlayerState(FieldMoves.PLAYER_SURF)
world:setMap(MAP, WHIRL.x, WHIRL.y - 1, "down")
U.wait(15)
world.noWildEncounters = true
local ctx = world:fieldContext()
local facing = Permissions.isWhirlpool(ctx.facingColl)
if not facing then
-- A re-import moved it: take any whirlpool with open water above it.
local def = world.maps[MAP]
for y = 1, (def.height or 0) * 2 - 1 do
for x = 0, (def.width or 0) * 2 - 1 do
if not facing and Permissions.isWhirlpool(world.map:cellCollision(x, y))
and Permissions.isWater(world.map:cellCollision(x, y - 1)) then
WHIRL.x, WHIRL.y = x, y
world:setMap(MAP, x, y - 1, "down")
world:applyPlayerState(FieldMoves.PLAYER_SURF)
U.wait(10)
ctx = world:fieldContext()
facing = Permissions.isWhirlpool(ctx.facingColl)
U.log(("note: using the whirlpool at (%d,%d)"):format(x, y))
end
end
end
end
claim(facing, ("facing the whirlpool at (%d,%d) on %s"):format(
WHIRL.x, WHIRL.y, MAP))
local blocks = world.maps[MAP] and world.maps[MAP].blocks
local index = ctx.facingBlockIndex
local before = blocks and index and blocks[index]
claim(before ~= nil, "and the block behind it is readable")
if not (facing and before) then
report()
U.log("nothing to drive; stopping rather than faking the moment")
while true do coroutine.yield() end
end
U.shot(game, out .. "/01-facing.png")
local function tap(button, gap)
table.insert(game.input.pressQueue, button)
game.input.state[button] = true
coroutine.yield()
game.input.state[button] = false
for _ = 1, (gap or 8) do coroutine.yield() end
end
-- A into the whirlpool, then A through the ask box (YES is the default) and
-- the USED WHIRLPOOL line.
for _ = 1, 24 do
if world.fieldMove and world.fieldMove.phase == "whirlpoolsfx" then break end
tap("a")
end
claim(world.fieldMove and world.fieldMove.phase == "whirlpoolsfx",
"the drain reached PlayWhirlpoolSound")
claim(blocks[index] == before,
"the whirlpool is still in the block buffer as the wash starts")
U.shot(game, out .. "/02-wash-starts.png")
local swapped, phaseFrames = nil, 0
for _ = 1, 300 do
if world.fieldMove and world.fieldMove.phase == "whirlpoolsfx" then
phaseFrames = phaseFrames + 1
if blocks[index] ~= before and not swapped then swapped = phaseFrames end
if phaseFrames == 6 then U.shot(game, out .. "/03-mid-wash.png") end
end
if not world:busy() then break end
coroutine.yield()
end
claim(swapped == nil,
"and it stayed there for every frame of the wash")
claim(blocks[index] ~= before,
"the block was swapped once the wash ended")
claim(phaseFrames > 0 and phaseFrames < 180,
("the wash ended on its own after %d frames"):format(phaseFrames))
U.wait(4)
U.shot(game, out .. "/04-drained.png")
report()
if fails > 0 then
U.log("a FAIL above means the run is not showing what it claims to")
end
U.log("what right looks like: the whirlpool keeps spinning through the whole")
U.log("surf wash and only pops out of the water when the sound has finished.")
U.log("water where the whirlpool was while the wash is still playing is the")
U.log("bug; a whirlpool still there after control comes back is the overshoot.")
U.log("the pad is yours -- the other whirlpools are further south.")
while true do coroutine.yield() end
end
@@ -0,0 +1,122 @@
-- Eye check on NIGHT_SHADE's wavy screen (#1848). AnimationWavyScreen's
-- `ld c, $ff` counts outer passes and the inner loop exits twice a frame
-- (pokered engine/battle/animations.asm:1884-1903, 1916-1927), so the
-- wobble runs ~128 frames with a 16-line wave, not 255 with a 32-line one.
-- POKEPORT_DRIVER=tests/drivers/nightshade_anim_bug1848_test.lua POKEPORT_IDENTITY=bug1848 POKEPORT_TOUCH=0 love .
-- No POKEPORT_SPEED: fast-forward scales the logic clock only.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local AnimPlayer = require("src.battle.AnimPlayer")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
-- data/generated/maps.lua ROUTE_1: open path south of the first grass
-- patch; pokered data/maps/objects/Route1.asm parks its youngsters at
-- (5, 24) and (15, 13), so nobody is standing here or watching.
local MAP = "ROUTE_1"
local STAND = { x = 5, y = 5, facing = "down" }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local opts = game.save.options or {}
local sfxVol = opts.sfxVol or 7
if sfxVol == 0 then
U.log("FAIL sfx volume is 0, so the flash that opens the animation is")
U.log(" silent. Set SFX to 7 in OPTION first.")
end
check(("sfx volume %d"):format(sfxVol), sfxVol > 0)
if opts.animations == false then
U.log("FAIL OPTION has animations off, which skips every queued anim row.")
end
check("battle animations are on", opts.animations ~= false)
local anims = (game.data.battle_anims or {}).moveAnims or {}
check("NIGHT_SHADE has an animation", anims.NIGHT_SHADE ~= nil)
-- what the animation player budgets for the effect, ahead of the battle
local probe = AnimPlayer.new(game.data.battle_anims)
probe:start("NIGHT_SHADE", true)
local budget
for _, e in ipairs(probe.events) do
if e.effect == "SE_WAVY_SCREEN" then budget = e.dur end
end
check(("SE_WAVY_SCREEN is budgeted %s frames (want 128)"):format(tostring(budget)),
budget == 128)
local lead = Pokemon.new(game.data, "GASTLY", 25)
lead.moves = { { id = "NIGHT_SHADE", pp = 15 } }
game.save.party = { lead }
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(15)
local ow = game.overworld
if not ow.map:isWalkableCell(STAND.x, STAND.y) then
for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
local cx, cy = STAND.x + d[1], STAND.y + d[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), cx, cy)
U.teleport(game, MAP, cx, cy, STAND.facing)
U.wait(15)
ow = game.overworld
break
end
end
end
check("standing on " .. MAP, ow.map.id == MAP)
local battle = BattleState.newWild(game, "PIDGEY", 6)
battle.onFinish = function() end
ow:pushBattle(battle)
local function waitPhase(phase, tries)
for _ = 1, tries do
if battle.phase == phase then return true end
U.tap(game, "a")
U.wait(6)
end
return battle.phase == phase
end
check("the battle reached the menu", waitPhase("menu", 60))
U.tap(game, "a")
check("NIGHT_SHADE is the move on the list", waitPhase("moveSelect", 20))
U.tap(game, "a")
-- ride the animation and time the wave from the fx layer itself
local started, frames, phases = false, 0, {}
local shots = { [4] = "start", [64] = "middle", [124] = "end" }
for _ = 1, 400 do
local wavy = battle.fx and battle.fx.wavy
if wavy then
started = true
frames = frames + 1
phases[#phases + 1] = wavy.phase
local tag = shots[frames]
if tag then
U.shot(game, ("%s/bug1848_wavy_%s.png"):format(DIR, tag))
end
elseif started then
break
end
U.wait(1)
end
check("the wave actually ran", started)
check(("the wave lasted %d frames (want 128)"):format(frames),
frames >= 120 and frames <= 136)
local step = (#phases >= 2) and (phases[2] - phases[1]) or 0
check(("the offset pointer advances %d entries a frame (want 2)"):format(step),
step == 2)
U.log("Three shots are in " .. DIR .. ": bug1848_wavy_start/middle/end.png.")
U.log("The screen should ripple with about nine full waves top to bottom,")
U.log("scrolling upward, and settle after roughly two seconds. The near-miss")
U.log("to watch for is half that many waves crawling for twice as long.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,118 @@
-- Party submenu box geometry (#1819) with PokemonMenuEntries in full (#1833).
-- pokered anchors this box to the bottom row of the screen and grows it
-- upward two rows per field move, indenting it by the widest field move name
-- (engine/menus/text_box.asm:397-440, data/moves/field_moves.asm,
-- data/text_boxes.asm:33).
-- POKEPORT_DRIVER=tests/drivers/party_submenu_box_bug1819_test.lua POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Screens = require("src.ui.Screens")
local Font = require("src.render.Font")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local mon = Pokemon.new(game.data, "CHANSEY", 40)
game.save.party = { mon }
game.save.player.name = "bryan"
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(10)
-- record the boxes one real draw pass puts on screen; the submenu is the
-- only one that does not start at column 0
local function boxThisFrame()
local seen = {}
local real = Font.drawBox
Font.drawBox = function(tx, ty, tw, th, ...)
seen[#seen + 1] = { tx, ty, tw, th }
return real(tx, ty, tw, th, ...)
end
U.wait(2)
Font.drawBox = real
for i = #seen, 1, -1 do
if seen[i][1] > 0 then return seen[i] end
end
return nil
end
local function openSubmenu(moves)
mon.moves = moves
Screens.push(game, "PartyMenu", {})
U.wait(12)
U.tap(game, "a")
U.wait(12)
return game.stack:top()
end
local function closeSubmenu()
U.tap(game, "b")
U.wait(8)
U.tap(game, "b")
U.wait(8)
end
local function move(id)
local def = game.data.moves[id]
return { id = id, pp = def and def.pp or 10 }
end
-- tx, ty, tw, th the port should hand Font.drawBox, per case
local cases = {
{ name = "no field moves", shot = "bug1819_submenu_plain.png",
moves = { move("POUND") }, rows = 3, box = { 11, 11, 9, 7 } },
{ name = "one field move (STRENGTH indents to tile 10)",
shot = "bug1819_submenu_strength.png",
moves = { move("STRENGTH") }, rows = 4, box = { 9, 8, 11, 10 } },
{ name = "SOFTBOILED, the widest name (tile 8)",
shot = "bug1819_submenu_softboiled.png",
moves = { move("SOFTBOILED") }, rows = 4, box = { 7, 8, 13, 10 } },
}
for _, c in ipairs(cases) do
local pm = openSubmenu(c.moves)
local ok = pm and pm.submenu
check(c.name .. ": the submenu opened", ok == true)
if ok then
local labels = {}
for i, e in ipairs(pm.subItems) do labels[i] = e.label end
U.log(c.name .. " lists:", table.concat(labels, " / "))
check(c.name .. ": " .. c.rows .. " rows ending in CANCEL",
#pm.subItems == c.rows
and pm.subItems[#pm.subItems].action == "cancel")
local b = boxThisFrame()
if not b then
check(c.name .. ": a submenu box was drawn", false)
else
U.log(c.name .. " box tx,ty,tw,th:", b[1], b[2], b[3], b[4])
check(c.name .. ": box is " .. table.concat(c.box, ","),
b[1] == c.box[1] and b[2] == c.box[2]
and b[3] == c.box[3] and b[4] == c.box[4])
check(c.name .. ": its bottom border is on tile row 17",
b[2] + b[4] - 1 == 17)
check(c.name .. ": its right edge is on tile column 19",
b[1] + b[3] - 1 == 19)
end
U.shot(game, SHOT_DIR .. "/" .. c.shot)
U.log("captured", SHOT_DIR .. "/" .. c.shot)
end
closeSubmenu()
end
-- leave the last one up for the eye
openSubmenu({ move("SOFTBOILED") })
U.log("The submenu on screen should sit flush against the bottom of the")
U.log("screen, its lower border sharing the screen's last pixel row, with")
U.log("SOFTBOILED / STATS / SWITCH / CANCEL inside it and one blank row")
U.log("above SOFTBOILED. The near-miss to watch for is an 8px gap under")
U.log("the box that shows the message box's own bottom line, so the two")
U.log("borders read as a doubled or detached line.")
while true do
coroutine.yield()
end
end
+5 -1
View File
@@ -72,7 +72,11 @@ return function(game)
check(howY == 112, "How many? sits on text-box first line (y=112)")
check(textBox ~= nil, "bottom PrintText box drawn")
check(qtyBox ~= nil, "quantity box at pokered row 9")
check(maxItemY and maxItemY <= 72, "last visible item row above qty/text (y<=72)")
-- LIST_MENU_BOX 4,2 - 19,12: the fourth row's quantity sits at y=88
-- (data/text_boxes.asm:13, engine/menus/players_pc.asm)
check(maxItemY == 88, "last visible item row at y=88")
check(textBox and maxItemY and maxItemY + 8 <= textBox.ty * 8,
"last visible item row clears the bottom text box (#174)")
if maxItemY and howY then
check(maxItemY + 8 <= howY, "item glyphs do not overlap How many?")
end
@@ -0,0 +1,206 @@
-- The four PC list screens: the player's PC item lists (#1845) and Bill's
-- PC mon lists plus CHANGE BOX (#1847). Both are LIST_MENU_BOX screens in
-- the original (home/list_menu.asm:29-31, engine/menus/save.asm:437-506),
-- not full-screen lists of their own.
-- SHOT_DIR=/tmp/pc_lists POKEPORT_DRIVER=tests/drivers/pc_lists_bug1845_bug1847_test.lua POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Boxes = require("src.pokemon.Boxes")
local BoxMenu = require("src.ui.BoxMenu")
local Font = require("src.render.Font")
local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu")
local PlayerPC = require("src.ui.PlayerPC")
local Pokemon = require("src.pokemon.Pokemon")
local TextBox = require("src.render.TextBox")
local pass = true
local function check(label, ok)
if ok then U.log("PASS", label) else pass = false; U.log("FAIL", label) end
return ok
end
-- record every Font.drawBox/Font.draw of one frame so the geometry can be
-- asserted instead of eyeballed
local function capture()
local out = {}
local savedDraw, savedBox = Font.draw, Font.drawBox
Font.draw = function(text, x, y)
out[#out + 1] = { kind = "text", text = tostring(text), x = x, y = y }
return savedDraw(text, x, y)
end
Font.drawBox = function(tx, ty, tw, th)
out[#out + 1] = { kind = "box", tx = tx, ty = ty, tw = tw, th = th }
return savedBox(tx, ty, tw, th)
end
game.stack:draw()
Font.draw, Font.drawBox = savedDraw, savedBox
return out
end
local function hasBox(drawn, tx, ty, tw, th)
for _, d in ipairs(drawn) do
if d.kind == "box" and d.tx == tx and d.ty == ty
and d.tw == tw and d.th == th then return true end
end
return false
end
local function textOnRow(drawn, y)
for _, d in ipairs(drawn) do
if d.kind == "text" and d.y == y and d.text ~= "" then return d end
end
return nil
end
local function hasText(drawn, want)
for _, d in ipairs(drawn) do
if d.kind == "text" and d.text == want then return d end
end
return nil
end
-- pokered data/maps/objects/ViridianPokecenter.asm keeps the floor east of
-- the counter free; the PC itself is a hidden object, so the screens are
-- pushed directly rather than bumped into
local MAP, STAND = "VIRIDIAN_POKECENTER", { x = 13, y = 4 }
U.teleport(game, MAP, STAND.x, STAND.y, "up")
U.wait(6)
local function freeNeighbour(ow)
for dy = -2, 2 do
for dx = -2, 2 do
local cx, cy = STAND.x + dx, STAND.y + dy
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
return cx, cy
end
end
end
end
local ow = game.overworld
if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then
local cx, cy = freeNeighbour(ow)
if cx then
U.log("stand cell blocked, standing on", cx, cy)
U.teleport(game, MAP, cx, cy, "up")
U.wait(6)
end
end
-- #1845: the player's PC item lists
game.save.pcItems = { POTION = 3, ANTIDOTE = 1, REPEL = 5, ESCAPE_ROPE = 2 }
game.stack:push(PlayerPC.new(game))
U.wait(4)
U.tap(game, "a") -- WITHDRAW ITEM
U.wait(6)
local list = game.stack:top()
check("WITHDRAW ITEM opened a list", getmetatable(list) == ListMenu)
if getmetatable(list) == ListMenu then
check("the list is drawn as LIST_MENU_BOX, not a screen", list.itemBox == true)
check("the PC menu underneath stays visible", list.isOpaque == false)
check("PrintListMenuEntries' four rows", list.rows == 4)
check("the list ends on the CANCEL terminator",
list.items[#list.items].cancel == true)
check("the withdraw prompt is up before anything is chosen",
type(list.footer) == "string" and list.footer:find("withdraw", 1, true) ~= nil)
local drawn = capture()
check("LIST_MENU_BOX at tile 4,2 (16x11)", hasBox(drawn, 4, 2, 16, 11))
check("the prompt's text box at tile 0,12 (20x6)", hasBox(drawn, 0, 12, 20, 6))
check("nothing on the invented title row (y=4)", textOnRow(drawn, 4) == nil)
local name = hasText(drawn, list.items[1].label)
check("first name at hlcoord 6,4 (48,32)",
name ~= nil and name.x == 48 and name.y == 32)
end
U.shot(game, DIR .. "/pc_1845_withdraw_item.png")
U.tap(game, "b") -- back to the PC menu
U.wait(4)
U.tap(game, "down"); U.tap(game, "down"); U.tap(game, "down")
U.wait(2)
U.tap(game, "a") -- LOG OFF
U.wait(6)
-- #1847: Bill's PC mon lists and CHANGE BOX
local boxes = Boxes.ensure(game.save)
boxes[1] = {
Pokemon.new(game.data, "PIDGEY", 12),
Pokemon.new(game.data, "RATTATA", 7),
Pokemon.new(game.data, "NIDORAN_M", 15),
Pokemon.new(game.data, "ZUBAT", 9),
Pokemon.new(game.data, "ONIX", 22),
}
boxes[3] = { Pokemon.new(game.data, "MAGIKARP", 5) }
game.save.currentBox = 1
game.save.party = { Pokemon.new(game.data, "CHARMANDER", 14) }
game.stack:push(BoxMenu.new(game))
U.wait(4)
U.tap(game, "a") -- WITHDRAW POKéMON
U.wait(6)
local mons = game.stack:top()
check("WITHDRAW POKéMON opened a list", getmetatable(mons) == ListMenu)
if getmetatable(mons) == ListMenu then
check("the mon list is drawn as LIST_MENU_BOX", mons.itemBox == true)
check("the mon list ends on CANCEL",
mons.items[#mons.items].cancel == true)
check("the level is a row of its own, not part of the name",
mons.items[1].sub == ":L12"
and mons.items[1].label:find(":L") == nil)
local drawn = capture()
check("LIST_MENU_BOX at tile 4,2 (16x11)", hasBox(drawn, 4, 2, 16, 11))
check("no invented BOX 1 (WITHDRAW) title",
hasText(drawn, "BOX 1 (WITHDRAW)") == nil)
local lvl = hasText(drawn, ":L12")
check("PrintLevel one row down, 8 columns right (112,40)",
lvl ~= nil and lvl.x == 112 and lvl.y == 40)
end
U.shot(game, DIR .. "/pc_1847_withdraw_mon.png")
U.tap(game, "b")
U.wait(4)
U.tap(game, "down"); U.tap(game, "down"); U.tap(game, "down")
U.wait(2)
U.tap(game, "a") -- CHANGE BOX
U.wait(30)
local ask = game.stack:top()
check("CHANGE BOX asks before it shows the box list",
getmetatable(ask) == TextBox)
U.shot(game, DIR .. "/pc_1847_change_box_prompt.png")
-- page through "When you change a POKéMON BOX..." and take the YES
local function isPicker(s)
return getmetatable(s) == Menu and s.kind == "pc_box_change"
end
for _ = 1, 12 do
if isPicker(game.stack:top()) then break end
U.tap(game, "a")
U.wait(12)
end
local picker = game.stack:top()
check("YES opens the box picker", isPicker(picker))
if isPicker(picker) then
check("twelve boxes", #picker.items == 12)
check("BoxNames spelling", picker.items[1].label == "BOX 1"
and picker.items[12].label == "BOX12")
check("the cursor starts on the current box",
picker.index == game.save.currentBox)
local drawn = capture()
check("box list at tile 11,0 (9x14)", hasBox(drawn, 11, 0, 9, 14))
check("BOX No. panel at tile 0,0 (11x4)", hasBox(drawn, 0, 0, 11, 4))
local first = hasText(drawn, "BOX 1")
check("BoxNames at hlcoord 13,1 (104,8)",
first ~= nil and first.x == 104 and first.y == 8)
end
U.shot(game, DIR .. "/pc_1847_change_box.png")
U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE")
U.log("On screen now: the CHANGE BOX picker. BOX 1..BOX12 sit in a tall")
U.log("box on the right, BOX No. 1 in a small panel top-left, and a")
U.log("pokeball marks BOX 1 and BOX 3 only, the two that hold POKéMON.")
U.log("The near-miss to watch for is a marker on all twelve boxes, or on")
U.log("none, and BOX10-12 spelled with the space of BOX 1 to BOX 9.")
U.log("Shots are in " .. DIR .. "; the two list shots must show the")
U.log("PC menu and the What? box around a bordered list, never a white")
U.log("page with a title row.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,119 @@
-- Eye/ear check for the Pokédex CONTENTS screen and entry page (#1829):
-- screen furniture, the seven rows, the side-menu cursor, the cry the entry
-- page waits on and the blinking page arrow.
-- engine/menus/pokedex.asm:161-199, :242-273, :500-506; home/joypad2.asm:55-83.
-- No POKEPORT_SPEED: the cry runs on the real-time audio clock.
-- POKEPORT_DRIVER=tests/drivers/pokedex_contents_bug1829_test.lua POKEPORT_IDENTITY=bug1829 POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Screens = require("src.ui.Screens")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local opts = game.save.options or {}
if (opts.sfxVol or 1) == 0 then
U.log("WARNING sfxVol is 0, so the entry page's cry will be silent and")
U.log("WARNING the pause before the height/weight lines will look like a hang")
end
-- dex numbers -> species, so the driver never hardcodes an id
local byDex = {}
for _, def in pairs(game.data.pokemon) do
if def.dex then byDex[def.dex] = def end
end
check("the cache carries a Kanto numbering", byDex[1] ~= nil and byDex[20] ~= nil)
game.save.pokedex = { seen = {}, owned = {} }
for n = 1, 20 do
local def = byDex[n]
if def and n ~= 5 then game.save.pokedex.seen[def.id] = true end
end
for _, n in ipairs({ 1, 2, 3, 7, 8 }) do
local def = byDex[n]
if def then
game.save.pokedex.seen[def.id] = true
game.save.pokedex.owned[def.id] = true
end
end
game.save.player.name = "bryan"
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(20)
local dex = Screens.push(game, "PokedexMenu")
U.wait(20)
check("the dex list is on the stack", game.stack:top() == dex)
check("the list stops at the highest seen number (20)", #dex.items == 20)
check("dex 005 was left unseen, so its row is a dashed line",
dex.items[5] and dex.items[5].name == "----------")
check("dex 001 is owned, so its row carries the ball marker",
dex.items[1] and dex.items[1].ball == true)
U.shot(game, SHOT_DIR .. "/bug1829_contents.png")
-- Right pages the list without moving the cursor off its screen row
local rowBefore = dex.index - dex.scroll
U.tap(game, "right")
U.wait(10)
check("Right scrolled the list", dex.scroll == 7)
check("the cursor kept its screen row", dex.index - dex.scroll == rowBefore)
U.shot(game, SHOT_DIR .. "/bug1829_contents_paged.png")
U.tap(game, "left")
U.wait(10)
check("Left paged back to the top", dex.scroll == 0)
-- A on an owned row opens the side menu: the cursor moves onto the
-- DATA/CRY/AREA/QUIT block that was already on screen
U.tap(game, "a")
U.wait(20)
local side = game.stack:top()
check("A opened the side menu", side ~= dex)
check("the list row kept its hollow cursor", dex.hollowIndex == dex.index)
U.shot(game, SHOT_DIR .. "/bug1829_side_menu.png")
-- DATA is the first row, so A again opens the entry page
U.tap(game, "a")
U.wait(2)
local page = game.stack:top()
check("DATA opened the entry page", page ~= side and page.def ~= nil)
U.shot(game, SHOT_DIR .. "/bug1829_entry_cry.png")
local waited = 0
while page.crying and page:crying() and waited < 300 do
waited = waited + 1
coroutine.yield()
end
U.log("the cry held the page for", waited, "frames")
U.wait(10)
U.shot(game, SHOT_DIR .. "/bug1829_entry_full.png")
check("the entry has more than one page, so the arrow is drawn",
(page.pageCount or 1) > 1)
local function shotAtBlink(target, path)
for _ = 1, 120 do
if (page.blink or 0) == target then break end
coroutine.yield()
end
U.shot(game, path)
end
shotAtBlink(8, SHOT_DIR .. "/bug1829_arrow_on.png")
shotAtBlink(38, SHOT_DIR .. "/bug1829_arrow_off.png")
U.log("Shots are in " .. SHOT_DIR .. ". The CONTENTS screen should read")
U.log("CONTENTS top left, a vertical rule down column 14 with SEEN and OWN")
U.log("counts beside it and DATA/CRY/AREA/QUIT under them, and seven rows")
U.log("of number-above-name with the ball left of the owned names.")
U.log("bug1829_entry_cry should show the frame, name, kind, No. and pic")
U.log("with nothing below the divider while the cry sounds; the height,")
U.log("weight and description arrive in bug1829_entry_full once it ends.")
U.log("The near-miss to watch for: all of it painted at once with the cry")
U.log("playing over the top, and a page arrow that never blinks off")
U.log("(bug1829_arrow_on has it, bug1829_arrow_off should not).")
while true do
coroutine.yield()
end
end
+71
View File
@@ -0,0 +1,71 @@
-- Rare Candy's "grew to level N!" line carries a jingle (#1846): pokered's
-- RareCandyText is text_far _RareCandyText / sound_get_item_1 /
-- text_promptbutton (engine/menus/party_menu.asm:289-293), and outside
-- battle that command plays SFX_GET_ITEM_1 (home/text.asm:546).
-- POKEPORT_DRIVER=tests/drivers/rare_candy_bug1846_test.lua POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Screens = require("src.ui.Screens")
local Bag = require("src.inventory.Bag")
local Sound = require("src.core.Sound")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
if (game.save.options and game.save.options.sfxVol or 1) == 0 then
U.log("WARNING: sfxVol is 0, so nothing here can be heard.")
U.log("WARNING: set SFX volume in OPTION before judging this driver.")
end
local mon = Pokemon.new(game.data, "NIDORINO", 20)
game.save.party = { mon }
game.save.player.name = "bryan"
game.save.inventory = {}
Bag.add(game.save, "RARE_CANDY", 3, game.data)
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(10)
local heard = {}
local realPlay = Sound.play
Sound.play = function(data, name, ...)
heard[#heard + 1] = name
return realPlay(data, name, ...)
end
-- bag -> RARE CANDY -> USE -> the only party member
Screens.push(game, "BagMenu", {})
U.wait(12)
U.tap(game, "a")
U.wait(12)
U.tap(game, "a")
U.wait(12)
local level = mon.level
U.tap(game, "a")
U.wait(40)
Sound.play = realPlay
check("the candy raised the level", mon.level == level + 1)
U.log("sounds heard:", table.concat(heard, " "))
local jingle = false
for _, name in ipairs(heard) do
if name == "Get_Item1" then jingle = true end
end
check("the level line carried SFX_GET_ITEM_1", jingle)
U.shot(game, SHOT_DIR .. "/bug1846_rare_candy.png")
U.log("captured", SHOT_DIR .. "/bug1846_rare_candy.png")
U.log("NIDORINO grew to level 21 just now: the short item fanfare should")
U.log("sound as that line finishes typing, and the box waits for A after")
U.log("it. Silence there is the bug. The other near-miss is the longer")
U.log("level-up fanfare, which only belongs to a candy used in battle.")
U.log("Two candies are left in the bag to hear it again.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,100 @@
-- Manual check that Hideout B4F's Rocket3 engages on sight (#1814).
-- RocketHideout4TrainerHeader2 is `trainer EVENT_..., 1, ...`
-- (scripts/RocketHideoutB4F.asm:95-96), so CheckSpriteCanSeePlayer engages him
-- from the tile below. The LIFT KEY half (his after-battle talk still reveals
-- the ball) is asserted in tests/parity_rocket3_sight_bug1814.lua.
-- POKEPORT_DRIVER=tests/drivers/rocket3_sight_bug1814_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- pokered data/maps/objects/RocketHideoutB4F.asm: ROCKET3 stands at (11,2)
-- facing DOWN, so the sight tile is the one below him
local MAP = "ROCKET_HIDEOUT_B4F"
local TEXT = "TEXT_ROCKETHIDEOUTB4F_ROCKET3"
local function rocketIn(ow)
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.text == TEXT then return n end
end
end
-- a party that can take a hit, so the battle the sight starts is playable
if #(game.save.party or {}) == 0 then
local Pokemon = require("src.pokemon.Pokemon")
game.save.party = { Pokemon.new(game.data, "NIDOKING", 45) }
end
U.teleport(game, MAP, 13, 3, "left")
U.wait(10)
local ow = game.overworld
local rocket = rocketIn(ow)
check("Rocket3 is on the map", rocket ~= nil)
if rocket then
U.log(("Rocket3 at (%d, %d) facing %s")
:format(rocket.cellX, rocket.cellY, tostring(rocket.facing)))
end
local header = rocket
and game.data:trainerHeader(ow.map.def.label, rocket.def.index)
check("his extracted view range is 1 tile", header and header.range == 1)
-- walk north into his line of sight; a map edit that moved him is a
-- different failure than a sight check that never fires, so aim at the
-- cell below wherever he actually stands
local target = rocket and { rocket.cellX, rocket.cellY + 1 } or { 11, 3 }
local steps = 0
for _ = 1, 8 do
local p = game.overworld.player
if (p.cellX == target[1] and p.cellY == target[2])
or game.overworld.engaging then break end
if p.cellX > target[1] then U.hold(game, "left", 16)
elseif p.cellX < target[1] then U.hold(game, "right", 16)
elseif p.cellY > target[2] then U.hold(game, "up", 16)
else U.hold(game, "down", 16) end
steps = steps + 1
U.wait(4)
end
U.log("walked", steps, "steps toward", target[1], target[2])
local sighted = false
for _ = 1, 90 do
ow = game.overworld
if ow.engaging or ow.emote then sighted = true break end
U.wait(1)
end
check("stepping into his line of sight engages him", sighted)
if game.overworld.emote then
U.shot(game, SHOT_DIR .. "/bug1814_sighted.png")
U.log("captured", SHOT_DIR .. "/bug1814_sighted.png")
end
local BattleState = require("src.battle.BattleState")
-- his line prints first and holds on a button wait (home/text_script.asm:96)
local fought = false
for _ = 1, 60 do
if getmetatable(game.stack:top()) == BattleState then fought = true break end
U.tap(game, "a")
U.wait(8)
end
check("the walk-up runs into the battle", fought)
if fought then
U.wait(60)
U.shot(game, SHOT_DIR .. "/bug1814_battle.png")
U.log("captured", SHOT_DIR .. "/bug1814_battle.png")
end
U.log("Right: the grunt notices the player one tile below him, the bubble")
U.log("pops, he marches down and the fight starts on its own. The near-miss")
U.log("is nothing happening until the player presses A at him, which is the")
U.log("bug. After winning, talk to him again: he drops the LIFT KEY ball.")
while true do
coroutine.yield()
end
end
+113
View File
@@ -0,0 +1,113 @@
-- Manual check of the Game Corner slot prompt, emote and menus (#1811).
-- PromptUserToPlaySlots prints "Want to play?" and floats SMILE_BUBBLE over
-- the player on the map, and only then loads the slot screen, whose bet menu
-- is hlcoord 14,11 b=5 c=4 (engine/slots/slot_machine.asm:9-23, :83-86).
-- Box geometry is asserted in tests/engine/slot_machine_boxes_bug1811.lua.
-- POKEPORT_DRIVER=tests/drivers/slots_prompt_bug1811_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local TextBox = require("src.render.TextBox")
local SlotMachine = require("src.ui.SlotMachine")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local vol = game.save.options and game.save.options.sfxVol
if vol == 0 then
U.log("sfx volume is 0 in options; raise it or the menu beeps are silent")
end
-- AbleToPlaySlotsCheck wants a COIN CASE with coins in it
game.save.inventory = game.save.inventory or {}
game.save.inventory.COIN_CASE = 1
game.save.coins = math.max(game.save.coins or 0, 50)
-- the seats are hidden events on the machine tiles (data/events/
-- hidden_events.asm StartSlotMachine); stand on any walkable neighbour
local seats = (game.data.field.slotMachines or {}).GAME_CORNER or {}
local placed = nil
U.teleport(game, "GAME_CORNER", 9, 15, "down")
local ow = game.overworld
local sides = {
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
}
for _, seat in ipairs(seats) do
if seat.state == "ok" then
for _, s in ipairs(sides) do
local cx, cy = seat.x + s[1], seat.y + s[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.teleport(game, "GAME_CORNER", cx, cy, s[3])
U.wait(10)
ow = game.overworld
local fx, fy = ow.player:facingCell()
if fx == seat.x and fy == seat.y then
placed = { seat = seat, x = cx, y = cy, facing = s[3] }
end
break
end
end
end
if placed then break end
end
check("standing at a working slot machine", placed ~= nil)
if placed then
U.log(("machine at (%d, %d), player at (%d, %d) facing %s")
:format(placed.seat.x, placed.seat.y, placed.x, placed.y,
placed.facing))
end
U.tap(game, "a")
U.wait(30)
local top = game.stack:top()
local isBox = getmetatable(top) == TextBox
check("A opens a text box, not the slot screen", isBox)
check("the slot screen is not on the stack yet",
getmetatable(top) ~= SlotMachine)
if isBox then
local shown = {}
for _, page in ipairs(top.pages or {}) do
for _, line in ipairs(page) do shown[#shown + 1] = line end
end
U.log("box reads:", table.concat(shown, " / "))
end
U.shot(game, SHOT_DIR .. "/bug1811_prompt.png")
-- through the text, then YES
for _ = 1, 8 do
if game.overworld.emote then break end
U.tap(game, "a")
U.wait(12)
end
U.wait(4)
local emote = game.overworld.emote
check("YES floats an emotion bubble over the player", emote ~= nil)
check("it is SMILE_BUBBLE, the third crop", emote and emote.bubble == 3)
check("and it is over the player, not an NPC",
emote and emote.npc == game.overworld.player)
U.shot(game, SHOT_DIR .. "/bug1811_smile.png")
for _ = 1, 120 do
if getmetatable(game.stack:top()) == SlotMachine then break end
U.wait(2)
end
local slots = game.stack:top()
check("the slot screen opens after the bubble",
getmetatable(slots) == SlotMachine)
check("and it opens on the bet menu", slots.stage == "bet")
U.wait(10)
U.shot(game, SHOT_DIR .. "/bug1811_bet.png")
U.log("captured", SHOT_DIR .. "/bug1811_{prompt,smile,bet}.png")
U.log("Right: the prompt box sits over the still-drawn Game Corner floor,")
U.log("then a smiley pops over the player, then the machine appears with a")
U.log("bet menu whose bottom edge is the last row of the screen. The")
U.log("near-miss is the machine already drawn behind the prompt, or a bet")
U.log("box two rows short of the bottom.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,99 @@
-- #1831: spinner tiles whirled the sprite per DISPLAY frame and flickered
-- the arrows at twice the cart's rate (engine/overworld/spinners.asm:1-22,
-- home/overworld.asm:41-44,268-272).
-- POKEPORT_DRIVER=tests/drivers/spinner_rate_bug1831_test.lua \
-- POKEPORT_IDENTITY=bug1831 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local TileRenderer = require("src.render.TileRenderer")
local results = {}
local function check(label, ok)
results[#results + 1] = (ok and "PASS " or "FAIL ") .. label
end
-- data/generated/field.lua ROCKET_HIDEOUT_B2F: the arrow at (12, 9)
-- slides 10 cells left, the longest straight ride on the floor.
U.teleport(game, "ROCKET_HIDEOUT_B2F", 13, 9, "left")
U.wait(10)
local ow = game.overworld
check("overworld is up on ROCKET_HIDEOUT_B2F", ow ~= nil and ow.player ~= nil)
-- one step left onto the arrow starts the forced slide
U.hold(game, "left", 20)
local spinning = false
for _ = 1, 30 do
if ow.player.spinning then spinning = true break end
U.wait(1)
end
check("stepping on the arrow starts the spin", spinning)
-- sample per fixed step while the slide runs
local facings, timers, blurs = {}, {}, {}
for _ = 1, 200 do
if not ow.player.spinning then break end
local _, _, _, facing = ow.player:pose()
facings[#facings + 1] = facing
timers[#timers + 1] = ow.player.spinTimer or 0
blurs[#blurs + 1] = TileRenderer.spinBlurActive()
U.wait(1)
end
check(("the slide gave %d samples (want >= 32)"):format(#facings),
#facings >= 32)
-- spinTimer ticks once per fixed step, not per rendered frame
local ticks = true
for i = 2, #timers do
if timers[i] - timers[i - 1] ~= 1 then ticks = false end
end
check("spinTimer advances exactly once per fixed step", ticks)
-- each facing holds for 2 fixed steps: one quarter-turn per OverworldLoop
-- iteration, two frames each (home/overworld.asm:41-44)
local runs, run = {}, 1
for i = 2, #facings do
if facings[i] == facings[i - 1] then
run = run + 1
else
runs[#runs + 1] = run
run = 1
end
end
local twos, others = 0, 0
for i = 2, #runs do -- the first run starts mid-phase, skip it
if runs[i] == 2 then twos = twos + 1 else others = others + 1 end
end
check(("facing holds 2 steps (%d runs of 2, %d other)"):format(twos, others),
twos >= 8 and others == 0)
-- arrow blur half-period is 16 frames: one whole 16-frame walked tile
-- per wSimulatedJoypadStatesIndex parity (spinners.asm:18-22). The clock
-- advances on the draw path, so allow one frame of sampling skew; the old
-- bug read 8 here.
local span, spans = 1, {}
for i = 2, #blurs do
if blurs[i] == blurs[i - 1] then
span = span + 1
else
spans[#spans + 1] = span
span = 1
end
end
local good, bad = 0, 0
for i = 2, #spans do -- first span starts mid-phase, skip it
if spans[i] >= 15 and spans[i] <= 17 then good = good + 1
else bad = bad + 1 end
end
check(("blur toggles every ~16 frames (%d good, %d off)"):format(good, bad),
good >= 1 and bad == 0)
for _, line in ipairs(results) do U.log(line) end
U.log("Right looks like: while the player is swept along the arrow the")
U.log("sprite makes roughly one full turn per 8 frames, a lazy whirl, and")
U.log("the arrow tiles swap between blur and static about twice a second.")
while true do
coroutine.yield()
end
end
+155
View File
@@ -0,0 +1,155 @@
-- Manual check for #1866: black hairlines at the title's SGB zone boundaries
-- (canvas rows 64 and 80, BlkPacket_Titlescreen, data/sgb/sgb_packets.asm:
-- 123-127) at window heights that are not a whole multiple of 144.
-- POKEPORT_DRIVER=tests/drivers/title_seam_bug1866_test.lua POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
-- Do not set POKEPORT_SPEED: fast-forward desynchronizes the title music.
return function(game)
local U = dofile("tests/drivers/util.lua")
local PaletteFX = require("src.render.PaletteFX")
local Renderer = require("src.render.Renderer")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local title
for _ = 1, 120 do
local top = game.stack:top()
if top and top.screenId == "TitleState" and top.sgbPalettes then
title = top
break
end
U.tap(game, "start")
U.wait(9)
end
check("the title screen is on top", title ~= nil)
if not (title and title.sgbPalettes) then
U.log("No title state to look at; nothing below can run.")
while true do coroutine.yield() end
end
local opts = game.save.options
local mode = opts and opts.colors or PaletteFX.mode
if PaletteFX.mode ~= "gbc" then
U.log("COLORS is", tostring(mode) .. "; switching the view to SGB for the check")
PaletteFX.setMode("gbc")
U.wait(20)
end
local zones = PaletteFX.ensureZones(title:sgbPalettes(game))
check("the title builds three SGB zones", zones ~= nil and #zones == 3)
-- Every height below is odd and none is a multiple of 144, so the picture
-- never lands on a whole number of screen pixels per GB pixel. "fill"
-- additionally drops the integer fit scale, which is the shape the report's
-- screenshot was taken in (about 5.98 screen pixels per GB pixel).
local HEIGHTS = { 721, 823, 907, 1013 }
local WIDTH = 1157
local worstRows, worstTag = 0, "none"
-- the control: a zone list with a real hole in it, drawn the way the
-- per-zone pass alone would draw it. It proves the sampler below can see
-- a seam at all, and that the underpaint is what closes this one.
local gapped = {}
for i, z in ipairs(zones) do
gapped[i] = { x = z.x, y = z.y, w = z.w, h = z.h, colors = z.colors }
end
if gapped[2] then
gapped[2].y = gapped[2].y + 1
gapped[2].h = gapped[2].h - 2
end
local function drawZonesOnly(zoneList, Ux, Uy, uox, uoy)
local shader = PaletteFX.shader()
love.graphics.setShader(shader)
for _, z in ipairs(zoneList) do
PaletteFX.sendColors(shader, z.colors)
love.graphics.setScissor(uox + z.x * Ux, uoy + z.y * Uy,
z.w * Ux, z.h * Uy)
love.graphics.draw(Renderer.canvas, uox, uoy, 0, Ux, Uy)
end
love.graphics.setScissor()
love.graphics.setShader()
end
local function probeAt(ph, fill, zoneList, zonesOnly)
local dpi = 1
local probe = love.graphics.newCanvas(WIDTH, ph, { dpiscale = dpi })
local Up
if fill then
Up = math.min(ph / 144, WIDTH / 160)
else
Up = math.max(1, math.floor(math.min(WIDTH / 160, ph / 144)))
end
local Ux, Uy = Up / dpi, Up / dpi
local uox = math.floor((WIDTH - 160 * Up) / 2) / dpi
local uoy = math.floor((ph - 144 * Up) / 2) / dpi
local uvpw, uvph = 160 * Ux, 144 * Uy
love.graphics.setCanvas(probe)
love.graphics.clear(0, 0, 0, 1)
love.graphics.setColor(1, 1, 1, 1)
if zonesOnly then
drawZonesOnly(zoneList, Ux, Uy, uox, uoy)
else
Renderer:blitCanvas(Renderer.canvas, Ux, Uy, zoneList, Ux, Uy,
uox, uoy, uox, uoy, uvpw, uvph, dpi, dpi)
end
love.graphics.setCanvas()
local data = probe:newImageData()
local top = math.floor(uoy * dpi) + 1
local bottom = math.min(math.floor((uoy + uvph) * dpi) - 1, data:getHeight() - 1)
local col = math.floor(uox * dpi) + 2
local rows = {}
for py = top, bottom do
local r, g, b = data:getPixel(col, py)
if r < 0.02 and g < 0.02 and b < 0.02 then rows[#rows + 1] = py end
end
return rows, ("%dx%d %s"):format(WIDTH, ph, fill and "fill" or "fit")
end
for _, ph in ipairs(HEIGHTS) do
for _, fill in ipairs({ false, true }) do
local rows, tag = probeAt(ph, fill, zones, false)
if #rows > worstRows then worstRows, worstTag = #rows, tag end
check(tag .. ": no letterbox row shows through a zone boundary",
#rows == 0)
if #rows > 0 then
U.log(" black rows:", table.concat(rows, ","))
end
end
end
U.log("worst case:", worstRows, "black rows at", worstTag)
local holeRows = probeAt(HEIGHTS[3], true, gapped, true)
check("a zone list with a hole in it does show the letterbox clear",
#holeRows > 0)
local filledRows = probeAt(HEIGHTS[3], true, gapped, false)
check("and the same hole drawn through the renderer shows none",
#filledRows == 0)
-- and the live window at one of those heights, for the eye
local ok = pcall(love.window.setMode, WIDTH, HEIGHTS[3],
{ resizable = true, vsync = 0 })
check("the window resized to " .. WIDTH .. "x" .. HEIGHTS[3], ok)
U.wait(20)
check("wrote " .. SHOT_DIR .. "/bug1866_title_907.png",
U.shot(game, SHOT_DIR .. "/bug1866_title_907.png"))
pcall(love.window.setMode, WIDTH, HEIGHTS[4], { resizable = true, vsync = 0 })
U.wait(20)
check("wrote " .. SHOT_DIR .. "/bug1866_title_1013.png",
U.shot(game, SHOT_DIR .. "/bug1866_title_1013.png"))
PaletteFX.setMode(mode)
U.wait(10)
U.log("Look along the title picture where the POKeMON logo meets the red")
U.log("VERSION ribbon, and where the ribbon meets RED and the copyright: the")
U.log("off-white background must run through both boundaries unbroken.")
U.log("The near-miss is a hairline one or two pixels tall that is dark grey")
U.log("or slightly off-white rather than black; that is still the seam.")
while true do
coroutine.yield()
end
end
+78
View File
@@ -0,0 +1,78 @@
-- Vitamins (#1805): pokered's .useVitamin plays SFX_HEAL_AILMENT, then
-- prints VitaminStatRoseText over the still-drawn party menu and ends at
-- RemoveUsedItem, which only strips the item from the bag
-- (engine/items/item_effects.asm:1313-1322, :2274).
-- POKEPORT_DRIVER=tests/drivers/vitamin_bug1805_test.lua POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Screens = require("src.ui.Screens")
local Bag = require("src.inventory.Bag")
local Sound = require("src.core.Sound")
local PartyMenu = require("src.ui.PartyMenu")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
if (game.save.options and game.save.options.sfxVol or 1) == 0 then
U.log("WARNING: sfxVol is 0, so nothing here can be heard.")
U.log("WARNING: set SFX volume in OPTION before judging this driver.")
end
local mon = Pokemon.new(game.data, "NIDORINO", 20)
game.save.party = { mon }
game.save.player.name = "bryan"
game.save.inventory = {}
Bag.add(game.save, "HP_UP", 3, game.data)
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(10)
local heard = {}
local realPlay = Sound.play
Sound.play = function(data, name, ...)
heard[#heard + 1] = name
return realPlay(data, name, ...)
end
-- bag -> HP UP -> USE -> the only party member
Screens.push(game, "BagMenu", {})
U.wait(12)
U.tap(game, "a")
U.wait(12)
U.tap(game, "a")
U.wait(12)
U.tap(game, "a")
U.wait(30)
Sound.play = realPlay
check("the vitamin was consumed", (game.save.inventory.HP_UP or 0) == 2)
check("HP stat exp went up", (mon.statExp and mon.statExp.hp or 0) == 2560)
U.log("sounds heard:", table.concat(heard, " "))
local jingle = false
for _, name in ipairs(heard) do
if name == "Heal_Ailment" then jingle = true end
end
check("the stat line carried SFX_HEAL_AILMENT", jingle)
local partyUp = false
for _, s in ipairs(game.stack.states or {}) do
if getmetatable(s) == PartyMenu then partyUp = true end
end
check("the party menu is still drawn under the message", partyUp)
U.shot(game, SHOT_DIR .. "/bug1805_vitamin.png")
U.log("captured", SHOT_DIR .. "/bug1805_vitamin.png")
U.log("NIDORINO's HEALTH rose just now: the short cure jingle should sound")
U.log("as that line types, with the party list still on screen behind the")
U.log("box, and only then does the menu come down. The bug was silence")
U.log("plus the list vanishing to the map the instant A was pressed.")
U.log("Two HP UPs are left in the bag to try it again.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,41 @@
-- data/moves/animations.asm:401-403
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local UI = require("src.ui.gen2.BattleState")
local AnimRunner = require("src.battle.gen2.AnimRunner")
local runner = AnimRunner.new({ animId = "ANIM_THROW_POKE_BALL", battleTurn = 0 })
T.eq(runner.animId, "ANIM_THROW_POKE_BALL",
"the runner exposes the anim id BattleState latches on")
T.eq(runner.env.animId, "ANIM_THROW_POKE_BALL",
"the object/bg env still sees the same id")
-- A real runner skipped with B must still hide the caught mon.
do
local s = setmetatable({
anim = runner,
ballThrow = { caught = true },
picHidden = { player = false, enemy = false },
}, { __index = UI })
local input = { wasPressed = function(_, key) return key == "b" end }
s:stepAnim(input)
T.eq(s.picHidden.enemy, true, "a B-skipped catch latches with a real runner")
end
do
local free = AnimRunner.new({ animId = "ANIM_THROW_POKE_BALL", battleTurn = 0 })
local s = setmetatable({
anim = free,
ballThrow = { caught = false },
picHidden = { player = false, enemy = false },
}, { __index = UI })
local input = { wasPressed = function(_, key) return key == "b" end }
s:stepAnim(input)
T.eq(s.picHidden.enemy, false, "a B-skipped break-free still does not latch")
end
T.finish("gen2 ball anim id bug 1804")
@@ -0,0 +1,67 @@
-- pokecrystal constants/engine_flags.asm:66-92 vs pokegold :65-91
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local FieldMoves = require("src.world.gen2.FieldMoves")
-- pokegold constants/engine_flags.asm:65-91, in const_def order from
-- ENGINE_RADIO_CARD = 0: PALLET 52 .. INDIGO_PLATEAU 63, NEW_BARK 64 ..
-- SILVER_CAVE 75.
local GOLD_IDS = {
SPAWN_PALLET = 52, SPAWN_VIRIDIAN = 53, SPAWN_PEWTER = 54,
SPAWN_CERULEAN = 55, SPAWN_ROCK_TUNNEL = 56, SPAWN_VERMILION = 57,
SPAWN_LAVENDER = 58, SPAWN_SAFFRON = 59, SPAWN_CELADON = 60,
SPAWN_FUCHSIA = 61, SPAWN_CINNABAR = 62, SPAWN_INDIGO = 63,
SPAWN_NEW_BARK = 64, SPAWN_CHERRYGROVE = 65, SPAWN_VIOLET = 66,
SPAWN_AZALEA = 67, SPAWN_CIANWOOD = 68, SPAWN_GOLDENROD = 69,
SPAWN_OLIVINE = 70, SPAWN_ECRUTEAK = 71, SPAWN_MAHOGANY = 72,
SPAWN_LAKE_OF_RAGE = 73, SPAWN_BLACKTHORN = 74, SPAWN_MT_SILVER = 75,
}
local function byFlag()
local out = {}
for _, row in ipairs(FieldMoves.FLYPOINTS) do out[row.spawn] = row.flag end
return out
end
-- A Gold cache carries no engineFlagOrder; the table keeps its literal ids.
FieldMoves.bindEngineFlags(nil)
local gold = byFlag()
for spawn, id in pairs(GOLD_IDS) do
T.eq(gold[spawn], id, spawn .. " keeps the pokegold id with no order")
end
-- pokecrystal constants/engine_flags.asm:25 ENGINE_MOBILE_SYSTEM at index 16
-- shifts every later id up exactly one.
local crystalOrder = {}
for _, row in ipairs(FieldMoves.FLYPOINTS) do
-- order[i] names id i-1, so the Crystal id (gold + 1) sits at gold + 2
crystalOrder[GOLD_IDS[row.spawn] + 2] = row.name
end
FieldMoves.bindEngineFlags(crystalOrder)
local crystal = byFlag()
for spawn, id in pairs(GOLD_IDS) do
T.eq(crystal[spawn], id + 1, spawn .. " rebinds one higher under Crystal")
end
-- The Kanto gate reads SPAWN_INDIGO through the rebound id: Crystal's 64,
-- which the old table misread as Gold's SPAWN_NEW_BARK.
local save = { engineFlags = { [64] = true } }
T.check(FieldMoves.hasVisitedSpawn(save, "SPAWN_INDIGO"),
"Crystal flag 64 is the INDIGO_PLATEAU flypoint")
local kanto = FieldMoves.flyPoints(save, nil, "kanto")
T.eq(#kanto, 1, "the Kanto half opens on it")
T.eq(kanto[1].spawn, "SPAWN_INDIGO", "with the plateau row itself")
-- And a rebind back to a Gold cache restores the literal ids.
FieldMoves.bindEngineFlags(nil)
local again = byFlag()
for spawn, id in pairs(GOLD_IDS) do
T.eq(again[spawn], id, spawn .. " returns to the pokegold id")
end
local goldSave = { engineFlags = { [63] = true } }
T.check(FieldMoves.hasVisitedSpawn(goldSave, "SPAWN_INDIGO"),
"Gold flag 63 is INDIGO_PLATEAU again")
T.finish("gen2 flypoint rebind bug 1836")
@@ -0,0 +1,79 @@
-- ../pokecrystal/engine/gfx/load_pics.asm:105-158
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local Assets = require("src.render.Assets")
local UI = require("src.ui.gen2.BattleState")
local FRONT = "assets/generated/battle/front/cyndaquil.png"
local SHEET = "assets/generated/battle/anim/cyndaquil.png"
local MOD_FRONT = "mods/skin/overrides/battle/front/cyndaquil.png"
local MOD_SHEET = "mods/skin/overrides/battle/anim/cyndaquil.png"
local image = { getDimensions = function() return 40, 200 end }
Assets.image = function() return image end
local overrides = {}
Assets.resolve = function(path) return overrides[path] or path end
local mon = { species = "CYNDAQUIL" }
local anim = { sheet = SHEET, tiles = 5, play = { { 1 } } }
local function newSelf(picPath)
local self = setmetatable({
pokemon = { CYNDAQUIL = { spriteFront = FRONT, anim = anim } },
picCache = {},
}, { __index = UI })
if picPath then
self.pic = function() return image, false, picPath end
end
return self
end
-- Stock cache, no mod: the sheet still animates.
do
overrides = {}
local s = newSelf()
s:startFrontAnim(mon)
T.check(s.frontAnim ~= nil, "an unmodded Crystal front pic still animates")
T.eq(s.frontAnim and s.frontAnim.sheet, image, "off the extracted sheet")
end
-- Gold and Silver have no anim row at all and never reach any of this.
do
overrides = {}
local s = newSelf()
s.pokemon.CYNDAQUIL.anim = nil
s:startFrontAnim(mon)
T.eq(s.frontAnim, nil, "a cache with no anim row is untouched")
s.pokemon.CYNDAQUIL.anim = anim
end
-- The bug: the static pic replaced, the sheet left stock. Holding the static
-- pic for the whole scene is the only way not to flicker stock art over it.
do
overrides = { [FRONT] = MOD_FRONT }
local s = newSelf()
s:startFrontAnim(mon)
T.eq(s.frontAnim, nil, "an overridden front pic suppresses the stock frames")
end
do
overrides = {}
local s = newSelf(MOD_FRONT)
s:startFrontAnim(mon)
T.eq(s.frontAnim, nil, "a pokemon.sprite path also suppresses them")
end
-- A mod that ships both halves keeps its animation.
do
overrides = { [FRONT] = MOD_FRONT, [SHEET] = MOD_SHEET }
local s = newSelf()
s:startFrontAnim(mon)
T.check(s.frontAnim ~= nil, "a mod that ships a sheet as well still animates")
end
T.finish("gen2 front anim mod sheet bug 1827")
@@ -0,0 +1,72 @@
-- engine/gfx/color.asm:342-359 / engine/gfx/cgb_layouts.asm:167-192
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
local Chrome = require("src.ui.gen2.Chrome")
local SummaryMenu = require("src.ui.gen2.SummaryMenu")
local printed, through
local realPrint, realThrough = Chrome.print, Chrome.printThrough
Chrome.print = function(text, tx, ty)
printed[#printed + 1] = { text = text, x = tx, y = ty }
return 0
end
Chrome.printThrough = function(text, tx, ty, palette)
through[#through + 1] = { text = text, x = tx, y = ty, palette = palette }
return 0
end
local function fake(page, placements)
local self = setmetatable({ page = page, mon = {}, party = {} },
{ __index = SummaryMenu })
self.greenPlacements = function() return placements end
self.bluePlacements = function() return placements end
self.pinkPlacements = function() return placements end
self.drawVerticalDivider = function() end
return self
end
local rows = {
{ text = "ITEM", x = 0, y = 8 },
{ text = "MOVE", x = 0, y = 10 },
}
for _, page in ipairs({ SummaryMenu.PINK_PAGE, SummaryMenu.GREEN_PAGE,
SummaryMenu.BLUE_PAGE }) do
local self = fake(page, rows)
printed, through = {}, {}
if page == SummaryMenu.GREEN_PAGE then
self:drawGreenPage()
elseif page == SummaryMenu.BLUE_PAGE then
self:drawBluePage()
else
self:drawPlacements(self:pinkPlacements(), self:lowerColors())
end
T.eq(#printed, 0, "no lower-half string prints on the white default paper")
T.eq(#through, #rows, "every lower-half string prints through a palette")
local tint = SummaryMenu.PAGE_TINTS[page]
for _, call in ipairs(through) do
T.eq(call.palette and call.palette[1][1], tint[1], "paper red is the page tint")
T.eq(call.palette and call.palette[1][2], tint[2], "paper green is the page tint")
T.eq(call.palette and call.palette[1][3], tint[3], "paper blue is the page tint")
T.eq(call.palette and call.palette[4][1], 0, "ink stays black")
end
end
-- Rows 0-7 are filled with palette $1, whose colour 0 is white, so the upper
-- half keeps the default paper.
do
local self = fake(SummaryMenu.PINK_PAGE, rows)
printed, through = {}, {}
self:drawPlacements(rows)
T.eq(#printed, #rows, "upper-half strings still take the default paper")
T.eq(#through, 0, "upper-half strings do not force a page tint")
end
Chrome.print, Chrome.printThrough = realPrint, realThrough
T.finish("gen2 stats text tint bug 1858")
@@ -0,0 +1,56 @@
-- engine/events/overworld.asm:1150-1165, engine/events/field_moves.asm:5-10
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local Sound = require("src.core.Sound")
local World = require("src.world.gen2.World")
local busy = false
Sound.sfxBusy = function() return busy end
local log = {}
local world = setmetatable({}, { __index = World })
world.setNickname = function() end
world.showText = function(_, _, done) if done then done() end end
world.playSfxNamed = function() log[#log + 1] = "sfx" end
world.replaceBlock = function(_, index, blockId)
log[#log + 1] = "block:" .. tostring(index) .. ":" .. tostring(blockId)
return true
end
world:runWhirlpool({ text = "USED WHIRLPOOL", blockIndex = 7, replacement = 3 })
T.eq(#log, 0, "the box closing does not swap the block yet")
T.eq(world.fieldMove and world.fieldMove.phase, "whirlpoolsfx",
"closing the box parks the WaitSFX phase")
busy = false
world:updateFieldMove()
T.same(log, { "sfx" }, "the first WaitSFX passes and SFX_SURF starts")
busy = true
world:updateFieldMove()
world:updateFieldMove()
T.same(log, { "sfx" }, "the whirlpool is still on screen while the sfx plays")
busy = false
world:updateFieldMove()
T.same(log, { "sfx", "block:7:3" },
"the block is swapped once the second WaitSFX clears")
T.eq(world.fieldMove, nil, "and the phase is done")
-- The callback path (DisappearWhirlpool via callasm) carries no block, and
-- must not invent one.
do
log = {}
world.fieldMove = nil
world:playWhirlpoolSound()
busy = false
world:updateFieldMove()
world:updateFieldMove()
T.same(log, { "sfx" }, "a bare sound call swaps nothing")
end
T.finish("gen2 whirlpool block after sfx bug 1862")
@@ -0,0 +1,105 @@
-- A link switch runs SwitchPlayerMon on the switcher and SwitchEnemyMon on
-- the peer (core.asm:2418-2422, trainer_ai.asm:596-599) (#1562).
-- luajit tests/run_engine.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local Link = require("tests.modkit.link")
local Net = require("src.link.Net")
local Protocol = require("src.link.Protocol")
local LinkBattle = require("src.link.LinkBattle")
math.randomseed(4242)
local Input = Link.prepare(Data)
local gameA = Link.fakeGame(Data, { "FIXMON_A", "FIXMON_B" }, { name = "RED" })
local gameB = Link.fakeGame(Data, { "FIXMON_A", "FIXMON_B" }, { name = "BLUE" })
local netA, netB = Net.loopbackPair()
local packedA = Protocol.packParty(gameA.save.party)
local packedB = Protocol.packParty(gameB.save.party)
local battleA = LinkBattle.newHost(gameA, netA, {
myParty = packedA, theirParty = packedB,
theirName = "BLUE", seed = 987654321,
})
local battleB = LinkBattle.newGuest(gameB, netB, {
myParty = packedB, theirParty = packedA,
theirName = "RED", seed = 987654321,
})
local function watch(battle)
local seen = {}
local sayNext, sayNextAuto = battle.sayNext, battle.sayNextAuto
battle.sayNext = function(s, text) seen[#seen + 1] = text; return sayNext(s, text) end
battle.sayNextAuto = function(s, text, d)
seen[#seen + 1] = text
return sayNextAuto(s, text, d)
end
return seen
end
local seenA, seenB = watch(battleA), watch(battleB)
local function count(list, needle)
local n = 0
for _, text in ipairs(list) do
if text and text:find(needle, 1, true) then n = n + 1 end
end
return n
end
local function firstIndex(list, needle)
for i, text in ipairs(list) do
if text and text:find(needle, 1, true) then return i end
end
end
local resA, resB
battleA.onFinish = function(r) resA = r end
battleB.onFinish = function(r) resB = r end
gameA.stack:push(battleA)
gameB.stack:push(battleB)
local switched, retreatSeen = false, false
for _ = 1, 60000 do
if resA and resB then break end
Input.pressed = { a = true }
if not switched and battleA.phase == "menu" and battleA.playerParty[2] then
battleA:resolveSwitch(battleA.playerParty[2])
switched = true
end
for _, g in ipairs({ gameA, gameB }) do
local top = g.stack:top()
if top and top.forceSwitch and top.party then
for i, mon in ipairs(top.party) do
if mon.hp > 0 then top.index = i break end
end
end
g.stack:update(1 / 60)
end
if battleA.shrinkOut then retreatSeen = true end
end
T.check(switched, "the host got a menu phase to switch from")
T.eq(count(seenA, "Come back!"), 1,
"RetreatMon prints once, for the voluntary switch only")
T.check(retreatSeen, "AnimateRetreatingPlayerMon runs on the switcher")
T.eq(count(seenB, "drew"), 1, "the peer prints AIBattleWithdrawText once")
local withdrawA = firstIndex(seenA, "Come back!")
local sendA = firstIndex(seenA, "Go! ") or firstIndex(seenA, "Do it! ")
or firstIndex(seenA, "Get'm! ")
T.check(sendA ~= nil and withdrawA < sendA, "the recall line comes before the send-out")
local withdrawB = firstIndex(seenB, "drew")
local sendB = firstIndex(seenB, "sent\nout")
T.check(sendB ~= nil and withdrawB < sendB, "and so does the peer's withdraw line")
T.check(battleA.localHashes ~= nil and battleB.localHashes ~= nil,
"both sides still record per-turn hashes")
local mismatch
for turn, hash in pairs(battleA.localHashes) do
local other = battleB.localHashes[turn]
if other and other ~= hash then mismatch = mismatch or turn end
end
T.check(mismatch == nil, "the added lines leave the lockstep state hash alone")
T.finish("link switch prints the recall lines (#1562)")
@@ -5,7 +5,7 @@
-- GetMonFieldMoves (engine/menus/text_box.asm, called from
-- start_sub_menus.asm) walks wPartyMon1Moves in slot order -- so a mon
-- with STRENGTH in slot 3 and SURF in slot 4 shows STRENGTH then SURF on
-- top, with STATS/SWITCH closing the list. The port used to build the
-- top, with STATS/SWITCH/CANCEL closing the list. The port used to build the
-- submenu as STATS/SWITCH first and tack the field moves on the bottom.
-- ROM-free: drives the real PartyMenu over stub game state.
-- luajit tests/engine/party_fieldmove_order_bug792.lua
@@ -74,7 +74,8 @@ local pm = PartyMenu.new(game, {})
game.stack:push(pm)
openSubmenu(pm)
check(pm.submenu, "A on a party mon opens the submenu")
same(actions(pm.subItems), { "strength", "surf", "stats", "switch" },
same(actions(pm.subItems),
{ "strength", "surf", "stats", "switch", "cancel" },
"field moves sit above STATS/SWITCH in move-list order (#792)")
eq(pm.subItems[1].label, "STRENGTH", "slot 3's STRENGTH leads the submenu")
eq(pm.subItems[2].label, "SURF", "slot 4's SURF follows it")
@@ -84,8 +85,8 @@ local plain = newGame({ { id = "TACKLE", pp = 35 } })
local pm2 = PartyMenu.new(plain, {})
plain.stack:push(pm2)
openSubmenu(pm2)
same(actions(pm2.subItems), { "stats", "switch" },
"no field moves: the submenu is just STATS/SWITCH")
same(actions(pm2.subItems), { "stats", "switch", "cancel" },
"no field moves: the submenu is just PokemonMenuEntries")
-- GetMonFieldMoves is badge-blind: the same Lapras without the badges
-- still lists both HMs, and .outOfBattleMovePointers refuses on
@@ -95,7 +96,8 @@ local noBadges = newGame(
local pm3 = PartyMenu.new(noBadges, {})
noBadges.stack:push(pm3)
openSubmenu(pm3)
same(actions(pm3.subItems), { "strength", "surf", "stats", "switch" },
same(actions(pm3.subItems),
{ "strength", "surf", "stats", "switch", "cancel" },
"the HM moves are listed without the badges (#1022)")
T.finish("party_fieldmove_order_bug792")
@@ -0,0 +1,98 @@
-- Out of battle the per-mon submenu ends STATS/SWITCH/CANCEL: pokered
-- prints one string, PokemonMenuEntries (engine/menus/text_box.asm:504-507),
-- under whatever field moves the mon has, and start_sub_menus.asm:71-75
-- treats the last row as a real selection that leaves the party menu
-- (.exitMenu, start_sub_menus.asm:26-30) where B only returns to the list.
-- The port stopped at SWITCH (#1833). ROM-free: drives the real PartyMenu
-- over stub game state.
-- luajit tests/engine/party_submenu_cancel_bug1833.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq, same = T.check, T.eq, T.same
love = love or require("tests.love_stub")
local PartyMenu = require("src.ui.PartyMenu")
local function newGame(moves)
local game = {
data = { pokemon = { LAPRAS = { name = "LAPRAS" } } },
save = {
party = { { species = "LAPRAS", hp = 50, stats = { hp = 50 },
level = 30, moves = moves } },
inventory = {}, options = {}, flags = {},
},
overworld = { map = { def = { tileset = "OVERWORLD" },
id = "PALLET_TOWN" },
dark = false },
}
game.stack = {
states = {},
push = function(self, s) table.insert(self.states, s) end,
pop = function(self) return table.remove(self.states) end,
top = function(self) return self.states[#self.states] end,
}
game.input = {
queue = {},
wasPressed = function(self, btn) return self.queue[btn] or false end,
isDown = function() return false end,
}
return game
end
local function press(pm, btn)
pm.game.input.queue = { [btn] = true }
pm:update(1 / 60)
pm.game.input.queue = {}
end
local function actions(items)
local out = {}
for i, item in ipairs(items or {}) do out[i] = item.action end
return out
end
local game = newGame({ { id = "TACKLE", pp = 35 } })
local cancelled = false
local pm = PartyMenu.new(game, { onCancel = function() cancelled = true end })
game.stack:push(pm)
press(pm, "a")
check(pm.submenu, "A on a party mon opens the submenu")
same(actions(pm.subItems), { "stats", "switch", "cancel" },
"the plain list is PokemonMenuEntries in full (#1833)")
eq(pm.subItems[3].label, "CANCEL", "CANCEL closes the list")
-- CANCEL sits at the bottom, so one wrap upward reaches it
press(pm, "up")
eq(pm.subIndex, 3, "up from STATS wraps onto CANCEL")
press(pm, "a")
eq(#game.stack.states, 0, "choosing CANCEL leaves the party menu (.exitMenu)")
check(cancelled, "and reports the cancel to the caller")
-- B out of the submenu is the other path: back to the party list, menu up
local game2 = newGame({ { id = "TACKLE", pp = 35 } })
local pm2 = PartyMenu.new(game2, {})
game2.stack:push(pm2)
press(pm2, "a")
press(pm2, "b")
check(not pm2.submenu, "B closes the submenu")
eq(#game2.stack.states, 1, "B keeps the party menu up (start_sub_menus.asm:68-69)")
-- field moves still lead, with the three fixed rows under them
local game3 = newGame({ { id = "STRENGTH", pp = 15 }, { id = "SURF", pp = 15 } })
local pm3 = PartyMenu.new(game3, {})
game3.stack:push(pm3)
press(pm3, "a")
same(actions(pm3.subItems), { "strength", "surf", "stats", "switch", "cancel" },
"field moves sit above the whole of PokemonMenuEntries")
-- the in-battle list is its own three-row template and is unchanged
local game4 = newGame({ { id = "SURF", pp = 15 } })
local pm4 = PartyMenu.new(game4, { battle = {}, onSwitch = function() end })
game4.stack:push(pm4)
press(pm4, "a")
same(actions(pm4.subItems), { "battle_switch", "stats", "cancel" },
"battle keeps SwitchStatsCancelText's order (data/text_boxes.asm:33)")
T.finish()
+127
View File
@@ -0,0 +1,127 @@
-- The Pokédex list was a generic ListMenu with a title and a footer instead
-- of the cart's CONTENTS screen, and Left/Right dragged the cursor with the
-- page (#1829).
-- engine/menus/pokedex.asm:161-199 (furniture), :242-273 (rows), :314-342
-- (Left/Right move wListScrollOffset only), :208-222 (wDexMaxSeenMon).
-- luajit tests/engine/pokedex_contents_bug1829.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
-- stub Font: the screen draws through Font.draw/Font.drawCode only, and the
-- real Font needs loaded page images this suite has no reason to touch.
local calls = {}
package.loaded["src.render.Font"] = {
draw = function(text, x, y) calls[#calls + 1] = { text = text, x = x, y = y } end,
drawCode = function(code, x, y) calls[#calls + 1] = { code = code, x = x, y = y } end,
width = function(text) return #text * 8 end,
split = function(text)
local out = {}
for i = 1, #text do out[i] = { from = i, to = i } end
return out
end,
}
local PokedexMenu = require("src.ui.PokedexMenu")
local Theme = require("src.ui.Theme")
local function hasText(text, x, y)
for _, c in ipairs(calls) do
if c.text == text and c.x == x and c.y == y then return true end
end
return false
end
local function hasCode(code, x, y)
for _, c in ipairs(calls) do
if c.code == code and c.x == x and c.y == y then return true end
end
return false
end
local DEX_SIZE = 151
local data = {
pokemon = {},
constants = { dexSize = DEX_SIZE, dexDigits = 3 },
}
for n = 1, DEX_SIZE do
local id = ("DEXMON_%03d"):format(n)
data.pokemon[id] = { id = id, name = ("MON%03d"):format(n), dex = n }
end
-- seen 1..20, owned 1..3, so the list stops at 20 and row 2 carries a ball
local function newGame()
local save = { pokedex = { seen = {}, owned = {} } }
for n = 1, 20 do save.pokedex.seen[("DEXMON_%03d"):format(n)] = true end
for n = 1, 3 do save.pokedex.owned[("DEXMON_%03d"):format(n)] = true end
return { data = data, save = save,
stack = { push = function() end, pop = function() end,
top = function() end } }
end
local dex = PokedexMenu.new(newGame(), {})
-- ------------------------------------------------ wDexMaxSeenMon caps it
eq(#dex.items, 20, "the list stops at the highest seen number, not at 151")
eq(dex.items[20].name, "MON020", "the last row is the highest seen species")
-- ------------------------------------------------ Left / Right scroll only
dex.index, dex.scroll = 3, 0
dex:pageScroll(1)
eq(dex.scroll, 7, "Right pages the scroll offset down seven rows")
eq(dex.index - dex.scroll, 3, "the cursor keeps the screen row it was on")
dex:pageScroll(1)
eq(dex.scroll, 13, "the second page clamps to wDexMaxSeenMon - 7")
eq(dex.index - dex.scroll, 3, "the clamped page still keeps the cursor row")
dex:pageScroll(-1)
eq(dex.scroll, 6, "Left pages back seven rows")
eq(dex.index - dex.scroll, 3, "Left keeps the cursor row too")
dex:pageScroll(-1)
eq(dex.scroll, 0, "Left stops at the top of the list")
eq(dex.index, 3, "the cursor is back where it started")
-- the witness for the bug: a whole-page jump used to move the cursor to the
-- bottom row, which is what a page jump on any other list does
check(dex.index - dex.scroll ~= 7,
"the cursor did not slide to the last screen row (the ListMenu bug)")
-- ------------------------------------------------------------- the screen
dex.index, dex.scroll = 1, 0
calls = {}
dex:draw()
check(hasText("CONTENTS", 8, 8), "CONTENTS at hlcoord 1,1, not a POKéDEX title")
check(hasText("SEEN", 128, 16), "SEEN label at hlcoord 16,2")
check(hasText("20", 136, 24), "the seen count right-aligned in its 3-tile field")
check(hasText("OWN", 128, 40), "OWN label at hlcoord 16,5")
check(hasText("3", 144, 48), "the owned count right-aligned under it")
check(not hasText("SEEN 20 OWN 3", 8, 136),
"the counts are not a bottom footer line any more")
check(hasText("DATA", 128, 80) and hasText("CRY", 128, 96)
and hasText("AREA", 128, 112) and hasText("QUIT", 128, 128),
"DATA/CRY/AREA/QUIT are permanent furniture at hlcoord 16,10")
check(hasText("001", 8, 16), "row 1's number sits one row above its name")
check(hasText("MON001", 32, 24), "row 1's name is at column 4")
check(hasCode(Theme.cursor, 0, 24), "the cursor is at column 0 on row 3")
check(hasText("007", 8, 112) and hasText("MON007", 32, 120),
"seven rows, two tiles apart, end on row 15")
check(not hasText("MON008", 32, 136), "there is no eighth row")
-- the hollow ▷ pokered leaves under the side menu (PlaceUnfilledArrowMenuCursor)
dex.hollowIndex = 1
calls = {}
dex:draw()
check(hasCode(Theme.cursorHollow, 0, 24),
"the chosen row keeps a hollow cursor while the side menu is open")
-- ------------------------------------------------------ unseen dashed rows
local partial = newGame()
partial.save.pokedex.seen.DEXMON_005 = nil
local gapped = PokedexMenu.new(partial, {})
eq(gapped.items[5].name, "----------",
"an unseen number inside the range prints the dashed line")
check(gapped.items[5].value == nil, "and cannot be chosen")
T.finish("pokedex contents bug 1829")
+13 -1
View File
@@ -23,7 +23,7 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local check, eq, same = T.check, T.eq, T.same
love = love or require("tests.love_stub")
-- Lazily-required inside the use branches, so seeding package.loaded before
@@ -34,8 +34,15 @@ package.loaded["src.core.Sound"] = {
}
-- Real TextBoxes want a Font atlas. This flow only cares that a message
-- opened, what it says, and what its onDone does.
local jingles = {}
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { textBox = true, text = text, done = done } end,
soundOpts = function(_, sound, opts)
jingles[#jingles + 1] = sound
opts = opts or {}
opts.auto = { sound = sound, wait = true }
return opts
end,
}
-- BagMenu and PartyMenu bind TextBox at require time, so they load against
-- the stub; Screens caches its factory per id and must be told to forget.
@@ -133,6 +140,7 @@ end
-- The bug: three candies in the bag, use one in the field.
do
local game, mon = freshGame(3)
jingles = {}
local list, why = useFromBag(game, nil, "RARE_CANDY")
if check(list ~= nil, "the bag opened and reached the picker: " .. tostring(why)) then
eq(mon.level, 6, "the candy leveled the mon 5 -> 6")
@@ -156,6 +164,10 @@ do
check(box.text:find("level 6", 1, true) ~= nil,
"and it names the new level: " .. tostring(box.text))
end
-- RareCandyText: text_far, sound_get_item_1, text_promptbutton
-- (engine/menus/party_menu.asm:289-293)
same(jingles, { "Get_Item1" },
"the level-up line carries sound_get_item_1 and nothing else")
end
end
@@ -221,6 +221,11 @@ do
local seen = {}
package.loaded["src.save_convert.SaveConvert"] = {
SAVE_SIZE = 32768,
-- importToSlot asks this before it measures the bytes, so a save for a
-- game with no codec is refused as that rather than as a bad checksum.
-- The double has to answer it; "yes" is what keeps this case about the
-- cache-name contract below and nothing else.
importSupported = function() return true end,
importSav = function(_, version, gameVersion)
seen.import = { version = version, gameVersion = gameVersion }
return nil, "stub"
+100
View File
@@ -0,0 +1,100 @@
-- The slot machine's menus sit where the original draws them (#1811).
--
-- The bet menu is `hlcoord 14,11 / b=5 / c=4 / TextBoxBorder`
-- (engine/slots/slot_machine.asm:83-86), i.e. a 6x7 box whose bottom edge is
-- the last row of the screen, with the multipliers at hlcoord 16,12 and the
-- cursor at wTopMenuItemX 15 (asm:77-78, :87). "One more go?" is a
-- TWO_OPTION_MENU at hlcoord 14,12 (asm:136-138) over the YES_NO_MENU body
-- (width 4, height 3 -> a 6x5 box; data/yes_no_menu_strings.asm:10).
-- The "Want to play?" prompt is not this screen's at all: PromptUserToPlaySlots
-- asks it before LoadSlotMachineTiles (asm:9-23), so the screen opens on the
-- bet menu.
-- luajit tests/engine/slot_machine_boxes_bug1811.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
-- Font wants a real atlas and this suite is about which box lands on which
-- row, so it records the calls instead (tests/engine/bag_item_box_bug1521.lua's
-- shape)
local calls = {}
package.loaded["src.render.Font"] = {
BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 },
draw = function(text, x, y) calls[#calls + 1] = { "draw", text, x, y } end,
drawCode = function(code, x, y) calls[#calls + 1] = { "code", code, x, y } end,
drawBox = function(tx, ty, tw, th) calls[#calls + 1] = { "box", tx, ty, tw, th } end,
width = function(text) return #tostring(text) * 8 end,
}
package.loaded["src.core.Sound"] = { play = function() end }
package.loaded["src.ui.SlotMachine"] = nil
local SlotMachine = require("src.ui.SlotMachine")
local game = {
data = { field = { slotWheels = {} }, text = {} },
save = { coins = 100 },
}
local function record(fn)
calls = {}
fn()
return calls
end
local function box(list, tx, ty)
for _, c in ipairs(list) do
if c[1] == "box" and c[2] == tx and c[3] == ty then return c end
end
end
local function drawn(list, text)
for _, c in ipairs(list) do
if c[1] == "draw" and c[2] == text then return c end
end
end
local function cursor(list)
for _, c in ipairs(list) do
if c[1] == "code" and c[2] == 0xED then return c end
end
end
local slots = SlotMachine.new(game, false)
eq(slots.stage, "bet", "the screen opens on the bet menu, not a prompt")
local bet = record(function() slots:drawBottom() end)
local betBox = box(bet, 14, 11)
check(betBox ~= nil, "the bet menu box starts at (14,11)")
eq(betBox and betBox[4], 6, "it is 6 tiles wide (c = 4)")
eq(betBox and betBox[5], 7, "and 7 tall (b = 5), down to the last row")
eq(betBox and (betBox[3] + betBox[5]), 18, "so its bottom edge is the screen bottom")
local x3 = drawn(bet, "×3")
eq(x3 and x3[3], 16 * 8, "the multipliers print at column 16")
eq(x3 and x3[4], 12 * 8, "on row 12")
local betCursor = cursor(bet)
eq(betCursor and betCursor[3], 15 * 8, "the cursor sits at column 15")
eq(betCursor and betCursor[4], 12 * 8, "on the ×3 row by default")
slots.stage = "onemore"
slots.yesno = 1
local more = record(function() slots:drawBottom() end)
local moreBox = box(more, 14, 12)
check(moreBox ~= nil, "the One more go? menu box starts at (14,12)")
eq(moreBox and moreBox[4], 6, "it is 6 tiles wide")
eq(moreBox and moreBox[5], 5, "and 5 tall (YES_NO_MENU 4x3)")
check(box(more, 13, 12) == nil, "and not one column left of that")
local yes, no = drawn(more, "YES"), drawn(more, "NO")
eq(yes and yes[3], 16 * 8, "YES prints at column 16")
eq(yes and yes[4], 13 * 8, "on row 13")
eq(no and no[4], 14 * 8, "NO one row below it")
local moreCursor = cursor(more)
eq(moreCursor and moreCursor[3], 15 * 8, "its cursor sits at column 15")
eq(moreCursor and moreCursor[4], 13 * 8, "starting on YES")
slots.yesno = 2
local onNo = record(function() slots:drawBottom() end)
eq(cursor(onNo) and cursor(onNo)[4], 14 * 8, "and moves to the NO row")
T.finish()
@@ -0,0 +1,52 @@
-- The START cursor is unsaved WRAM (ram/wram.asm:238-242) and lies outside
-- sGameData (ram/sram.asm:17-21), so it must not ride the save file (#1821).
-- luajit tests/run_engine.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
require("src.render.Font").load(Data)
local SaveData = require("src.core.SaveData")
local StartMenu = require("src.ui.StartMenu")
local function stubGame(save)
local stack = { states = {} }
function stack:push(s) self.states[#self.states + 1] = s end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
return {
data = Data, save = save, stack = stack,
input = { wasPressed = function() return false end,
isDown = function() return false end },
}
end
do
local save = SaveData.newGame()
local game = stubGame(save)
local menu = StartMenu.new(game)
menu.index = math.min(3, #menu.items)
menu:update(1 / 60)
T.eq(game.startMenuIndex, menu.index, "the cursor slot lands on the game")
T.check(save.startMenuIndex == nil, "and never on the save table")
local reopened = StartMenu.new(stubGame(save))
T.eq(reopened.index, 1, "a fresh session opens on the top row")
local same = StartMenu.new(game)
T.eq(same.index, menu.index, "the same session reopens where it was")
end
do
local save = SaveData.newGame()
save.startMenuIndex = 5
local encoded = SaveData.encodeForTest and SaveData.encodeForTest(save) or nil
T.check(encoded == nil or encoded:find("startMenuIndex") == nil,
"no serializer writes the cursor back out")
local game = stubGame(save)
local menu = StartMenu.new(game)
T.eq(menu.index, 1,
"a legacy save file's stored slot no longer reopens the menu on it")
end
T.finish("START menu cursor is session state (#1821)")
+113
View File
@@ -0,0 +1,113 @@
-- The forget-a-move text is not one uninterrupted string: pokered's
-- OneTwoAndText holds the box with text_pause, plays SFX_SWAP, prints
-- " Poof!" and holds it again before the paragraph break
-- (engine/pokemon/learn_move.asm:208-222, home/text.asm:492-504). The port
-- typed all of it straight through in silence (#1818). TextBox.PAUSE now
-- carries those waits inside the string, with opts.pauseSounds naming the
-- sfx each one fires. ROM-free.
-- luajit tests/engine/textbox_pause_bug1818.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local TextBox = require("src.render.TextBox")
local MoveLearnMenu = require("src.ui.MoveLearnMenu")
local Sound = require("src.core.Sound")
local played = {}
local realPlay = Sound.play
Sound.play = function(data, name)
played[#played + 1] = name
return nil
end
local function newGame()
local game = {
save = { player = {}, options = { textSpeed = "FAST" } },
data = { text = {}, moves = { MEGA_PUNCH = { name = "MEGA PUNCH" } },
pokemon = { NIDORINO = { name = "NIDORINO" } } },
}
game.stack = {
states = {},
push = function(self, s) table.insert(self.states, s) end,
pop = function(self) return table.remove(self.states) end,
top = function(self) return self.states[#self.states] end,
}
game.input = {
queue = {},
wasPressed = function(self, btn) return self.queue[btn] or false end,
isDown = function(self, btn) return self.queue[btn] or false end,
}
return game
end
local game = newGame()
local box = TextBox.new(game, "1, 2 and..." .. TextBox.PAUSE .. " Poof!",
nil, { pauseSounds = { "Swap" } })
eq(box.pages[1][1], "1, 2 and... Poof!",
"the marker is stripped out of the line the font ever sees")
check(box.pauseAt and box.pauseAt[1] and box.pauseAt[1][1],
"the pause is re-anchored onto page 1, line 1")
eq(box.pauseAt[1][1][11], 1, "it sits on the 11th glyph, the last of \"1, 2 and...\"")
-- type up to the marker
local frames = 0
while not box.pauseFrames and frames < 600 do
box:update(1 / 60)
frames = frames + 1
end
check(box.pauseFrames, "typing stops at the marker")
eq(box.charIndex, 11, "it stops with \"1, 2 and...\" printed and nothing after it")
eq(#played, 0, "SFX_SWAP has not sounded yet -- the wait comes first")
local frozen = box.charIndex
for _ = 1, 31 do
box:update(1 / 60)
if #played > 0 then break end
eq(box.charIndex, frozen, "nothing types while the pause runs")
end
eq(played[1], "Swap", "the wait ends with SFX_SWAP (learn_move.asm:210-213)")
for _ = 1, 600 do
if box.done then break end
box:update(1 / 60)
end
check(box.done, "\" Poof!\" types out after the sound")
eq(box.charIndex, 17, "the whole line printed, marker included in nothing")
-- a held A/B skips the wait: TextCommand_PAUSE reads hJoyHeld
local held = newGame()
held.input.queue = { a = true }
local box2 = TextBox.new(held, "1, 2 and..." .. TextBox.PAUSE .. " Poof!",
nil, { pauseSounds = { "Swap" } })
local before = #played
for _ = 1, 40 do
if box2.done then break end
box2:update(1 / 60)
end
check(box2.done, "with A held the box runs straight through the pause")
check(#played > before, "the sound still plays on the skipped wait")
-- and the real caller wires both waits with the swap on the first
local mlm = MoveLearnMenu.new(game, { species = "NIDORINO", level = 20,
moves = {} }, "MEGA_PUNCH")
mlm.forgot = "HORN ATTACK"
game.stack:push(mlm)
mlm:finish(true)
local learned = game.stack:top()
check(learned.pauseAt, "the learned-move box carries text_pause waits")
eq(learned.pauseSounds[1], "Swap", "the first wait carries SFX_SWAP")
local count = 0
for _, lines in pairs(learned.pauseAt) do
for _, chars in pairs(lines) do
for _ in pairs(chars) do count = count + 1 end
end
end
eq(count, 2, "both of learn_move.asm's text_pause commands survive")
Sound.play = realPlay
T.finish()
+7 -5
View File
@@ -133,16 +133,18 @@ check(ow.player.spinDrop ~= true and ow.player.spinning == false,
check(not playerHidden(ow), "player drawable again after the dig landing")
-- ------------------------------------------------------------------ fly
-- flap (24) + path1 (36) + hold (40) + path2 (33) = 133 frames of flyAnim,
-- then the fade, then the bird swoops in (flyArrive). Same invariant.
-- the StopMusic fade hold (28) then flap (24) + path1 (36) + hold (40) +
-- path2 (33) = 161 frames of flyAnim, then the fade, then the bird swoops
-- in (flyArrive). Same invariant.
Data.field.flyWarps.FIX_ROUTE = { x = 4, y = 6 }
ow = newOW()
ow:flyTo("FIX_ROUTE")
st = drive(ow, 260)
check(st.warpFrame ~= nil, "fly departure ends and the warp fade begins")
-- flap (8*3) + path1 (12*3) + hold (40) + path2 (11*3) = 133 frames; the
-- warp fires on frame 133's update, so the fade is on top from loop frame 134
eq(st.warpFrame, 134, "fly fade begins right after the bird''s exit path")
-- fade hold (4*7) + flap (8*3) + path1 (12*3) + hold (40) + path2 (11*3)
-- = 161 frames; the warp fires on frame 161's update, so the fade is on top
-- from loop frame 162 (player_animations.asm:123 StopMusic) #1840
eq(st.warpFrame, 162, "fly fade begins right after the bird''s exit path")
check(st.fadeFrames > 0, "fly warp fade ran (" .. st.fadeFrames .. " frames)")
eq(st.gapFrames, 0,
"no fly fade frame leaves the player standing bare (#916)")
+10
View File
@@ -645,6 +645,16 @@ save = newSave({ eggWith(0) })
hatched = Breeding.hatch(DATA, save, 1, "SPROUT")
eq(hatched.nickname, "SPROUT", "a nickname taken from the naming screen sticks")
-- HatchEggs writes wPlayerID / wPlayerName over whatever the egg carried, so
-- the ODD_EGG's "ODD" and its table id do not survive the hatch.
-- engine/pokemon/breeding.asm:299-309
save = newSave({ eggWith(0) })
save.party[1].ot = "ODD"
save.party[1].otId = 2048
hatched = Breeding.hatch(DATA, save, 1)
eq(hatched.ot, "GOLD", "the hatchling's OT becomes the player")
eq(hatched.otId, 1234, "and MON_OT_ID becomes wPlayerID")
eq(#Breeding.readyToHatch(newSave({ eggWith(0), eggWith(2), eggWith(0) })), 2,
"readyToHatch names every spent counter")
+91
View File
@@ -0,0 +1,91 @@
-- The #DEX screen (src/ui/gen2/PokedexMenu.lua) reads its KIND label and
-- description pages from data.gen2Pokedex.entries, a table loaded straight
-- from disk before mods:load runs -- a separate table from data.pokemon,
-- the `pokemon` registry's own merge target
-- (mod.content.pokemon:patch(id, { dexEntry = ... })). Without a projection
-- step, a translation mod's #DEX text validates against the registry and
-- never reaches the screen. src/core/gen2/PokedexText.lua is that
-- projection, called once after the merge (src/core/Game2.lua). ROM-free:
-- luajit tests/gen2_pokedex_text_registry_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 pokedex text registry")
local check, eq = S.check, S.eq
local PokedexText = require("src.core.gen2.PokedexText")
-- A patched species (CYNDAQUIL, kind/text/text2 all overridden), one left
-- alone (TOTODILE, no dexEntry at all in data.pokemon -- a mod that never
-- touched it), and one gen2Pokedex has no matching data.pokemon row for
-- (UNOWN, e.g. a form gen2Pokedex tracks that the pokemon registry does not).
do
local data = {
pokemon = {
CYNDAQUIL = { name = "CYNDAQUIL", dexEntry = {
kind = "SOURIS DE FEU", text = "Page un traduite.",
text2 = "Page deux traduite.",
} },
TOTODILE = { name = "TOTODILE" },
},
gen2Pokedex = { entries = {
CYNDAQUIL = { id = "CYNDAQUIL", dex = 155, kind = "FIRE MOUSE",
height = 108, weight = 170,
text = "It is timid, and always curls itself up in a ball.",
text2 = "If attacked, it flares up its back for protection." },
TOTODILE = { id = "TOTODILE", dex = 158, kind = "BIG JAW",
height = 200, weight = 200, text = "English page one.",
text2 = "English page two." },
UNOWN = { id = "UNOWN", dex = 201, kind = "SYMBOL",
height = 50, weight = 50, text = "English only." },
} },
}
local touched = PokedexText.apply(data)
eq(touched, 1, "only the species a mod actually patched are touched")
local cyndaquil = data.gen2Pokedex.entries.CYNDAQUIL
eq(cyndaquil.kind, "SOURIS DE FEU", "kind reaches the #DEX entry")
eq(cyndaquil.text, "Page un traduite.", "page one reaches the #DEX entry")
eq(cyndaquil.text2, "Page deux traduite.", "page two reaches the #DEX entry")
eq(cyndaquil.dex, 155, "dex number is untouched -- it is not translatable text")
eq(cyndaquil.height, 108, "height is untouched -- game state, not prose")
eq(cyndaquil.weight, 170, "weight is untouched -- game state, not prose")
local totodile = data.gen2Pokedex.entries.TOTODILE
eq(totodile.kind, "BIG JAW", "a species with no dexEntry patch stays in English")
eq(totodile.text, "English page one.", "unpatched page one is untouched")
local unown = data.gen2Pokedex.entries.UNOWN
eq(unown.kind, "SYMBOL",
"a #DEX entry with no data.pokemon counterpart at all is left alone")
end
-- A patch that only sets `kind` (species_kinds without species_dex_text
-- shipped, or vice versa) must not blank out the fields it did not touch.
do
local data = {
pokemon = { ABRA = { name = "ABRA", dexEntry = { kind = "PSY" } } },
gen2Pokedex = { entries = {
ABRA = { id = "ABRA", dex = 63, kind = "PSI",
text = "English text.", text2 = "English text two." },
} },
}
PokedexText.apply(data)
local abra = data.gen2Pokedex.entries.ABRA
eq(abra.kind, "PSY", "kind alone is applied")
eq(abra.text, "English text.", "text is left alone when the patch omits it")
eq(abra.text2, "English text two.", "text2 is left alone when the patch omits it")
end
-- Missing tables at every level answer 0 rather than raising -- a Gen 1 boot,
-- or a Gold boot with no mods loaded, never calls this with a shaped table.
do
eq(PokedexText.apply(nil), 0, "nil data does not raise")
eq(PokedexText.apply({}), 0, "empty data does not raise")
eq(PokedexText.apply({ gen2Pokedex = { entries = {} } }), 0,
"no data.pokemon at all does not raise")
end
check(true, "PokedexText.apply covers the projection this fix adds")
S.finish()
+96
View File
@@ -0,0 +1,96 @@
-- A Gen 2 cart save must be refused as a Gen 2 cart save, not as a corrupt
-- Gen 1 one (#1832).
-- luajit tests/gen2_save_import_message_test.lua
-- Also dofile'd by tests/run_tests.lua.
--
-- SaveFileIO.importToSlot judges anything that is not exactly SAVE_SIZE with
-- SaveConvert.mainChecksumValid, which is pokered's main-data checksum. Gen 2
-- carts are MBC3+TIMER, so a real Gold/Silver/Crystal battery save carries an
-- RTC footer and is 32786 bytes: it misses the size test, is then measured
-- against a checksum rule written for a different generation, and the launcher
-- tells the player their save is corrupt. It is not -- there is simply no Gen
-- 2 codec yet, which is a different sentence and an actionable one.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local S = require("tests.harness").suite("gen2 save import message")
local check = S.check
local SaveConvert = require("src.save_convert.SaveConvert")
local SaveFileIO = require("src.import.SaveFileIO")
-- The size a real Gen 2 cart save actually is: 32768 bytes of SRAM plus the
-- 18-byte RTC footer an MBC3+TIMER cart writes.
local GEN2_CART_SAVE_SIZE = 32786
local function blob(n) return string.rep("\0", n) end
-- readSource only takes a raw string when it is EXACTLY 32768 bytes; anything
-- else is treated as a picker path (its own comment says so). A real Gen 2
-- cart save is 32786, so it can only ever reach importToSlot as a FILE -- which
-- is exactly how the player in #1832 supplied theirs. Write one and hand over
-- the path, so this exercises the route the report came from.
local function savFile(n)
local path = os.tmpname()
local f = assert(io.open(path, "wb"))
f:write(blob(n))
f:close()
return path
end
-- ------------------------------------------------------------------
-- The report: a real Gen 2 save is not "checksum invalid"
-- ------------------------------------------------------------------
for _, version in ipairs({ "gold", "silver", "crystal" }) do
local ok, err = SaveFileIO.importToSlot(savFile(GEN2_CART_SAVE_SIZE), version, true)
check(ok == false, version .. ": a Gen 2 cart save is still refused")
check(type(err) == "string" and err:find("Gen 2 cart save", 1, true) ~= nil,
version .. ": refused AS a Gen 2 save -- got: " .. tostring(err))
check(type(err) == "string" and err:find("checksum", 1, true) == nil,
version .. ": never blamed on a checksum it was never measured by -- got: "
.. tostring(err))
end
-- ------------------------------------------------------------------
-- The predicate both callers share
-- ------------------------------------------------------------------
for _, version in ipairs({ "red", "blue", "yellow" }) do
check(SaveConvert.importSupported(version) == true,
version .. ": Gen 1 import is unaffected")
check(SaveConvert.exportSupported(version) == true,
version .. ": Gen 1 export is unaffected")
end
for _, version in ipairs({ "gold", "silver", "crystal" }) do
local impOk, impWhy = SaveConvert.importSupported(version)
local expOk, expWhy = SaveConvert.exportSupported(version)
check(impOk == false and expOk == false, version .. ": both directions say no")
-- One sentence per direction, wherever it is asked from: the early gate in
-- SaveFileIO and the late one inside importSav must not describe the same
-- game two different ways.
local _, lateWhy = SaveConvert.importSav(blob(32768), version, version)
check(impWhy == lateWhy,
version .. ": the early gate and importSav answer identically")
check(expWhy:find("exporting", 1, true) ~= nil,
version .. ": the export sentence is about exporting")
end
-- ------------------------------------------------------------------
-- Gen 1 keeps its own diagnosis
-- ------------------------------------------------------------------
--
-- A Gen 1 save that really is the wrong size AND fails pokered's checksum must
-- still say so: this fix moves the generation check in front of that test, it
-- does not remove it.
do
local ok, err = SaveFileIO.importToSlot(savFile(GEN2_CART_SAVE_SIZE), "red", true)
check(ok == false, "red: a corrupt oversize save is still refused")
check(type(err) == "string" and err:find("checksum", 1, true) ~= nil,
"red: still diagnosed by pokered's checksum -- got: " .. tostring(err))
end
S.finish()
+87
View File
@@ -526,6 +526,93 @@ for _, id in ipairs(Screens.GEN2_IDS) do
end
check("Gen2UnownPuzzle is a screen id", registered, true)
-- ================================================= Crystal's flag skew (#1834)
--
-- pokecrystal constants/engine_flags.asm:25 ENGINE_MOBILE_SYSTEM shifts the
-- unlock flags to 43-46 (:57-60); pokegold keeps 42-45 (:56-59).
do
local World = require("src.world.gen2.World")
local crystalOrder = {}
for _, set in ipairs(Unown.UNLOCK_SETS) do
crystalOrder[set.flag + 2] = set.name
end
local function stubWorld(engineFlags, order)
return setmetatable({
constants = { engineFlagOrder = order },
game = { save = { engineFlags = engineFlags } },
}, { __index = World })
end
-- what a Crystal save's raw flags used to feed Unown directly: L-Z only
local rawCrystal = { [43] = true, [44] = true, [45] = true, [46] = true }
local leaked = Unown.unlockedLetters(rawCrystal)
check("raw Crystal flags leak only 15 letters", #leaked, 15)
check("and the first survivor is L", leaked[1], 12)
local view = stubWorld(rawCrystal, crystalOrder):unownUnlockFlags()
local resolved = Unown.unlockedLetters(view)
check("the resolved view unlocks all 26", #resolved, 26)
check("A included", resolved[1], 1)
-- the same distribution through wildDVs itself
math.randomseed(1)
local function roll()
return { attack = math.random(0, 15), defense = math.random(0, 15),
speed = math.random(0, 15), special = math.random(0, 15) }
end
local seenOld, seenNew = {}, {}
for _ = 1, 4000 do
seenOld[Unown.letterFromDVs(Unown.wildDVs(rawCrystal, roll))] = true
seenNew[Unown.letterFromDVs(Unown.wildDVs(view, roll))] = true
end
local countOld, countNew = 0, 0
for _ in pairs(seenOld) do countOld = countOld + 1 end
for _ in pairs(seenNew) do countNew = countNew + 1 end
check("raw flags roll only 15 letters", countOld, 15)
check("raw flags never roll A", seenOld[1], nil)
check("the resolved view rolls all 26", countNew, 26)
-- one chamber: Crystal's setflag 43 is Kabuto's A-K, not L-R
local kabuto = Unown.unlockedLetters(
stubWorld({ [43] = true }, crystalOrder):unownUnlockFlags())
check("Crystal flag 43 is A-K", #kabuto, 11)
check("starting at A", kabuto[1], 1)
-- Crystal's 42 is ENGINE_EARTHBADGE and must not unlock a chamber
check("Crystal flag 42 unlocks nothing", Unown.anyUnlocked(
stubWorld({ [42] = true }, crystalOrder):unownUnlockFlags()), false)
-- a Gold cache has no engineFlagOrder and keeps its own ids
local gold = Unown.unlockedLetters(
stubWorld({ [42] = true }, nil):unownUnlockFlags())
check("Gold flag 42 stays A-K", #gold, 11)
end
-- ============================================= the letters table's keys (#1834)
--
-- RomExtractorGen2 keys `letters` by "A".."Z"; monLetter answers a number, so
-- the anim lookup must convert or every letter animates as A.
do
local BattleState = require("src.ui.gen2.BattleState")
local SummaryMenu = require("src.ui.gen2.SummaryMenu")
local animA, animI = { sheet = "sheet-a" }, { sheet = "sheet-i" }
data.pokemon.UNOWN.anim = animA
data.pokemon.UNOWN.letters.I.anim = animI
local screen = { pokemon = data.pokemon }
check("animData picks the mon's own letter",
BattleState.animData(screen, unown), animI)
local plain = Mon.new(data, "UNOWN", 5, { dvs = Unown.dvsForLetter(2) })
check("a letter with no anim row falls back to the species'",
BattleState.animData(screen, plain), animA)
-- StatsScreen_PlaceFrontpic reads the same letter row
-- (../pokecrystal/engine/pokemon/stats_screen.asm:889-901)
local asked
local summary = { mon = unown, pokemon = data.pokemon,
picImage = function(_, sheet) asked = sheet return nil end }
SummaryMenu.startPicAnim(summary)
check("the summary menu reads the same letter row", asked, "sheet-i")
end
print(("gen2 unown: %d checks, %d failures"):format(checks, failures))
-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an exit
-- here would take the whole tier down and silently skip every suite after it.
+5 -1
View File
@@ -922,8 +922,12 @@ check(poolWorld:useFieldMove("WHIRLPOOL", SPINNER[1]).ok, "whirlpool cleared")
runFrames(poolWorld, 1)
eq(poolWorld.log[1], T.USE_WHIRLPOOL, "UseWhirlpoolText")
advanceText(poolWorld)
-- engine/events/overworld.asm:1142-1164
eq(poolWorld.map.def.blocks[TREE_BLOCK_INDEX], 0x07,
"the whirlpool block is still on screen while the sfx plays (#1862)")
runFrames(poolWorld, 4)
eq(poolWorld.map.def.blocks[TREE_BLOCK_INDEX], 0x36,
"DisappearWhirlpool swaps block $07 for $36")
"DisappearWhirlpool swaps block $07 for $36 once the sfx ends")
local wrongBlock = fieldWorld({ [4 * 100 + 5] = COLL_WHIRLPOOL }, SPINNER)
check(not wrongBlock:useFieldMove("WHIRLPOOL", SPINNER[1]).ok,
"a whirlpool collision over the wrong block is refused")
+6 -5
View File
@@ -558,8 +558,9 @@ local pm = PartyMenu.new(pgame)
pm.game = pgame
pgame.stack:push(pm)
press(pm, "a")
check(pm.submenu and #pm.subItems == 2
and pm.subItems[1].label == "STATS" and pm.subItems[2].label == "SWITCH",
check(pm.submenu and #pm.subItems == 3
and pm.subItems[1].label == "STATS" and pm.subItems[2].label == "SWITCH"
and pm.subItems[3].label == "CANCEL",
"vanilla party submenu unchanged with no hooks")
pm.submenu = nil
@@ -570,9 +571,9 @@ hooks:wrap("ui.party.submenu", function(nextFn, game, items, mon, ctx)
return nextFn(game, items, mon, ctx)
end, 0, "fixture")
press(pm, "a")
check(#pm.subItems == 3 and pm.subItems[3].label == "QUESTS",
check(#pm.subItems == 4 and pm.subItems[4].label == "QUESTS",
"hook appends a party submenu entry")
pm.subIndex = 3
pm.subIndex = 4
press(pm, "a")
check(ranWith == pgame.save.party[1],
"an injected entry's onSelect runs with the focused mon")
@@ -581,7 +582,7 @@ hooks:removeOwner("fixture")
hooks:wrap("ui.party.submenu", function() return nil end, 0, "bad")
press(pm, "a")
check(#pm.subItems == 2, "a non-table submenu result keeps the vanilla list")
check(#pm.subItems == 3, "a non-table submenu result keeps the vanilla list")
hooks:removeOwner("bad")
pm.submenu = nil
+5 -2
View File
@@ -44,11 +44,14 @@ end
TileRenderer.setSpinning(false)
check(not TileRenderer.spinBlurActive(), "no arrow blur frame outside a spin")
-- one toggle per 16-frame walked tile: wSimulatedJoypadStatesIndex drops
-- once per JoypadOverworld call (home/overworld.asm:1844-1846) and
-- spinners.asm:18-22 reads its bit 0 (#1831)
TileRenderer.setSpinning(true)
local a = TileRenderer.spinBlurActive()
for i = 1, 8 do TileRenderer.tick() end
for i = 1, 16 do TileRenderer.tick() end
local b = TileRenderer.spinBlurActive()
check(a ~= b, "arrow blur frame toggles every ~8 ticks while spinning")
check(a ~= b, "arrow blur frame toggles every ~16 ticks while spinning")
TileRenderer.setSpinning(false)
check(not TileRenderer.spinBlurActive(), "blur frame turns off once the spin ends")
+3 -1
View File
@@ -303,7 +303,9 @@ Game.save.forcedBike = true
ow = pushOW("ROUTE_17", 4, 10, "down")
ow:flyTo("PALLET_TOWN")
eq(Game.save.forcedBike, nil, "Fly clears BIT_ALWAYS_ON_BIKE")
ow.flyAnim, ow.flyDest, ow.player.inputLocked = nil, nil, false -- undo flyTo
-- undo flyTo, flyFade included: it re-arms flyAnim when it drains
ow.flyAnim, ow.flyDest, ow.flyFade = nil, nil, nil
ow.player.inputLocked = false
Game.save.forcedBike = true
ow:warpToHealPoint()
eq(Game.save.forcedBike, nil, "blackout/escape warps clear BIT_ALWAYS_ON_BIKE")
+82
View File
@@ -0,0 +1,82 @@
-- Parity test: an AI trainer switch clears the exp participant flags.
--
-- pokered's AI switch path calls EnemySendOut, which zeroes
-- wPartyGainExpFlags and re-flags only wPlayerMonNumber before falling
-- into EnemySendOutFirstMon (engine/battle/core.asm:1276-1292). The port
-- kept the old participants, so a mon that had fought the withdrawn foe
-- still halved the exp for the newly sent-out one (#1826).
--
-- Self-contained; run via `luajit tests/parity_ai_switch_exp.lua`.
-- Also picked up by tests/run_tests.lua's parity_* glob.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.pokemon and Data.pokemon.RATTATA) then Data:load() end
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local S = require("tests.harness").suite("parity ai switch exp")
local check, eq = S.check, S.eq
local function freshGame()
return {
data = Data,
save = {
party = { Pokemon.new(Data, "BULBASAUR", 50), Pokemon.new(Data, "CHARMANDER", 50) },
player = { name = "RED", id = 1234 },
inventory = {},
options = { battleStyle = "set" },
pokedex = { seen = {}, owned = {} },
flags = {},
money = 0,
},
stack = { push = function() end, pop = function() end, top = function() end },
}
end
-- Both party mons fought the first foe; the AI then withdraws it.
local function battleAfterAiSwitch()
local Game = freshGame()
local b = BattleState.newTrainer(Game, "OPP_AGATHA", 1)
check(#b.enemyParty >= 2, "the trainer has a reserve to switch to")
b.participants = { [Game.save.party[1]] = true, [Game.save.party[2]] = true }
b:executeAction(b.enemy, b.player, { special = "aiSwitch", index = 2 })
return Game, b
end
do
local Game, b = battleAfterAiSwitch()
eq(b.participants[Game.save.party[1]], true,
"the mon on the field is the one participant after the switch")
eq(b.participants[Game.save.party[2]], nil,
"the benched mon lost its gain-exp flag (core.asm:1276-1289)")
end
-- The divisor follows: the on-field mon takes the whole share, and the
-- benched one earns nothing off the new foe's KO.
do
local Game, b = battleAfterAiSwitch()
b.enemy.mon.hp = 0
local before1 = Game.save.party[1].exp
local before2 = Game.save.party[2].exp
b:awardExp()
local gainedSwitched = Game.save.party[1].exp - before1
check(gainedSwitched > 0, "the mon on the field is paid for the KO")
eq(Game.save.party[2].exp - before2, 0, "the benched mon is paid nothing")
-- Reference: the same KO with a single flagged participant from the start.
local Game2 = freshGame()
local b2 = BattleState.newTrainer(Game2, "OPP_AGATHA", 1)
b2:executeAction(b2.enemy, b2.player, { special = "aiSwitch", index = 2 })
b2.participants = { [Game2.save.party[1]] = true }
b2.enemy.mon.hp = 0
local ref = Game2.save.party[1].exp
b2:awardExp()
eq(gainedSwitched, Game2.save.party[1].exp - ref,
"the share is undivided, not halved by the stale participant")
end
S.finish()
+8 -5
View File
@@ -35,12 +35,12 @@ local check, eq = S.check, S.eq
-- rng floor: accuracyRoll compares rng(0, 255) against the scaled accuracy,
-- so the lowest roll lands HYPNOSIS (60%) every run
local function freshBattle()
local function freshBattle(rng)
Game.save.options.animations = true
Game.save.party = { Pokemon.new(Data, "SQUIRTLE", 30) }
local tb = BattleState.newWild(Game, "PIDGEY", 10)
tb.queue, tb.nextInsert = {}, 0
tb.rng = function(a) return a end
tb.rng = rng or function(a) return a end
return tb
end
@@ -53,8 +53,8 @@ local function hitRows(tb)
return out
end
local function typeOf(moveId, isPlayer)
local tb = freshBattle()
local function typeOf(moveId, isPlayer, rng)
local tb = freshBattle(rng)
local user = isPlayer and tb.player or tb.enemy
local target = isPlayer and tb.enemy or tb.player
tb:performMove(user, target, { id = moveId, pp = 10 }, false)
@@ -111,7 +111,10 @@ do
eq(typeOf("HYPNOSIS", false), 3, "and type 3 from the foe")
eq(typeOf("GROWL", true), 6, "GROWL is a primary stat drop: type 6")
eq(typeOf("TAIL_WHIP", true), 6, "so is TAIL_WHIP")
eq(typeOf("SAND_ATTACK", false), 3, "the foe's SAND-ATTACK is type 3")
-- StatModifierDownEffect misses on a roll under $40 on the enemy's
-- turn -- engine/battle/effects.asm:552
eq(typeOf("SAND_ATTACK", false, function() return 0x40 end), 3,
"the foe's SAND-ATTACK is type 3")
eq(typeOf("POISONPOWDER", true), 6, "POISONPOWDER is type 6")
eq(typeOf("CONFUSE_RAY", true), 6, "CONFUSE RAY is type 6")
eq(typeOf("DISABLE", true), 6, "DISABLE is type 6")
+47
View File
@@ -0,0 +1,47 @@
-- Parity test: the ball-shake pause keeps the resting ball on screen.
--
-- PlaySubanimation draws the frame block and only then runs
-- DoSpecialEffectByAnimationId (engine/battle/animations.asm:623-627), so
-- DoBallShakeSpecialEffects' SFX_TINK + DelayFrames 40 (animations.asm:
-- 739-747) lands with the ball already drawn. The port paused first and
-- showed 40 blank frames per wobble (#1853).
--
-- Self-contained; run via `luajit tests/parity_ball_shake_anim.lua`.
-- Also picked up by tests/run_tests.lua's parity_* glob.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity ball shake anim")
local check, eq = S.check, S.eq
local AnimPlayer = require("src.battle.AnimPlayer")
local player = AnimPlayer.new(require("data.generated.battle_anims"))
player:start("SHAKE_ANIM", true, { shakes = 3 })
local tinks = {}
for _, e in ipairs(player.events) do
if e.effect == "SFX_TINK" then tinks[#tinks + 1] = e.frame end
end
eq(#tinks, 3, "one SFX_TINK per requested shake")
-- the step a given frame falls in
local function stepAt(frame)
local at = 0
for _, st in ipairs(player.steps) do
if frame >= at and frame < at + st.dur then return st end
at = at + st.dur
end
end
for i, frame in ipairs(tinks) do
local st = stepAt(frame)
check(st ~= nil, "shake " .. i .. " has a step at its tink frame")
eq(st.dur, 40, "shake " .. i .. " pauses 40 frames on the tink")
check(#st.sprites > 0,
"shake " .. i .. " shows the resting ball through that pause")
end
check(tinks[1] > 0,
"the first tink is not the very first thing the animation does")
S.finish()
+4 -1
View File
@@ -259,7 +259,10 @@ do
withdraw(game)
check(captured ~= nil, "withdraw opened its box list")
if captured then
eq(#captured.items, 1, "the list has the one box mon in it")
-- engine/menus/players_pc.asm: the list ends in CANCEL (#1847)
eq(#captured.items, 2, "the list has the one box mon in it, plus CANCEL")
check(captured.items[2] and captured.items[2].cancel,
"the last row is the CANCEL terminator")
captured.opts.onChoose(captured.items[1],
{ index = 1, close = function() end })
eq(#save.boxes[1], 0, "the mon left the box")
+31 -15
View File
@@ -90,7 +90,7 @@ end
eq(hooks.onInteract(game, ow, 2, 0), false,
"the wall left of the blackboard stays silent")
-- walk the loop: intro -> prompt -> heading box -> blurb -> prompt again
-- walk the loop: intro -> held prompt -> heading menu over it
stack = {}
hooks.onInteract(game, ow, 3, 0)
local intro = stack[#stack]
@@ -100,36 +100,52 @@ local prompt = stack[#stack]
check(getmetatable(prompt) == TextBox
and pages(prompt):find("heading", 1, true) ~= nil,
"the intro leads into the which-heading prompt")
prompt.onDone()
-- #591: LinkCableHelpText2 ends in `text_end`, so .linkHelpLoop leaves it on
-- screen and runs HandleMenuInput under it
check(prompt.onDone == nil and prompt.stay ~= nil,
"the prompt is held open instead of waiting for A and popping")
prompt.stay.onShown()
local menu = stack[#stack]
check(getmetatable(menu) == Menu, "the prompt opens the heading menu")
check(getmetatable(menu) == Menu, "the held prompt opens the heading menu")
eq(stack[#stack - 1], prompt, "the menu sits on the still-visible prompt box")
eq(#menu.items, 4, "four headings, as in HowToLinkText")
local labels = {}
for i, item in ipairs(menu.items) do labels[i] = item.label end
eq(table.concat(labels, "/"),
"HOW TO LINK/COLOSSEUM/TRADE CENTER/STOP READING",
"the headings read in HowToLinkText order")
eq(menu.items[4].onSelect, nil, "STOP READING just closes the menu")
check(menu.tw == 15 and menu.th == 10 and menu.tx == 0 and menu.ty == 0,
"the box is the asm's 15x10 at the top left")
for i = 1, 3 do
stack = {}
stack = { prompt, menu }
menu.index = i
eq(menu.items[i].keepOpen, true,
labels[i] .. " leaves the menu up, as `jp .linkHelpLoop` does")
menu.items[i].onSelect()
local blurb = stack[#stack]
check(getmetatable(blurb) == TextBox,
labels[i] .. " prints a text box")
check(getmetatable(blurb) == TextBox, labels[i] .. " prints a text box")
local want = Data.text["_LinkCableInfoText" .. i]:match("^[^\n\011\012]+")
check(pages(blurb):find(want, 1, true) ~= nil,
labels[i] .. " prints _LinkCableInfoText" .. i)
check(type(blurb.onDone) == "function",
labels[i] .. " returns to the prompt instead of dropping out")
blurb.onDone()
check(getmetatable(stack[#stack]) == TextBox,
"the prompt comes back after " .. labels[i])
stack[#stack].onDone()
check(getmetatable(stack[#stack]) == Menu,
"the menu comes back after " .. labels[i])
eq(blurb.onDone, nil,
labels[i] .. " pops itself back onto the menu instead of dropping out")
table.remove(stack) -- the blurb pops itself once it has been read
eq(stack[#stack], menu, "the same menu comes back after " .. labels[i])
eq(menu.index, i, "the cursor stays on the heading that was just read")
eq(stack[#stack - 1], prompt, "the held prompt is still under it")
end
-- STOP READING and B share .exit: both close the menu and the prompt box
eq(menu.items[4].keepOpen, nil, "STOP READING closes the menu")
stack = { prompt, menu }
table.remove(stack) -- Menu pops itself before a non-keepOpen onSelect runs
menu.items[4].onSelect()
eq(#stack, 0, "STOP READING closes the menu and the held prompt together")
check(type(menu.onCancel) == "function", "B is watched (PAD_A | PAD_B)")
stack = { prompt, menu }
table.remove(stack)
menu.onCancel()
eq(#stack, 0, "B closes the menu and the held prompt together")
S.finish()
+112
View File
@@ -0,0 +1,112 @@
-- Parity test: the Pokemon Tower 6F Marowak ghost scene.
--
-- InitWildBattle runs the battle transition before the disguise
-- (engine/battle/core.asm:6695-6702), _EnemyAppearedText carries no
-- article (data/text/text_2.asm:1251-1255), and the CUBONE's-mother text
-- ends in `done` so PlayCry lands on it (scripts/PokemonTower6F.asm:
-- 137-148, text/PokemonTower6F.asm:1-5). See #1849.
--
-- Self-contained; run via `luajit tests/parity_ghost_marowak.lua`.
-- Also picked up by tests/run_tests.lua's parity_* glob.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.pokemon and Data.pokemon.RATTATA) then Data:load() end
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local Pokemon = require("src.pokemon.Pokemon")
local S = require("tests.harness").suite("parity marowak scene")
local check, eq = S.check, S.eq
do
local RealBattleState = require("src.battle.BattleState")
local game = {
data = Data,
save = {
party = { Pokemon.new(Data, "BULBASAUR", 30) },
player = { name = "RED" },
inventory = {},
options = {},
pokedex = { seen = {}, owned = {} },
flags = {},
money = 0,
},
stack = { push = function() end, pop = function() end, top = function() end },
}
local b = RealBattleState.newWild(game, "MAROWAK", 30)
b:makeGhost()
eq(b.enemy.name, "GHOST", "the disguise renames the foe")
eq(b.introText, "GHOST\nappeared!",
"_EnemyAppearedText has no article in front of the nick")
end
-- The script side: entry wipe and cry ordering.
local realTextBox = package.loaded["src.render.TextBox"]
local realBattleState = package.loaded["src.battle.BattleState"]
local pushed = {}
package.loaded["src.render.TextBox"] = {
new = function(_, text, cb)
local box = { text = text, cb = cb }
table.insert(pushed, box)
return box
end,
substitute = function(_, text) return text end,
}
local battles = {}
local stubBattle = {}
stubBattle.__index = stubBattle
function stubBattle:makeGhost() self.ghost = true end
function stubBattle:makeUnveiledGhost() self.scopeReveal = true end
package.loaded["src.battle.BattleState"] = {
newWild = function(_, species, level)
local b = setmetatable({ species = species, level = level }, stubBattle)
table.insert(battles, b)
return b
end,
}
local scripts = dofile("data/scripts/story3.lua")
local onStep = scripts.POKEMON_TOWER_6F.onStep
check(onStep ~= nil, "the tower 6F step trigger is registered")
local game = {
data = { text = {} },
save = { flags = {}, inventory = {} },
stack = { push = function() end },
}
local rows
local pushedBattles, stackBattles = 0, 0
local ow = {
pushBattle = function() pushedBattles = pushedBattles + 1 end,
afterBattle = function() end,
scriptMove = function() end,
runner = { run = function(_, r) rows = r end },
}
game.stack.push = function(_, thing)
if getmetatable(thing) == stubBattle then stackBattles = stackBattles + 1 end
end
eq(onStep(game, ow, 10, 16), true, "stepping on the trigger cell fires it")
eq(#pushed, 1, "the Be gone... line opens the scene")
pushed[1].cb()
eq(#battles, 1, "the ghost battle is built")
eq(pushedBattles, 1, "the battle goes through the entry wipe (pushBattle)")
eq(stackBattles, 0, "and is never pushed straight onto the stack")
battles[1].onFinish("win")
check(rows ~= nil, "victory queues the departed script")
eq(rows[1][1], "play_cry", "PlayCry is armed before the CUBONE's-mother box")
eq(rows[1][2], "MAROWAK", "and it is the RESTLESS SOUL's cry")
eq(rows[1][3], nil, "with no button wait -- that text ends in `done`")
eq(rows[2][1], "show_text", "the CUBONE's-mother line follows")
check(rows[2][2]:find("CUBONE") ~= nil, "and it is that line")
eq(rows[3][1], "wait", "then the DelayFrames 30 gap")
eq(rows[4][1], "show_text", "then the soul-was-calmed line")
package.loaded["src.render.TextBox"] = realTextBox
package.loaded["src.battle.BattleState"] = realBattleState
S.finish()
+76
View File
@@ -0,0 +1,76 @@
-- Parity test: Giovanni's "WHAT! This cannot be!" is an end-battle line.
--
-- RocketHideoutB4FGiovanniText arms it with SaveEndBattleTextPointers
-- before EngageMapTrainer (scripts/RocketHideoutB4F.asm:99-120), so
-- PrintEndBattleText prints it on the battle screen between
-- TrainerDefeatedText and MoneyForWinningText. The port pushed it as an
-- overworld TextBox from onFinish, i.e. after the map was back (#1817).
--
-- Self-contained; run via `luajit tests/parity_giovanni_end_battle.lua`.
-- Also picked up by tests/run_tests.lua's parity_* glob.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity giovanni end battle")
local check, eq = S.check, S.eq
local realTextBox = package.loaded["src.render.TextBox"]
local realBattleState = package.loaded["src.battle.BattleState"]
local pushed = {}
package.loaded["src.render.TextBox"] = {
new = function(_, text, cb)
local box = { text = text, cb = cb }
table.insert(pushed, box)
return box
end,
substitute = function(_, text) return text end,
}
local battles = {}
package.loaded["src.battle.BattleState"] = {
newTrainer = function(_, class, index)
local b = { trainerClass = class, partyIndex = index }
table.insert(battles, b)
return b
end,
}
local scripts = dofile("data/scripts/story3.lua")
local handler = scripts.ROCKET_HIDEOUT_B4F.talk.TEXT_ROCKETHIDEOUTB4F_GIOVANNI
check(handler ~= nil, "the Giovanni talk handler is registered")
local game = {
data = { text = {} },
save = { defeatedTrainers = {}, flags = {}, player = { name = "RED" } },
stack = { push = function() end },
}
local ow = {
trainerDefeated = function() return false end,
pushBattle = function() end,
afterBattle = function() end,
}
handler(game, ow, { id = 7 }, function() end)
eq(#pushed, 1, "the impressed line opens the scene")
pushed[1].cb()
eq(#battles, 1, "talking starts the Giovanni battle")
local battle = battles[1]
check(battle.endBattleText ~= nil,
"the loss line is handed to the battle (SaveEndBattleTextPointers)")
check(battle.endBattleText:find("cannot be") ~= nil,
"and it is the WHAT!/This cannot be! text")
pushed = {}
battle.onFinish("win")
eq(#pushed, 1, "victory pushes exactly one overworld box")
check(pushed[1].text:find("cannot be") == nil,
"the cannot-be line no longer reprints on the map")
check(pushed[1].text:find("meet") ~= nil,
"BeatGiovanniScript's hope-we-meet-again text follows instead")
eq(game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI, true,
"the beat-Giovanni event flag is set")
package.loaded["src.render.TextBox"] = realTextBox
package.loaded["src.battle.BattleState"] = realBattleState
S.finish()
+38
View File
@@ -0,0 +1,38 @@
-- Parity: HiddenCoins pays BCD constants, and the COIN+40 tile pays 20 (#1810).
--
-- engine/events/hidden_items.asm:79-96 branches the hidden-event argument
-- into .bcd10 ($10), .bcd20 ($20) or .bcd100 ($0100); `cp 40 / jr z, .bcd20`
-- is pokered's own typo, so .bcd40 at :90 ("due to a typo, this is never
-- used") never runs and the Game Corner's COIN+40 tile (data/events/
-- hidden_events.asm:294, GAME_CORNER 11,7) credits 20 coins on hardware.
--
-- Self-contained; run via `luajit tests/parity_hidden_coins_bcd_bug1810.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity hidden coins bcd")
local check, eq = S.check, S.eq
local OverworldState = require("src.world.OverworldController")
local pay = OverworldState.hiddenCoinPayout
eq(pay(10), 10, "COIN + 10 pays BCD $10")
eq(pay(20), 20, "COIN + 20 pays BCD $20")
eq(pay(40), 20, "COIN + 40 falls into .bcd20 and pays 20")
eq(pay(100), 100, "COIN + 100 pays BCD $0100")
eq(pay(0), 100, "anything the branch chain misses pays 100")
-- the generated data still carries the raw argument: the runtime mapping,
-- not the extractor, is what turns the 40 tile into 20
local field = dofile("data/generated/field.lua")
local corner = field.hiddenCoins and field.hiddenCoins.GAME_CORNER
check(corner ~= nil, "GAME_CORNER has hidden coin tiles")
local forty
for _, h in ipairs(corner or {}) do
if h.x == 11 and h.y == 7 then forty = h end
end
check(forty ~= nil, "the (11,7) hidden coin tile is present")
eq(forty and forty.coins, 40, "it is extracted as the COIN + 40 argument")
eq(pay(forty and forty.coins), 20, "and the award path credits 20 for it")
S.finish()
+1 -1
View File
@@ -138,4 +138,4 @@ do
eq(#pushed, 0, "loss does not push after-battle text")
end
print("parity_lance: ok")
S.finish()
+5 -2
View File
@@ -62,12 +62,15 @@ local function gameWith(inventory, flags)
end
local function owWith()
local moved, after = {}, {}
local moved, after, wiped = {}, {}, {}
return {
player = { facing = "up" },
scriptMove = function(_, _, dir, n) moved[#moved + 1] = { dir, n } end,
afterBattle = function(_, result) after[#after + 1] = result end,
}, moved, after
-- InitWildBattle runs the transition before the disguise
-- (engine/battle/core.asm:6695-6702), so the script uses pushBattle
pushBattle = function(_, battle) wiped[#wiped + 1] = battle end,
}, moved, after, wiped
end
-- Walk the trigger: run onStep, then the Be-gone text's done() to push
@@ -0,0 +1,79 @@
-- Parity: the Tower 2F rival scene arms its in-battle line, prints
-- HowsYourDex on the win, and restores the map music (#1842).
--
-- PokemonTower2FRivalText arms SaveEndBattleTextPointers with .DefeatedText
-- before the fight (scripts/PokemonTower2F.asm:145-150), the win re-runs
-- DisplayTextID so the beaten branch prints .HowsYourDexText (asm:72-75,
-- :137-140), and PokemonTower2FRivalExitsScript hides him and calls
-- PlayDefaultMusic (asm:115-124).
--
-- Self-contained; run via `luajit tests/parity_pokemon_tower_rival_bug1842.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity pokemon tower rival")
local check, eq = S.check, S.eq
local story = dofile("data/scripts/story.lua")
local tower = story.POKEMON_TOWER_2F
check(tower ~= nil and tower.rivalScript ~= nil, "POKEMON_TOWER_2F rivalScript exists")
local rows = tower.rivalScript(15)
local function find(verb, arg)
for i, row in ipairs(rows) do
if row[1] == verb and (arg == nil or row[2] == arg) then return i, row end
end
end
local iBattle = find("rival_battle")
local iArm = find("save_end_battle_text", "_PokemonTower2FRivalDefeatedText")
check(iArm ~= nil, "_PokemonTower2FRivalDefeatedText is armed for the battle")
check(iBattle and iArm == iBattle - 1,
"SaveEndBattleTextPointers runs just before the battle")
check(find("show_text", "_PokemonTower2FRivalDefeatedText") == nil,
"and it is not also printed on the map afterwards")
local iSet = find("set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL")
local iDex = find("show_text", "_PokemonTower2FRivalHowsYourDexText")
check(iSet and iDex and iDex > iSet,
"the win prints HowsYourDex after setting the event")
local iHide = find("hide_object")
local iDefault = find("play_default_music")
check(iDefault ~= nil, "the exit calls play_default_music")
check(iHide and iDefault and iDefault > iHide,
"PlayDefaultMusic follows HideObject")
local iWalk = find("walk_npc")
check(iWalk and iHide and iHide > iWalk, "the walk-out runs before the hide")
-- the beaten branch: every jump target resolves, and talking again after the
-- win reaches HowsYourDex instead of falling off the end
local ScriptRunner = require("src.script.ScriptRunner")
local problems = ScriptRunner.validate(rows)
eq(#problems, 0, "rival script validates: " .. table.concat(problems, "; "))
local iJumpIfTrue, jumpRow = find("jump_if_true")
check(iJumpIfTrue ~= nil, "the beaten check jumps")
local labelRow
for i, row in ipairs(rows) do
if row[1] == "label" and row[2] == (jumpRow and jumpRow[2]) then labelRow = i end
end
check(labelRow ~= nil, "it jumps to a real label, not a stale row number")
local tail
for i = labelRow or 0, #rows do
if rows[i] and rows[i][1] == "show_text" then tail = rows[i][2] break end
end
eq(tail, "_PokemonTower2FRivalHowsYourDexText",
"the beaten branch prints HowsYourDex")
-- both exit paths are still distinct (ON_LEFT vs not, asm:76-82)
local left = tower.rivalScript(15)
local right = tower.rivalScript(14)
local _, lWalk = find("walk_npc")
local rWalk
for _, row in ipairs(right) do if row[1] == "walk_npc" then rWalk = row end end
check(lWalk and rWalk and lWalk[3][1] ~= rWalk[3][1],
"the two exit movements still differ")
eq(#left, #right, "both variants are the same script shape")
S.finish()
+3
View File
@@ -29,8 +29,11 @@ local Bag = require("src.inventory.Bag")
local realTextBox = package.loaded["src.render.TextBox"]
local realBag = package.loaded["src.ui.BagMenu"]
local realParty = package.loaded["src.ui.PartyMenu"]
-- soundOpts only builds an opts table, so the real one runs against the stub.
local soundOpts = require("src.render.TextBox").soundOpts
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { textBox = true, text = text, done = done } end,
soundOpts = soundOpts,
}
package.loaded["src.ui.BagMenu"] = nil
package.loaded["src.ui.PartyMenu"] = nil
+85
View File
@@ -0,0 +1,85 @@
-- Parity: Rocket3 in the Hideout B4F engages on sight, and the LIFT KEY
-- still drops after a sight-triggered battle (#1814).
--
-- RocketHideout4TrainerHeader2 is `trainer EVENT_..., 1, ...`
-- (scripts/RocketHideoutB4F.asm:95-96) and the `trainer` macro stores arg 2
-- as `db \2 << 4` (macros/scripts/maps.asm:107-127), so CheckSpriteCanSeePlayer
-- (engine/overworld/trainer_sight.asm:257-262) engages him from one tile away
-- through SCRIPT_ROCKETHIDEOUTB4F_DEFAULT (asm:42, home/trainers.asm:129).
-- The port used to skip any trainer with a hand-ported talk script; only an
-- explicit `noSight` opt-out may do that now. His LIFT KEY lives in
-- Rocket3AfterBattleText (asm:189-199), i.e. the next talk, so a sight battle
-- does not lose it.
--
-- Self-contained; run via `luajit tests/parity_rocket3_sight_bug1814.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity rocket3 sight")
local check, eq = S.check, S.eq
local MAP = "ROCKET_HIDEOUT_B4F"
local TEXT = "TEXT_ROCKETHIDEOUTB4F_ROCKET3"
-- the sight range the engine reads
local headers = dofile("data/generated/trainer_headers.lua")
local h = headers.RocketHideoutB4F and headers.RocketHideoutB4F[4]
check(h ~= nil, "RocketHideoutB4F header 4 is extracted")
eq(h and h.range, 1, "Rocket3's view range is one tile")
local maps = dofile("data/generated/maps.lua")
local rocket3
for _, o in ipairs(maps[MAP].objects or {}) do
if o.text == TEXT then rocket3 = o end
end
check(rocket3 ~= nil, "Rocket3 is object " .. TEXT)
eq(rocket3 and rocket3.index, 4, "he is object index 4, the header's row")
check(rocket3 and rocket3.trainerClass ~= nil, "and he is a trainer sprite")
eq(rocket3 and rocket3.movement, "STAY", "he holds his tile")
eq(rocket3 and rocket3.range, "DOWN", "facing down, so the sight line is below him")
eq(rocket3 and rocket3.x, 11, "standing at x 11")
eq(rocket3 and rocket3.y, 2, "standing at y 2")
-- the gate the fix uses: having a talk script is NOT an opt-out; only an
-- explicit noSight set is
local story3 = dofile("data/scripts/story3.lua")
local hideout = story3[MAP]
check(hideout ~= nil and hideout.talk[TEXT] ~= nil,
"Rocket3 still has his hand-ported talk script")
check(hideout.noSight == nil or hideout.noSight[TEXT] ~= true,
"and he is not opted out of CheckFightingMapTrainers")
-- the LIFT KEY half: after the battle -- however it started -- the next talk
-- reveals the ball
-- put the real module back at the bottom: the tier dofiles every parity file
-- into one process, so a stub left behind here breaks every later suite
local realTextBox = package.loaded["src.render.TextBox"]
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { text = text, done = done } end,
soundOpts = function() return nil end,
substitute = function(_, t) return t end,
}
local pushed
local game = {
data = { text = { _RocketHideoutB4FRocket3AfterBattleText = "Oh no!" } },
save = { flags = {} },
stack = { push = function(_, box) pushed = box end },
}
local ow = {
map = { id = MAP, def = { objects = {} } },
npcs = {}, entities = {},
trainerDefeated = function() return true end,
}
local finished = false
hideout.talk[TEXT](game, ow, { def = rocket3 }, function() finished = true end)
check(pushed ~= nil, "talking after the win prints the after-battle text")
if pushed and pushed.done then pushed.done() end
check(game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY == true,
"EVENT_ROCKET_DROPPED_LIFT_KEY is set")
local toggles = game.save.objectToggles and game.save.objectToggles[MAP]
check(toggles and toggles.ROCKETHIDEOUTB4F_LIFT_KEY == true,
"the LIFT KEY ball is shown")
check(finished, "and the talk hands control back")
package.loaded["src.render.TextBox"] = realTextBox
S.finish()
+9 -3
View File
@@ -64,16 +64,22 @@ do
"player at x=15 uses DownThenRightMovement")
end
-- (2) win path: flag, defeat text, walk, hide -- in order
-- (2) win path: flag, post-win text, walk, hide -- in order.
-- DefeatedText rides SaveEndBattleTextPointers, so it prints in battle and the
-- map-side box after the win is HowsYourDex (#1842).
-- scripts/PokemonTower2F.asm:71-75, :137-140
do
local rows = tower.rivalScript(14)
local preds = {
{ "save_end_battle_text DefeatedText",
function(r) return r[1] == "save_end_battle_text"
and r[2] == "_PokemonTower2FRivalDefeatedText" end },
{ "set_flag EVENT_BEAT_POKEMON_TOWER_RIVAL",
function(r) return r[1] == "set_flag"
and r[2] == "EVENT_BEAT_POKEMON_TOWER_RIVAL" end },
{ "show_text DefeatedText",
{ "show_text HowsYourDexText",
function(r) return r[1] == "show_text"
and r[2] == "_PokemonTower2FRivalDefeatedText" end },
and r[2] == "_PokemonTower2FRivalHowsYourDexText" end },
{ "walk_npc exit",
function(r) return r[1] == "walk_npc" end },
{ "hide_object POKEMONTOWER2F_RIVAL",

Some files were not shown because too many files have changed in this diff Show More