From e9d431b3ffaaa9b4beb51ead8d869037f3e13e43 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 13 Aug 2026 05:30:33 -0400 Subject: [PATCH] CLOSES #1091, CLOSES #1097, CLOSES #1100, CLOSES #1104, CLOSES #1135, CLOSES #1155, CLOSES #1157, CLOSES #1158, CLOSES #1159, CLOSES #1160, CLOSES #1167, CLOSES #1208 --- main.lua | 9 +- src/battle/gen2/Battle.lua | 20 ++- src/battle/gen2/Effects.lua | 20 ++- src/core/SaveData.lua | 29 +++- src/core/gen2/Save.lua | 5 + src/import/LauncherSettings.lua | 123 ++++++++-------- src/import/RomImporter.lua | 5 +- src/ui/gen2/BattleState.lua | 65 +++++++-- tests/drivers/gold_bug1091_test.lua | 80 +++++++++++ tests/drivers/gold_bug1097_test.lua | 79 +++++++++++ tests/drivers/gold_bug1104_test.lua | 78 +++++++++++ tests/drivers/gold_bug1155_test.lua | 100 +++++++++++++ tests/drivers/gold_bug1157_test.lua | 113 +++++++++++++++ tests/drivers/gold_bug1158_test.lua | 96 +++++++++++++ tests/drivers/gold_bug1159_test.lua | 131 ++++++++++++++++++ tests/drivers/gold_bug1167_test.lua | 82 +++++++++++ tests/drivers/gold_bug1208_test.lua | 48 +++++++ tests/engine/launcher_gold_touch_rows.lua | 114 +++++++++++++++ .../engine/touch_controls_pad_cursor_test.lua | 4 +- 19 files changed, 1119 insertions(+), 82 deletions(-) create mode 100644 tests/drivers/gold_bug1091_test.lua create mode 100644 tests/drivers/gold_bug1097_test.lua create mode 100644 tests/drivers/gold_bug1104_test.lua create mode 100644 tests/drivers/gold_bug1155_test.lua create mode 100644 tests/drivers/gold_bug1157_test.lua create mode 100644 tests/drivers/gold_bug1158_test.lua create mode 100644 tests/drivers/gold_bug1159_test.lua create mode 100644 tests/drivers/gold_bug1167_test.lua create mode 100644 tests/drivers/gold_bug1208_test.lua create mode 100644 tests/engine/launcher_gold_touch_rows.lua diff --git a/main.lua b/main.lua index 11a2c003..63fff0d1 100644 --- a/main.lua +++ b/main.lua @@ -197,14 +197,19 @@ end local touchEditorHost local closeTouchControlsEditor -- forward declaration -local function openTouchControlsEditor() +-- `version` is the launcher tab the gear was opened on, and it decides which +-- option block the layout lands in (src/ui/TouchControlsEditor.lua persist). +local function openTouchControlsEditor(version) touchEditorHost = Importer if Importer and Importer.prepareOverlayHandoff then Importer:prepareOverlayHandoff() end Importer = nil TouchEditor = require("src.ui.TouchControlsEditor") - TouchEditor.load({ onClose = function() closeTouchControlsEditor() end }) + TouchEditor.load({ + version = version, + onClose = function() closeTouchControlsEditor() end, + }) end function closeTouchControlsEditor() diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 691ada53..e215c176 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -1604,9 +1604,25 @@ function Battle:useMove(attacker, defender, moveId) return end - -- Damage that skips the formula entirely. - local fixed = Effects.fixedDamage(def.effect, attacker, defender, self.random) + -- Damage that skips the formula entirely. The move's own power goes with + -- it: EFFECT_STATIC_DAMAGE's arm of BattleCommand_ConstantDamage reads + -- BATTLE_VARS_MOVE_POWER as the damage (effect_commands.asm:3157-3161). + local fixed = Effects.fixedDamage(def.effect, attacker, defender, self.random, + def.power) if fixed then + -- The constant-damage effect list carries `resettypematchup` instead of + -- `stab`, and that command misses the move outright when the matchup byte + -- is 0 (effect_commands.asm:1480-1493) -- an immune target is the one + -- thing that stops SONIC BOOM, NIGHT SHADE or SUPER FANG. + local defenderTypes = (self:speciesDef(defender) or {}).types + or defender.types + local matchups = self.data.type_chart and self.data.type_chart.matchups + if Damage.typeMultiplier(def.type, defenderTypes, matchups) == 0 then + self:markMissed() + self:emit({ kind = "message", + text = "It doesn't affect " .. self:monName(defender) .. "..." }) + return + end self:dealDamage(attacker, defender, fixed, { move = def, moveId = moveId }) return end diff --git a/src/battle/gen2/Effects.lua b/src/battle/gen2/Effects.lua index 19f0b302..0cecd019 100644 --- a/src/battle/gen2/Effects.lua +++ b/src/battle/gen2/Effects.lua @@ -179,9 +179,9 @@ end -- --------------------------------------------------------------- fixed damage --- BattleCommand_LevelDamage / SuperFang / Psywave, all of which skip the --- damage formula entirely. -function Effects.fixedDamage(effect, attacker, defender, random) +-- BattleCommand_ConstantDamage (engine/battle/effect_commands.asm:3131-3205), +-- one command for the whole SuperFang / Psywave / StaticDamage list. +function Effects.fixedDamage(effect, attacker, defender, random, power) if effect == "EFFECT_LEVEL_DAMAGE" then return math.max(1, attacker.level or 1) end @@ -189,9 +189,17 @@ function Effects.fixedDamage(effect, attacker, defender, random) return math.max(1, math.floor((defender.hp or 1) / 2)) end if effect == "EFFECT_PSYWAVE" then - -- 1..(level * 1.5), rerolled until it is in range; one roll is enough here. - local ceiling = math.max(1, math.floor((attacker.level or 1) * 3 / 2)) - return math.max(1, (random and random(ceiling) or 0) + 1) + -- .psywave rerolls until the byte is nonzero AND below level * 1.5, so the + -- top of the range is that ceiling minus one (effect_commands.asm:3163). + local ceiling = math.max(2, math.floor((attacker.level or 1) * 3 / 2)) + return math.max(1, (random and random(ceiling - 1) or 0) + 1) + end + -- SONIC BOOM and DRAGON RAGE share EFFECT_STATIC_DAMAGE, whose arm reads + -- BATTLE_VARS_MOVE_POWER straight into the damage word: their stored power + -- (20 and 40) IS the damage, never a formula input + -- (effect_commands.asm:3157-3161). + if effect == "EFFECT_STATIC_DAMAGE" then + return math.max(1, math.floor(power or 0)) end return nil end diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 70dd44c7..8940911b 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -818,9 +818,19 @@ end function SaveData.slotSummary(save) if type(save) ~= "table" then return nil, nil end local name = save.player and save.player.name or nil + -- A Gen 2 slot carries no badge items and fills pokedex.caught, so both + -- counts come off wJohtoBadges/wPokedexCaught (engine/menus/intro_menu.asm:461). + local vinfo = type(save.version) == "string" and GameVersion.info(save.version) + local gen2 = save.generation == 2 or (vinfo and vinfo.generation == 2) or false local dexCount = 0 - for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do - dexCount = dexCount + 1 + if gen2 then + for _, has in pairs((save.pokedex and save.pokedex.caught) or {}) do + if has then dexCount = dexCount + 1 end + end + else + for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do + dexCount = dexCount + 1 + end end -- playTime is a plain seconds count in a Gen 1 save but a -- { hours, minutes, seconds, frames } table in a Gen 2 (Gold) save, matching @@ -838,8 +848,21 @@ function SaveData.slotSummary(save) end local timeText = ("%d:%02d"):format(math.floor(t / 3600), math.floor(t / 60) % 60) + local badges + if gen2 then + -- Continue_DisplayBadgeCount walks TWO bytes, Johto then Kanto + -- (engine/menus/intro_menu.asm:461-469). + badges = 0 + local p = save.player or {} + for _, has in pairs(p.badges or {}) do if has then badges = badges + 1 end end + for _, has in pairs(p.kantoBadges or {}) do + if has then badges = badges + 1 end + end + else + badges = Badges.count(nil, save) + end return name, { - badges = Badges.count(nil, save), + badges = badges, timeText = timeText, dexCount = dexCount, } diff --git a/src/core/gen2/Save.lua b/src/core/gen2/Save.lua index 6affceaa..3d754198 100644 --- a/src/core/gen2/Save.lua +++ b/src/core/gen2/Save.lua @@ -764,9 +764,14 @@ end function Save.summary(save) if type(save) ~= "table" then return nil end local badges = 0 + -- Continue_DisplayBadgeCount counts TWO bytes, wJohtoBadges then wKantoBadges + -- (engine/menus/intro_menu.asm:461-469). for _, has in pairs(save.player and save.player.badges or {}) do if has then badges = badges + 1 end end + for _, has in pairs(save.player and save.player.kantoBadges or {}) do + if has then badges = badges + 1 end + end local caught = 0 for _, has in pairs(save.pokedex and save.pokedex.caught or {}) do if has then caught = caught + 1 end diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index c44deeb7..0fe7c4de 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -62,6 +62,68 @@ local FILTERS = { "OFF", "1X", "2X", "3X" } -- The core rows. Helper modules are required lazily under pcall: they are -- pure label/cycle tables, but the launcher must never die because a render -- module grew a dependency on live game data. +-- TOUCH PAD, VIBRATION and the layout editor, shared by both row sets. +-- +-- Gold reads these out of its own `gold` block (src/core/gen2/Save.lua:297), +-- so `opts` is whichever table the gear is editing and the rows never have to +-- know which game they belong to. #1100 / #1135: the Gold gear carried none +-- of them, so a phone player could turn the pad and the buzz off in Red and +-- had no way to reach either in Gold. +local function addTouchRows(rows, add, opts, hooks) + -- TOUCH PAD only where the overlay can appear, mirroring OptionsMenu's + -- gate (mobile, or desktop forced by POKEPORT_TOUCH=1). + local env = os.getenv("POKEPORT_TOUCH") + local osName = love.system and love.system.getOS and love.system.getOS() + local show = env == "1" + or (env ~= "0" and (osName == "Android" or osName == "iOS")) + if show then + add(Strings("TOUCH PAD"), + function() + local tc = opts.touchControls + local on = not (type(tc) == "table" and tc.enabled == false) + return on and Strings("ON") or Strings("OFF") + end, + function() + local tc = type(opts.touchControls) == "table" and opts.touchControls or {} + tc.enabled = tc.enabled == false + opts.touchControls = tc + return true + end) + -- VIBRATION sits with it (#806): same gate, same subsystem. Stepping + -- the row buzzes once at the level being selected. + local okTC, TC = pcall(require, "src.core.TouchControls") + if okTC then + add(Strings("VIBRATION"), + function() return Strings(TC.hapticLabel(opts.haptics)) end, + function(dir) + opts.haptics = TC.cycleHaptics(opts.haptics, dir) + TC.buzz(opts.haptics) + return true + end) + end + end + + -- TOUCH CONTROLS, the on-screen pad's layout editor. It used to be a + -- button on the game panel, once per game -- but the overlay layout is + -- global (options.touchControls.layouts), so three tabs offered three + -- buttons that edited the same thing while crowding the column that has to + -- hold Play. It belongs with the other control rows, behind the gear. + -- The host owns the editor screen, so the row only fires when a hook was + -- supplied (the standalone save editor opens this model with none). + if hooks and hooks.editTouchControls then + rows[#rows + 1] = { + label = Strings("TOUCH CONTROLS"), + actionLabel = Strings("Edit"), + action = function() + hooks.editTouchControls() + -- The editor replaces the whole screen: nothing left to persist here + -- beyond what the caller already saved on the way out. + return false + end, + } + end +end + local function coreRows(opts, hooks) local rows = {} local function add(label, value, step) @@ -236,60 +298,7 @@ local function coreRows(opts, hooks) end) end - -- TOUCH PAD only where the overlay can appear, mirroring OptionsMenu's - -- gate (mobile, or desktop forced by POKEPORT_TOUCH=1). - do - local env = os.getenv("POKEPORT_TOUCH") - local osName = love.system and love.system.getOS and love.system.getOS() - local show = env == "1" - or (env ~= "0" and (osName == "Android" or osName == "iOS")) - if show then - add(Strings("TOUCH PAD"), - function() - local tc = opts.touchControls - local on = not (type(tc) == "table" and tc.enabled == false) - return on and Strings("ON") or Strings("OFF") - end, - function() - local tc = type(opts.touchControls) == "table" and opts.touchControls or {} - tc.enabled = tc.enabled == false - opts.touchControls = tc - return true - end) - -- VIBRATION sits with it (#806): same gate, same subsystem. Stepping - -- the row buzzes once at the level being selected. - local okTC, TC = pcall(require, "src.core.TouchControls") - if okTC then - add(Strings("VIBRATION"), - function() return Strings(TC.hapticLabel(opts.haptics)) end, - function(dir) - opts.haptics = TC.cycleHaptics(opts.haptics, dir) - TC.buzz(opts.haptics) - return true - end) - end - end - end - - -- TOUCH CONTROLS, the on-screen pad's layout editor. It used to be a - -- button on the game panel, once per game -- but the overlay layout is - -- global (options.touchControls.layouts), so three tabs offered three - -- buttons that edited the same thing while crowding the column that has to - -- hold Play. It belongs with the other control rows, behind the gear. - -- The host owns the editor screen, so the row only fires when a hook was - -- supplied (the standalone save editor opens this model with none). - if hooks and hooks.editTouchControls then - rows[#rows + 1] = { - label = Strings("TOUCH CONTROLS"), - actionLabel = Strings("Edit"), - action = function() - hooks.editTouchControls() - -- The editor replaces the whole screen: nothing left to persist here - -- beyond what the caller already saved on the way out. - return false - end, - } - end + addTouchRows(rows, add, opts, hooks) -- RESET REBINDS, directly under the touch-pad row. Rebinds are additive -- (src/core/Input.lua:applyBindings layers options.bindings over the @@ -462,7 +471,7 @@ end -- src/ui/gen2/OptionsMenu.lua's ROWS; when editing one, keep the two in sync. local GEN2_KEY = "gold" -local function gen2Rows(opts) +local function gen2Rows(opts, hooks) local rows = {} local function add(label, value, step) rows[#rows + 1] = { label = label, value = value, step = step } @@ -551,6 +560,8 @@ local function gen2Rows(opts) end) end + addTouchRows(rows, add, opts, hooks) + return rows end @@ -574,7 +585,7 @@ function LauncherSettings.open(hooks, version) opts[GEN2_KEY] = block end sections = { - { title = Strings("OPTIONS"), rows = gen2Rows(block) }, + { title = Strings("OPTIONS"), rows = gen2Rows(block, hooks) }, } else sections = { diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index da44939f..0b21f346 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -2652,11 +2652,14 @@ function RomImporter:_openSettings() -- hook rather than reaching for main.lua's handler itself. Closing the -- settings panel FIRST persists the pending edits (_closeSettings saves) -- and leaves no modal behind the editor to return to. + -- The tab rides along: the editor persists the layout into that game's own + -- option block, and Gold's is not the flat Gen 1 one (#1100). local hooks = {} if self.onEditTouchControls then + local version = self.tab hooks.editTouchControls = function() self:_closeSettings() - self.onEditTouchControls() + self.onEditTouchControls(version) end end -- The tab the gear was opened on decides the row set: Gold reads a diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 47a9ab31..d3dfbd76 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -106,6 +106,10 @@ local TEXT_ASK_FORGET_SLOT = Strings.source("Which move should\nbe forgotten?") local TEXT_CANT_FORGET_HM = Strings.source("HM moves can't be\nforgotten now.") local TEXT_STOP_LEARNING = Strings.source("Stop learning\n%s?") +-- BattleText_EnemyIsAboutToUseWillPlayerChangeMon (data/text/battle.asm:222-231). +local TEXT_ENEMY_ABOUT_TO_USE = Strings.source( + "%s\nis about to use\v%s.\fWill %s\nchange POKéMON?") + -- _AskForgetMoveText, all three paragraphs (data/text/common_3.asm:141-165). local TEXT_ASK_FORGET_MOVE = Strings.source( "%s is\ntrying to learn\v%s.\fBut %s\ncan't learn more\vthan four moves." @@ -548,6 +552,14 @@ BattleState.PLAYER_PIC_TILES = 6 -- box's own bottom centre. local PIC_RESIZE_TILES = { [0] = 6, [1] = 4, [2] = 2, [3] = 7, [4] = 5, [5] = 3 } +-- SUBSTATUS_UNDERGROUND / SUBSTATUS_FLYING, which BattleCommand_Charge sets on +-- FLY and DIG only (engine/battle/effect_commands.asm:5478-5485); the port +-- carries both as the volatile `vanished` flag. +function BattleState.isVanished(mon) + local volatiles = mon and mon.volatile + return (volatiles and volatiles.vanished) and true or false +end + function BattleState:drawPic(mon, back) -- During the intro slide the player-side pic belongs to presentSlide's -- backpic overlay, not to the baked bands (see BattleAnimView). @@ -571,6 +583,13 @@ function BattleState:drawPic(mon, back) -- The box is empty either because the animation running right now has -- cleared it, or because the last one ENDED with it cleared (picHidden). if (anim and anim.hidden) or self.picHidden[side] then return end + -- Mid FLY / DIG the box is empty: DisappearUser ClearBoxes it + -- (engine/battle/misc.asm:1-13), AppearUserRaiseSub puts it back on the + -- stored attack (engine/battle/effect_commands.asm:2113-2117). + if not (trainerBack or enemyTrainer) and BattleState.isVanished(mon) + and not (self.vanishAnim and self.vanishAnim == self.anim) then + return + end local G = love.graphics local w, h = image:getDimensions() local px, py @@ -582,10 +601,12 @@ function BattleState:drawPic(mon, back) py = BattleState.PLAYER_PIC_TILE_Y * 8 + (box - h) boxTiles = BattleState.PLAYER_PIC_TILES else - -- Bottom-aligned and horizontally centred inside the 7x7 box. + -- PadFrontpic pads a short pic and never a long one + -- (engine/gfx/load_pics.asm:342-386), so an oversized mod pic pins to the + -- box's own corner at hlcoord 12, 0 rather than to a negative offset. local box = BattleState.ENEMY_PIC_TILES * 8 - px = BattleState.ENEMY_PIC_TILE_X * 8 + math.floor((box - w) / 2) - py = BattleState.ENEMY_PIC_TILE_Y * 8 + (box - h) + px = BattleState.ENEMY_PIC_TILE_X * 8 + math.max(0, math.floor((box - w) / 2)) + py = BattleState.ENEMY_PIC_TILE_Y * 8 + math.max(0, box - h) boxTiles = BattleState.ENEMY_PIC_TILES end -- One tile per two frames to the right, SlideBattlePicOut's own step. @@ -903,11 +924,12 @@ function BattleState:startAnim(key, opts) param = opts.param or 0, sfxOrder = audio.sfxOrder, ballPalette = opts.ballPalette, + -- BGEffect_CheckFlyDigStatus reads wPlayerSubStatus3 / wEnemySubStatus3 + -- (engine/battle_anims/bg_effects.asm:2838-2851); the port keeps that bit + -- on the mon's volatile table, not on the mon itself. flying = { - player = self.battle and self.battle.player - and self.battle.player.vanished or false, - enemy = self.battle and self.battle.enemy - and self.battle.enemy.vanished or false, + player = BattleState.isVanished(self.battle and self.battle.player), + enemy = BattleState.isVanished(self.battle and self.battle.enemy), }, hooks = { -- anim_sound (engine/battle_anims/anim_commands.asm:1105) calls @@ -1365,6 +1387,13 @@ function BattleState:advanceQueue() end end end + -- BattleCommand_Charge runs LoadMoveAnim BEFORE DisappearUser + -- (engine/battle/effect_commands.asm:5459-5470), so FLY / DIG still draw + -- the take-off or the burrow on the turn the substatus goes up; the box + -- only empties once THIS animation is done with. + if BattleState.isVanished(self:activeMon(event.side)) then + self.vanishAnim = self.anim + end elseif event.kind == "damage" and event.side then -- ANIM_x_DAMAGE is the MOVE's after-anim (effect_commands.asm:1963-1972), -- so only a move hit gets it; `animMove` is HandleWrap's (core.asm:1198-1203). @@ -1864,6 +1893,20 @@ function BattleState:update(_dt) return end + -- The prompt's earlier pages: PlaceYesNoBox only follows the LAST one + -- (engine/battle/core.asm:3302-3305), so the mon is named and read first. + if self.phase == "shift-intro" then + if self.messageTimer > 0 then + if input:wasPressed("a") or input:wasPressed("b") then + self.messageTimer = 0 + end + return + end + self:nextPage() + if not self.messagePages then self.phase = "ask-shift" end + return + end + -- OfferSwitch's YesNoBox: YES opens PickSwitchMonInBattle, NO (and B) falls -- straight through to the enemy's send-out (engine/battle/core.asm:3305-3310). if self.phase == "ask-shift" then @@ -2464,13 +2507,13 @@ end -- (engine/battle/core.asm:3298-3304, data/text/battle.asm:222-231). function BattleState:offerShiftSwitch(mon) self.shiftIndex = 1 - self.phase = "ask-shift" local trainer = (self.battle.trainer and self.battle.trainer.name) or "Foe" local player = (self.save and self.save.player and self.save.player.name) or "GOLD" - self.message = trainer .. " is about to use " .. self:name(mon) - .. ". Will " .. player .. " change POKéMON?" - self.messageTimer = MESSAGE_FRAMES + -- The `para` splits this in two (data/text/battle.asm:222-231): the incoming + -- mon is NAMED on its own page, and only the second carries the yes/no box. + self:showPages(Strings(TEXT_ENEMY_ABOUT_TO_USE, trainer, self:name(mon), player)) + self.phase = self.messagePages and "shift-intro" or "ask-shift" end function BattleState:answerUseNextMon(yes) diff --git a/tests/drivers/gold_bug1091_test.lua b/tests/drivers/gold_bug1091_test.lua new file mode 100644 index 00000000..8b225927 --- /dev/null +++ b/tests/drivers/gold_bug1091_test.lua @@ -0,0 +1,80 @@ +-- #1091: does GAME SPEED change the wild encounter rate per STEP? +-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bug1091_test.lua love . +local Encounter = require("src.battle.gen2.Encounter") +local Player = require("src.world.gen2.Player") +local Mon = require("src.battle.gen2.Mon") + +return function(game) + local rolls, hits, steps = 0, 0, 0 + + local realTriggers = Encounter.triggers + Encounter.triggers = function(rate, random) + rolls = rolls + 1 + if realTriggers(rate, random) then hits = hits + 1 end + return false + end + + local World = require("src.world.gen2.World") + local ticks = 0 + local realWorldStep = World.step + World.step = function(self) + ticks = ticks + 1 + return realWorldStep(self) + end + + local realUpdate = Player.update + Player.update = function(self) + local landed = realUpdate(self) + if landed then steps = steps + 1 end + return landed + end + + local function wait(n) for _ = 1, n do coroutine.yield() end end + local function clearDirs() + for _, d in ipairs({ "up", "down", "left", "right" }) do + game.input.state[d] = false + end + end + + wait(30) + local world = game.world + game.save.party = { Mon.new(game.data, "PIDGEY", 5) } + + local function measure(speed, yields) + world:setMap("ROUTE_29", 44, 12, "right") + wait(20) + world.wildCooldown = 0 + game.speedOverride = speed + rolls, hits, steps, ticks = 0, 0, 0, 0 + local dir = "right" + for i = 1, yields do + if i % 8 == 0 then dir = (dir == "right") and "left" or "right" end + clearDirs() + game.input.state[dir] = true + table.insert(game.input.pressQueue, dir) + coroutine.yield() + end + clearDirs() + game.speedOverride = 1 + wait(5) + return rolls, hits, steps, ticks + end + + local r1, h1, s1, t1 = measure(1, 900) + local r2, h2, s2, t2 = measure(200, 60) + print(("[driver] 1X frames=%d cells=%d rolls=%d hits=%d rolls/cell=%.3f") + :format(t1, s1, r1, h1, s1 > 0 and r1 / s1 or 0)) + print(("[driver] 200X frames=%d cells=%d rolls=%d hits=%d rolls/cell=%.3f") + :format(t2, s2, r2, h2, s2 > 0 and r2 / s2 or 0)) + + Encounter.triggers = realTriggers + Player.update = realUpdate + World.step = realWorldStep + local rate1 = s1 > 0 and r1 / s1 or 0 + local rate2 = s2 > 0 and r2 / s2 or 0 + assert(s1 > 20 and s2 > 20, "not enough walked cells to judge") + assert(math.abs(rate1 - rate2) < 0.05, + ("rolls per cell changed with GAME SPEED: %.3f vs %.3f"):format(rate1, rate2)) + print("[driver] PASS encounter rolls are one per walked cell at every speed") +end diff --git a/tests/drivers/gold_bug1097_test.lua b/tests/drivers/gold_bug1097_test.lua new file mode 100644 index 00000000..f1c7c123 --- /dev/null +++ b/tests/drivers/gold_bug1097_test.lua @@ -0,0 +1,79 @@ +-- #1097 (and the absorbed #1116): walking out of Dark Cave and pressing UP +-- puts the player inside the mountain on Route 31. +-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bug1097_test.lua love . +local Mon = require("src.battle.gen2.Mon") + +return function(game) + local function wait(n) for _ = 1, n do coroutine.yield() end end + local function clearDirs() + for _, d in ipairs({ "up", "down", "left", "right" }) do + game.input.state[d] = false + end + end + local function hold(dir, frames, trace) + local last + for _ = 1, frames do + clearDirs() + game.input.state[dir] = true + table.insert(game.input.pressQueue, dir) + coroutine.yield() + local w = game.world + local at = ("%s (%d,%d)"):format(w.map.id, w.player.cellX, w.player.cellY) + if trace and at ~= last then print(" " .. at); last = at end + end + clearDirs() + end + + wait(30) + local world = game.world + game.save.party = { Mon.new(game.data, "PIDGEY", 5) } + + -- Route31CheckMomCallCallback (maps/Route31.asm:14) fires on every NEWMAP + -- arrival until the errand is over; set its event so the walk is not spent + -- behind a phone call. tests/drivers/gold/flag_names.lua:1132. + if world.events then world.events:set(64, true) end + + -- maps/DarkCaveVioletEntrance.asm warp 1 is (3,15) -> ROUTE_31 warp 3. + world:setMap("DARK_CAVE_VIOLET_ENTRANCE", 3, 14, "down") + wait(20) + hold("down", 40) + -- the ROUTE_31 arrival runs Route31CheckMomCallCallback (maps/Route31.asm:14) + for _ = 1, 900 do + if not world:busy() then break end + coroutine.yield() + end + wait(20) + print(("[driver] out of the cave at %s (%d,%d)") + :format(world.map.id, world.player.cellX, world.player.cellY)) + assert(world.map.id == "ROUTE_31", "did not reach ROUTE_31") + + print("[driver] holding UP:") + hold("up", 120, true) + wait(30) + local m, x, y = world.map.id, world.player.cellX, world.player.cellY + print(("[driver] ended at %s (%d,%d)"):format(m, x, y)) + -- Anything above y=5 on Route 31 in that column is the inside of the + -- mountain; the honest outcomes are re-entering the cave or bumping. + local inside = (m == "ROUTE_31" and y < 5) + assert(not inside, ("walked into the mountain at (%d,%d)"):format(x, y)) + -- The other two Dark Cave mouths, same shape: maps/Route46.asm warp 3 is + -- (14,5) and maps/Route45.asm warp 1 is (2,5), both COLL_CAVE tiles with + -- ordinary FLOOR above them in the collision data. + for _, c in ipairs({ { "ROUTE_46", 14, 5 }, { "ROUTE_45", 2, 5 } }) do + local mapId, wx, wy = c[1], c[2], c[3] + world:setMap(mapId, wx, wy, "up") + wait(20) + hold("up", 90) + wait(20) + if world.map.id == mapId then + assert(world.player.cellY >= wy, + ("%s: walked above the cave mouth to (%d,%d)") + :format(mapId, world.player.cellX, world.player.cellY)) + end + print(("[driver] %s mouth ended at %s (%d,%d)"):format( + mapId, world.map.id, world.player.cellX, world.player.cellY)) + end + + print("[driver] PASS the cave mouth cannot be walked through") +end diff --git a/tests/drivers/gold_bug1104_test.lua b/tests/drivers/gold_bug1104_test.lua new file mode 100644 index 00000000..e9d814d5 --- /dev/null +++ b/tests/drivers/gold_bug1104_test.lua @@ -0,0 +1,78 @@ +-- #1104: the Ruins of Alph ladders warp somewhere else. +-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bug1104_test.lua love . +local Mon = require("src.battle.gen2.Mon") + +-- maps/RuinsOfAlphInnerChamber.asm:76, maps/RuinsOfAlphOutside.asm:126, +-- maps/RuinsOfAlphKabutoChamber.asm and its three siblings. +local CASES = { + { "RUINS_OF_ALPH_INNER_CHAMBER", 10, 13, "RUINS_OF_ALPH_OUTSIDE", 10, 13 }, + { "RUINS_OF_ALPH_OUTSIDE", 10, 13, "RUINS_OF_ALPH_INNER_CHAMBER", 10, 13 }, + { "RUINS_OF_ALPH_OUTSIDE", 2, 17, "RUINS_OF_ALPH_HO_OH_CHAMBER", 3, 9 }, + { "RUINS_OF_ALPH_OUTSIDE", 14, 7, "RUINS_OF_ALPH_KABUTO_CHAMBER", 3, 9 }, + { "RUINS_OF_ALPH_OUTSIDE", 2, 29, "RUINS_OF_ALPH_OMANYTE_CHAMBER", 3, 9 }, + { "RUINS_OF_ALPH_OUTSIDE", 16, 33, "RUINS_OF_ALPH_AERODACTYL_CHAMBER", 3, 9 }, + { "RUINS_OF_ALPH_OUTSIDE", 17, 11, "RUINS_OF_ALPH_RESEARCH_CENTER", 2, 7 }, + { "RUINS_OF_ALPH_KABUTO_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 14, 7, "down" }, + { "RUINS_OF_ALPH_HO_OH_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 2, 17, "down" }, + { "RUINS_OF_ALPH_OMANYTE_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 2, 29, "down" }, + { "RUINS_OF_ALPH_AERODACTYL_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 16, 33, "down" }, + { "RUINS_OF_ALPH_RESEARCH_CENTER", 2, 7, "RUINS_OF_ALPH_OUTSIDE", 17, 11, "down" }, +} + +return function(game) + local function wait(n) for _ = 1, n do coroutine.yield() end end + local function clearDirs() + for _, d in ipairs({ "up", "down", "left", "right" }) do + game.input.state[d] = false + end + end + -- Hold until the map changes, then let the load settle and STOP: holding + -- past the arrival would walk the player off the landing tile. + local function walkUntilWarp(dir, frames, fromMap) + for _ = 1, frames do + clearDirs() + game.input.state[dir] = true + table.insert(game.input.pressQueue, dir) + coroutine.yield() + if game.world.map and game.world.map.id ~= fromMap then + clearDirs() + -- The ARRIVAL cell is the one the warp table names. Standing on a + -- door/cave/staircase tile then forces one step DOWN off it + -- (engine/overworld/player_movement.asm:201-213), so the settled cell + -- is legitimately one row lower on an outdoor mouth. + local ax, ay = game.world.player.cellX, game.world.player.cellY + for _ = 1, 45 do coroutine.yield() end + return game.world.map.id, ax, ay, + game.world.player.cellX, game.world.player.cellY + end + end + clearDirs() + return game.world.map and game.world.map.id, nil, nil + end + + wait(30) + local world = game.world + game.save.party = { Mon.new(game.data, "PIDGEY", 5) } + + local bad = 0 + for _, c in ipairs(CASES) do + local src, wx, wy, wantMap, wantX, wantY = c[1], c[2], c[3], c[4], c[5], c[6] + -- default approach is from below, the way the report describes facing the + -- ladder from below; a chamber's exit sits on the bottom row instead + local dir = c[7] or "up" + local sy = (dir == "up") and wy + 1 or wy - 1 + world:setMap(src, wx, sy, dir) + wait(12) + local from = ("%s (%d,%d)"):format(src, world.player.cellX, world.player.cellY) + local m, ax, ay, sx, sy = walkUntilWarp(dir, 90, src) + local ok = (m == wantMap and ax == wantX and ay == wantY) + if not ok then bad = bad + 1 end + print(("[driver] %-12s %s -> %s (%s,%s) settled (%s,%s) want %s (%d,%d)") + :format(ok and "OK" or "MISMATCH", from, tostring(m), + tostring(ax), tostring(ay), tostring(sx), tostring(sy), + wantMap, wantX, wantY)) + end + print(("[driver] %s ruins warps: %d mismatches") + :format(bad == 0 and "PASS" or "FAIL", bad)) +end diff --git a/tests/drivers/gold_bug1155_test.lua b/tests/drivers/gold_bug1155_test.lua new file mode 100644 index 00000000..ba643887 --- /dev/null +++ b/tests/drivers/gold_bug1155_test.lua @@ -0,0 +1,100 @@ +-- #1155: "MAGNITUDE always rolls a 4". +-- +-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bug1155_test.lua love . +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +local ROLLS = 15 + +local function battleScreen(game) + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then return top end + U.wait(1) + end + error("battle screen never came up") +end + +local function drain(game, screen, frames) + for _ = 1, (frames or 400) do + if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then + return true + end + U.tap(game, "a") + U.wait(3) + end + return false +end + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1155" + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + local caster = Mon.new(game.data, "DUNSPARCE", 50) + local def = assert(game.data.moves.MAGNITUDE, "MAGNITUDE is not in moves.lua") + caster.moves = { { id = "MAGNITUDE", pp = def.pp, maxPp = def.pp } } + print("[driver] MAGNITUDE stored effect = " .. tostring(def.effect) + .. ", stored power = " .. tostring(def.power)) + game.save.party = { caster } + + local seen, damages, shown = {}, {}, 0 + local screen + for i = 1, ROLLS do + if not (screen and game.stack:top() == screen and not screen.battle.over) then + local dummy = Mon.new(game.data, "SNORLAX", 60) + dummy.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } } + assert(world:startBattle({ wild = dummy }), "startBattle failed") + screen = battleScreen(game) + drain(game, screen, 300) + end + local foe = screen.battle.enemy + local before = foe.hp + caster.moves[1].pp = def.pp + screen:submit({ kind = "move", move = "MAGNITUDE" }) + for _, event in ipairs(screen.queue) do + local number = event.text and event.text:match("^Magnitude (%d+)") + if number then + seen[number] = (seen[number] or 0) + 1 + if shown < 1 then + U.wait(1) + U.shot(game, out .. "/01-magnitude-text.png") + shown = 1 + end + end + end + drain(game, screen, 400) + local dealt = before - foe.hp + if dealt > 0 then damages[dealt] = true end + if i % 10 == 0 then U.wait(5) end + end + + local numbers = {} + for number, count in pairs(seen) do + numbers[#numbers + 1] = number .. "x" .. count + end + table.sort(numbers) + print("[driver] magnitudes over " .. ROLLS .. " uses: " + .. table.concat(numbers, " ")) + local distinctPowers = 0 + for _ in pairs(damages) do distinctPowers = distinctPowers + 1 end + + local failures = {} + local function check(ok, what) + print((ok and "[ok] " or "[FAIL] ") .. what) + if not ok then failures[#failures + 1] = what end + end + check(#numbers > 1, "MAGNITUDE rolls more than one number") + check(seen["4"] == nil or seen["4"] < ROLLS, + "and is not stuck on MAGNITUDE 4") + check(distinctPowers > 1, "the rolled power reaches the damage (" .. + distinctPowers .. " distinct damage figures)") + + if #failures > 0 then + error(#failures .. " magnitude checks failed") + end + print("[driver] #1155 clear; shot in " .. out) +end diff --git a/tests/drivers/gold_bug1157_test.lua b/tests/drivers/gold_bug1157_test.lua new file mode 100644 index 00000000..59895009 --- /dev/null +++ b/tests/drivers/gold_bug1157_test.lua @@ -0,0 +1,113 @@ +-- #1157: SONIC BOOM has to deal a flat 20, not a rolled 8-10. +-- +-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bug1157_test.lua love . + +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +local function battleScreen(game) + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then return top end + U.wait(1) + end + error("battle screen never came up") +end + +local function drain(game, screen, frames) + for _ = 1, (frames or 400) do + if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then + return true + end + U.tap(game, "a") + U.wait(3) + end + return false +end + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1157" + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + local failures = {} + local function check(ok, what) + print((ok and "[ok] " or "[FAIL] ") .. what) + if not ok then failures[#failures + 1] = what end + end + + -- The reporter's own matchup, three times over. + local caster = Mon.new(game.data, "MAGNEMITE", 20) + local boom = assert(game.data.moves.SONICBOOM, "SONICBOOM missing") + print("[driver] SONICBOOM effect = " .. tostring(boom.effect) + .. ", stored power = " .. tostring(boom.power)) + caster.moves = { { id = "SONICBOOM", pp = boom.pp, maxPp = boom.pp } } + game.save.party = { caster } + + -- SONIC BOOM is a 90% move, so a miss (0) is legal and simply does not + -- count: what matters is that every use that CONNECTS is 20. + local dealt, hits, wrong = {}, 0, 0 + for i = 1, 6 do + local rattata = Mon.new(game.data, "RATTATA", 11) + rattata.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } } + assert(world:startBattle({ wild = rattata }), "startBattle failed") + local screen = battleScreen(game) + drain(game, screen, 300) + local before = rattata.hp + caster.hp = caster.maxHp or caster.hp + caster.moves[1].pp = boom.pp + screen:submit({ kind = "move", move = "SONICBOOM" }) + drain(game, screen, 400) + dealt[i] = before - rattata.hp + if dealt[i] > 0 then + hits = hits + 1 + if dealt[i] ~= 20 then wrong = wrong + 1 end + end + if i == 1 then U.shot(game, out .. "/01-sonicboom.png") end + for _ = 1, 400 do + if not game.stack:top() or not game.stack:top().battle then break end + U.tap(game, "a") + U.wait(3) + end + U.wait(45) + end + print("[driver] SONIC BOOM dealt " .. table.concat(dealt, ", ") + .. " (0 is a miss)") + check(hits >= 3, "SONIC BOOM connected at least three times") + check(wrong == 0, "and every hit was exactly 20") + + -- The two siblings on the same command. + local dragon = Mon.new(game.data, "DRATINI", 25) + local rage = assert(game.data.moves.DRAGON_RAGE, "DRAGON_RAGE missing") + local toss = assert(game.data.moves.SEISMIC_TOSS, "SEISMIC_TOSS missing") + dragon.moves = { + { id = "DRAGON_RAGE", pp = rage.pp, maxPp = rage.pp }, + { id = "SEISMIC_TOSS", pp = toss.pp, maxPp = toss.pp }, + } + game.save.party = { dragon } + local target = Mon.new(game.data, "SNORLAX", 60) + target.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } } + assert(world:startBattle({ wild = target }), "startBattle failed") + local screen = battleScreen(game) + drain(game, screen, 300) + local before = target.hp + screen:submit({ kind = "move", move = "DRAGON_RAGE" }) + drain(game, screen, 400) + local rageDealt = before - target.hp + before = target.hp + screen:submit({ kind = "move", move = "SEISMIC_TOSS" }) + drain(game, screen, 400) + local tossDealt = before - target.hp + U.shot(game, out .. "/02-siblings.png") + print(("[driver] DRAGON RAGE %d, SEISMIC TOSS %d (user is L%d)") + :format(rageDealt, tossDealt, dragon.level)) + check(rageDealt == 40, "DRAGON RAGE deals exactly 40") + check(tossDealt == dragon.level, "SEISMIC TOSS deals the user's level") + + if #failures > 0 then + error(#failures .. " constant-damage checks failed") + end + print("[driver] #1157 fixed; shots in " .. out) +end diff --git a/tests/drivers/gold_bug1158_test.lua b/tests/drivers/gold_bug1158_test.lua new file mode 100644 index 00000000..b5b69ae3 --- /dev/null +++ b/tests/drivers/gold_bug1158_test.lua @@ -0,0 +1,96 @@ +-- #1158: you could not see the trainer's incoming mon before the switch offer. +-- +-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bug1158_test.lua love . + +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +local function battleScreen(game) + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then return top end + U.wait(1) + end + error("battle screen never came up") +end + +local function runToPhase(game, screen, phase, frames) + for _ = 1, (frames or 600) do + if screen.phase == phase then return true end + U.tap(game, "a") + U.wait(3) + end + return false +end + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1158" + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + local failures = {} + local function check(ok, what) + print((ok and "[ok] " or "[FAIL] ") .. what) + if not ok then failures[#failures + 1] = what end + end + + -- SHIFT is the only style that offers at all (CheckWhetherToAskSwitch). + game.options = game.options or {} + game.options.battleStyle = "SHIFT" + + local lead = Mon.new(game.data, "TYPHLOSION", 40) + lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local bench = Mon.new(game.data, "FERALIGATR", 40) + bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + game.save.party = { lead, bench } + + local foe1 = Mon.new(game.data, "PIDGEY", 5) + foe1.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local foe2 = Mon.new(game.data, "PIDGEOTTO", 20) + foe2.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + assert(world:startBattle({ trainer = { class = "FALKNER", name = "FALKNER", + party = { foe1, foe2 } } }), "trainer startBattle failed") + local screen = battleScreen(game) + runToPhase(game, screen, "menu", 400) + + foe1.hp = 1 + screen:submit({ kind = "move", move = "TACKLE" }) + + -- Page by page, collecting what the box actually showed. + local pages, sawBoxWithName = {}, false + local incoming = foe2.nickname or foe2.species or "PIDGEOTTO" + for _ = 1, 900 do + local message = screen.message + if message and pages[#pages] ~= message + and (screen.phase == "shift-intro" or screen.phase == "ask-shift") then + pages[#pages + 1] = message + if #pages == 1 then U.shot(game, out .. "/01-first-page.png") end + if message:find(incoming, 1, true) then + U.shot(game, out .. "/02-names-the-mon.png") + sawBoxWithName = true + end + end + if screen.phase == "ask-shift" and (screen.messageTimer or 0) <= 0 then + break + end + U.tap(game, "a") + U.wait(4) + end + U.shot(game, out .. "/03-yes-no.png") + + for i, page in ipairs(pages) do + print(("[driver] page %d: %s"):format(i, (page:gsub("\n", " / ")))) + end + check(sawBoxWithName, + "the incoming " .. incoming .. " is named in the box before the yes/no") + check(screen.phase == "ask-shift", + "and the prompt still ends on the YES/NO question") + check(#pages > 1, "the offer runs as pages, not one over-long line") + + if #failures > 0 then + error(#failures .. " send-out order checks failed") + end + print("[driver] #1158 fixed; shots in " .. out) +end diff --git a/tests/drivers/gold_bug1159_test.lua b/tests/drivers/gold_bug1159_test.lua new file mode 100644 index 00000000..86bec478 --- /dev/null +++ b/tests/drivers/gold_bug1159_test.lua @@ -0,0 +1,131 @@ +-- #1159 (Fly / Dig leave the user on the field) and #1160 (64x64 front pics). +-- +-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bug1159_test.lua love . + +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") +local BattleState = require("src.ui.gen2.BattleState") + +local function battleScreen(game) + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then return top end + U.wait(1) + end + error("battle screen never came up") +end + +local function drain(game, screen, frames) + for _ = 1, (frames or 400) do + if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then + return true + end + U.tap(game, "a") + U.wait(3) + end + return false +end + +-- One drawPic call with love.graphics.draw intercepted: how many blits it made +-- and where the first one landed. +local function probePic(screen, mon, back) + local G = love.graphics + local real = G.draw + local count, x, y = 0, nil, nil + G.draw = function(image, a, b, ...) + count = count + 1 + if count == 1 then + -- draw(image, x, y, ...) or draw(image, quad, x, y, ...) + if type(a) == "number" then x, y = a, b else x, y = b, (...) end + end + end + local ok, err = pcall(screen.drawPic, screen, mon, back) + G.draw = real + if not ok then error(err) end + return count, x, y +end + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1159" + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + local failures = {} + local function check(ok, what) + print((ok and "[ok] " or "[FAIL] ") .. what) + if not ok then failures[#failures + 1] = what end + end + + ------------------------------------------------------------------ #1159 + local digger = Mon.new(game.data, "SANDSHREW", 30) + local dig = assert(game.data.moves.DIG, "DIG missing from the Gold cache") + digger.moves = { { id = "DIG", pp = dig.pp, maxPp = dig.pp } } + game.save.party = { digger } + local target = Mon.new(game.data, "SNORLAX", 40) + target.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } } + assert(world:startBattle({ wild = target }), "startBattle failed") + local screen = battleScreen(game) + drain(game, screen, 300) + + local before = probePic(screen, screen.battle.player, true) + check(before > 0, "the back pic is on the field before DIG") + U.shot(game, out .. "/01-before-dig.png") + + screen:submit({ kind = "move", move = "DIG" }) + drain(game, screen, 600) + + check(BattleState.isVanished(screen.battle.player) == true, + "SUBSTATUS_UNDERGROUND is set after the charge turn") + local underground = probePic(screen, screen.battle.player, true) + print("[driver] back-pic draws while underground: " .. underground) + check(underground == 0, "the back pic is GONE for the charge turn") + U.shot(game, out .. "/02-underground.png") + + -- The enemy front pic is untouched by the player's own dig. + local foe = probePic(screen, screen.battle.enemy, false) + check(foe > 0, "the enemy front pic is unaffected") + + -- Turn two: CheckCharge clears the bit and AppearUserRaiseSub redraws it. + screen:submit({ kind = "move", move = "DIG" }) + drain(game, screen, 600) + check(BattleState.isVanished(screen.battle.player) == false, + "the substatus is cleared when the stored attack lands") + local after = probePic(screen, screen.battle.player, true) + print("[driver] back-pic draws after the attack: " .. after) + check(after > 0, "the back pic is back on the field") + U.shot(game, out .. "/03-resurfaced.png") + + ------------------------------------------------------------------ #1160 + -- A stubbed pic of each size through the same placement code. trueColor is + -- set so the probe never enters the GBC palette shader. + local realPic = screen.pic + local function place(size, back) + local data = love.image.newImageData(size, size) + local image = love.graphics.newImage(data) + screen.pic = function() return image, true, "mods/front" .. size .. ".png" end + local n, x, y = probePic(screen, screen.battle.enemy, back) + screen.pic = realPic + return n, x, y + end + + local _, x56, y56 = place(56, false) + print(("[driver] 56x56 front at (%s, %s)"):format(tostring(x56), tostring(y56))) + check(x56 == 96 and y56 == 0, "vanilla 56x56 front pic is unmoved at (96, 0)") + + local _, x40, y40 = place(40, false) + print(("[driver] 40x40 front at (%s, %s)"):format(tostring(x40), tostring(y40))) + check(x40 == 104 and y40 == 16, + "a small 40x40 front pic still stands on the box's ground line") + + local _, x64, y64 = place(64, false) + print(("[driver] 64x64 front at (%s, %s)"):format(tostring(x64), tostring(y64))) + check(x64 == 96 and y64 == 0, + "a 64x64 front pic pins to the box corner and overlaps the HUD") + + if #failures > 0 then + error(#failures .. " checks failed") + end + print("[driver] #1159 / #1160 fixed; shots in " .. out) +end diff --git a/tests/drivers/gold_bug1167_test.lua b/tests/drivers/gold_bug1167_test.lua new file mode 100644 index 00000000..1fb7cb6c --- /dev/null +++ b/tests/drivers/gold_bug1167_test.lua @@ -0,0 +1,82 @@ +-- #1167: the Ilex Forest hidden Ether and hidden Super Potion. +-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bug1167_test.lua love . +local Mon = require("src.battle.gen2.Mon") +local HiddenItems = require("src.world.gen2.HiddenItems") + +local CASES = { + { name = "ETHER", x = 27, y = 1, event = 136 }, + { name = "SUPER_POTION", x = 17, y = 7, event = 137 }, + { name = "FULL_HEAL", x = 9, y = 17, event = 138 }, +} + +local FACE = { + up = { 0, 1 }, down = { 0, -1 }, left = { 1, 0 }, right = { -1, 0 }, +} + +return function(game) + local function wait(n) for _ = 1, n do coroutine.yield() end end + local function tap(btn) + table.insert(game.input.pressQueue, btn) + game.input.state[btn] = true + coroutine.yield() + game.input.state[btn] = false + coroutine.yield() + end + + wait(30) + local world = game.world + game.save.party = { Mon.new(game.data, "PIDGEY", 5) } + world:setMap("ILEX_FOREST", 9, 18, "up") + wait(30) + assert(world.map.id == "ILEX_FOREST", "not in ILEX_FOREST") + + -- what the engine itself thinks it has for this map + local listed = HiddenItems.unfound(world.map.def, world.events) + print("[driver] engine hidden items on ILEX_FOREST:") + for _, row in ipairs(listed) do + print((" (%2d,%2d) item=%s flag=%d") + :format(row.x, row.y, tostring(row.item), row.event)) + end + + local function bagCount(name) + local n = 0 + for _, row in ipairs((game.save.bag and game.save.bag.items) or {}) do + if row.item == name or row.id == name then n = n + (row.count or 1) end + end + return n + end + + local bad = 0 + for _, c in ipairs(CASES) do + local got = false + for dir, d in pairs(FACE) do + local sx, sy = c.x + d[1], c.y + d[2] + if world.map:isWalkable(sx, sy) then + world:setMap("ILEX_FOREST", sx, sy, dir) + wait(20) + world.player.facing = dir + local before = bagCount(c.name) + tap("a") + wait(60) + for _ = 1, 240 do + if not world:busy() then break end + tap("a") + end + wait(20) + if world.events:get(c.event) or bagCount(c.name) > before then + got = true + print(("[driver] OK %-13s at (%2d,%2d) picked up facing %s") + :format(c.name, c.x, c.y, dir)) + break + end + end + end + if not got then + bad = bad + 1 + print(("[driver] MISSING %-13s at (%2d,%2d)"):format(c.name, c.x, c.y)) + end + end + print(("[driver] %s ilex hidden items: %d missing") + :format(bad == 0 and "PASS" or "FAIL", bad)) +end diff --git a/tests/drivers/gold_bug1208_test.lua b/tests/drivers/gold_bug1208_test.lua new file mode 100644 index 00000000..bdbcf6c7 --- /dev/null +++ b/tests/drivers/gold_bug1208_test.lua @@ -0,0 +1,48 @@ +-- #1208: the launcher's save-slot line read a Gold save with Gen 1 eyes +-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bug1208_test.lua love . + +local U = require("tests.drivers.util") +local SaveData = require("src.core.SaveData") +local GoldSave = require("src.core.gen2.Save") + +return function(game) + U.wait(45) + local save = game.save + assert(save and save.player, "gold save did not boot") + + save.player.badges = { ZEPHYR = true, HIVE = true, PLAIN = true, FOG = true, + STORM = true, MINERAL = true, GLACIER = true, RISING = true } + save.player.kantoBadges = { BOULDER = true, CASCADE = true } + save.pokedex = save.pokedex or {} + save.pokedex.caught = { [155] = true, [158] = true, [152] = true } + + local ok, err = game:writeSave() + if not ok then + U.log("FAIL gold writeSave:", tostring(err)) + else + local want = GoldSave.summary(save) + local active = SaveData.activeSlot("gold") + local row + for _, slot in ipairs(SaveData.listSlots("gold")) do + if slot.id == active then row = slot end + end + if not (row and row.meta) then + U.log("FAIL launcher has no slot row for", tostring(active)) + else + U.log(("launcher: %d badges - %s - %d caught"):format( + row.meta.badges, row.meta.timeText, row.meta.dexCount)) + U.log(("continue: %d badges - %d:%02d - %d caught"):format( + want.badges, want.hours, want.minutes, want.caught)) + if row.meta.badges == want.badges and row.meta.dexCount == want.caught then + U.log("PASS launcher summary matches CONTINUE for slot", tostring(active)) + else + U.log("FAIL launcher summary disagrees with CONTINUE") + end + end + end + + while true do + coroutine.yield() + end +end diff --git a/tests/engine/launcher_gold_touch_rows.lua b/tests/engine/launcher_gold_touch_rows.lua new file mode 100644 index 00000000..5b8e0e12 --- /dev/null +++ b/tests/engine/launcher_gold_touch_rows.lua @@ -0,0 +1,114 @@ +-- #1100 / #1135: the launcher gear offered TOUCH PAD, VIBRATION and the +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") + +-- The rows are mobile-gated the same way OptionsMenu's are, so the suite has +-- to look like a phone; POKEPORT_TOUCH=1 is the other way in. +local realGetOS = love.system.getOS +love.system.getOS = function() return "Android" end + +local LauncherSettings = require("src.import.LauncherSettings") +local TouchControls = require("src.core.TouchControls") + +local function labels(model) +local out = {} +for _, section in ipairs(model.sections) do + for _, row in ipairs(section.rows) do out[#out + 1] = row.label end +end +return out +end + +local function findRow(model, label) +for _, section in ipairs(model.sections) do + for _, row in ipairs(section.rows) do + if row.label == label then return row end + end +end +end + +local function has(model, label) +for _, l in ipairs(labels(model)) do + if l == label then return true end +end +return false +end + +local edited = 0 +local hooks = { editTouchControls = function() edited = edited + 1 end } + +local gold = LauncherSettings.open(hooks, "gold") +check(has(gold, "TOUCH PAD"), "Gold's gear offers TOUCH PAD") +check(has(gold, "VIBRATION"), "and VIBRATION") +check(has(gold, "TOUCH CONTROLS"), "and the layout editor") + +-- Every write has to land in the gold block: the flat keys beside it are +-- Red's, and Gold's boot never reads them (src/core/gen2/Save.lua:299). +-- loadOptions seeds the flat Gen 1 defaults, so the check is that the Gold +-- rows leave them exactly as they found them. +local flatPad = gold.opts.touchControls +local flatBuzz = gold.opts.haptics + +local pad = findRow(gold, "TOUCH PAD") +local before = pad.value() +pad.step(1) +check(pad.value() ~= before, "stepping TOUCH PAD flips it") +check(type(gold.opts.gold) == "table", "into the gold block") +eq(gold.opts.gold.touchControls.enabled, false, "which now carries enabled") +eq(gold.opts.touchControls, flatPad, "leaving the flat Gen 1 key alone") + +local buzz = findRow(gold, "VIBRATION") +local buzzBefore = buzz.value() +buzz.step(1) +check(buzz.value() ~= buzzBefore, "stepping VIBRATION moves the level") +eq(gold.opts.gold.haptics, TouchControls.normalizeHaptics(gold.opts.gold.haptics), + "VIBRATION stores a level the shared module knows") +eq(gold.opts.haptics, flatBuzz, "also without touching Red's") + +findRow(gold, "TOUCH CONTROLS").action() +eq(edited, 1, "the editor row reaches the host hook") + +-- The Gen 1 gear is untouched by the extraction: same three rows, still on +-- the flat table. +local red = LauncherSettings.open(hooks, "red") +check(has(red, "TOUCH PAD"), "Red still offers TOUCH PAD") +check(has(red, "VIBRATION"), "and VIBRATION") +check(has(red, "TOUCH CONTROLS"), "and the layout editor") +findRow(red, "TOUCH PAD").step(1) +eq(red.opts.touchControls.enabled, false, "writing to the flat key") +eq(red.opts.gold, nil, "with no gold block invented") +eq(findRow(red, "TOUCH PAD").value() ~= nil, true, "and reading it back") + +-- With no hook (the standalone save editor's shape) the editor row is gone +-- rather than dead, on both sides. +eq(has(LauncherSettings.open(nil, "gold"), "TOUCH CONTROLS"), false, + "no hook, no editor row on Gold") +eq(has(LauncherSettings.open(nil, "red"), "TOUCH CONTROLS"), false, + "nor on Red") +-- The Edit row hands the screen to the host, and the host has to know WHICH +-- game's block to write the dragged layout into: TouchControlsEditor persists +-- to opts.gold only when it was loaded with version = "gold". Source-shape +-- checks, the same way tests/engine/touch_controls_pad_cursor_test.lua pins +-- the handoff either side of this one. +local function read(path) + local f = assert(io.open(path, "r")) + local src = f:read("*a") + f:close() + return src +end + +local importerSrc = read("src/import/RomImporter.lua") +check(importerSrc:find("self.onEditTouchControls(version)", 1, true) ~= nil, + "the gear hands the launcher tab to the host") + +local mainSrc = read("main.lua") +local body = mainSrc:match("local function openTouchControlsEditor%((.-)%)") +eq(body, "version", "main.lua's opener takes that tab") +check(mainSrc:match("TouchEditor%.load%({%s*version = version") ~= nil, + "and loads the editor with it, so Gold's layout lands in the gold block") + +love.system.getOS = realGetOS + +T.finish("launcher gold touch rows") diff --git a/tests/engine/touch_controls_pad_cursor_test.lua b/tests/engine/touch_controls_pad_cursor_test.lua index ba4c3928..25fa9359 100644 --- a/tests/engine/touch_controls_pad_cursor_test.lua +++ b/tests/engine/touch_controls_pad_cursor_test.lua @@ -51,7 +51,9 @@ check(mainSrc:find("prepareOverlayHandoff", 1, true) ~= nil and mainSrc:find("openTouchControlsEditor", 1, true) ~= nil, "main.lua mentions prepareOverlayHandoff + touch editor") -- prepare must run inside openTouchControlsEditor, not only openEditor -local touchOpen = mainSrc:match("local function openTouchControlsEditor%(%)(.-)\nend") +-- The signature takes the launcher tab since #1100, so match any parameter +-- list rather than pinning the arity. +local touchOpen = mainSrc:match("local function openTouchControlsEditor%(.-%)(.-)\nend") check(touchOpen ~= nil, "openTouchControlsEditor body found") check(touchOpen:find("prepareOverlayHandoff", 1, true) ~= nil, "openTouchControlsEditor prepares overlay handoff like the save editor")