diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 1ffe28fa..68acd386 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -37,12 +37,16 @@ body: id: os attributes: label: Which build are you running + description: Official release targets. Pick Multiple platforms if you saw it on more than one. options: - macOS - Windows - Linux - Android - iOS + - Nintendo Switch + - Xbox + - Anbernic RG34XXSP - Multiple platforms validations: required: true diff --git a/data/scripts/flavor/pewter_city.lua b/data/scripts/flavor/pewter_city.lua index eb525b7d..137035f0 100644 --- a/data/scripts/flavor/pewter_city.lua +++ b/data/scripts/flavor/pewter_city.lua @@ -3,9 +3,8 @@ -- guide and SUPER_NERD2 garden nerd. -- -- The YOUNGSTER's gym escort (talk + east-exit onStep) lives in --- story5.lua so the lockstep RLE walk is not overwritten by this --- flavor merge. SUPER_NERD1's museum escort is not ported; only the --- YES/NO-branched flavor text is here. +-- story5.lua; SUPER_NERD1's museum escort (scripts/PewterCity.asm:47-113) +-- is below. local M = {} @@ -25,11 +24,138 @@ local function ask(game, s, cb) game.stack:push(TextBox.new(game, s, nil, { choice = cb })) end +-- RLEList_PewterMuseumGuy (engine/overworld/auto_movement.asm:199-204) +local museumGuySteps = { + "up", "up", "up", "up", "up", "up", + "left", "left", "left", "left", "left", "left", "left", "left", + "left", "left", "left", "left", "left", + "up", "up", "up", + "left", +} + +-- RLEList_PewterMuseumPlayer (engine/overworld/auto_movement.asm:192-197) +local museumPlayerRle = { + "NO", + "up", "up", "up", + "left", "left", "left", "left", "left", "left", "left", "left", + "left", "left", "left", "left", "left", + "up", "up", "up", "up", "up", "up", +} + +-- PewterMuseumGuyCoords (engine/events/pewter_guys.asm:58-75) +local museumPreambles = { + ["27,18"] = { "up", "up" }, + ["27,16"] = { "right", "left" }, + ["26,17"] = { "up", "right" }, + ["28,17"] = { "up", "left" }, +} + +-- PewterGuys (engine/events/pewter_guys.asm:1-49), same transform as +-- pewterEscort.playerPlan in story5.lua +local function museumPlan(x, y) + local pre = museumPreambles[x .. "," .. y] + if not pre then return nil end + local buf = {} + for i, d in ipairs(museumPlayerRle) do buf[i] = d end + buf[#buf] = pre[1] + for i = 2, #pre do buf[#buf + 1] = pre[i] end + local path = {} + for i = #buf, 1, -1 do path[#path + 1] = buf[i] end + local head = 0 + while path[head + 1] == "NO" do head = head + 1 end + local tail = #path + while tail > head and path[tail] == "NO" do tail = tail - 1 end + local steps = {} + for i = head + 1, tail do steps[#steps + 1] = path[i] end + return { steps = steps, guyHeadStart = math.floor(head / 8) } +end + +-- PewterCitySuperNerd1ShowsPlayerMuseumScript (scripts/PewterCity.asm:47-113) +local function museumEscortWalk(game, ow) + if ow.runner:isRunning() or #ow.scriptMoves > 0 then return false end + local plan = museumPlan(ow.player.cellX, ow.player.cellY) + if not plan then return false end + local Music = require("src.core.Music") + local t = text(game) + local guy = ow:npcByIndex(3) -- PEWTERCITY_SUPER_NERD1 + local head = plan.guyHeadStart + + -- SetSpritePosition2 + ShowObject back on his spawn (27,17), the same + -- snap walkHome does in story5.lua (scripts/PewterCity.asm:102-113) + local function walkOut() + if not guy then return end + local i = 0 + local function tick() + i = i + 1 + if i > 4 then + guy.cellX, guy.cellY = 27, 17 + guy.px, guy.py = 27 * 16, 17 * 16 + guy.moving = false + guy.targetX, guy.targetY = nil, nil + guy.facing = "down" + return + end + ow:scriptMove(guy, "down", 1, tick) + end + tick() + end + + -- SetSpritePosition1 pins him beside the museum door (map (17,12) minus + -- the +4 border offset = (13,8)), then MovementData_PewterMuseumGuyExit + local function afterWalk() + if guy then + guy.stepFrames = nil + guy.cellX, guy.cellY = 13, 8 + guy.px, guy.py = 13 * 16, 8 * 16 + guy.moving = false + guy.targetX, guy.targetY = nil, nil + guy.facing = "up" + end + Music.playMap(game.data, "PEWTER_CITY") + push(game, t._PewterCitySuperNerd1ItsRightHereText + or "It's right here!", walkOut) + end + + local function lockstep() + local i = 0 + local function tick() + i = i + 1 + local ps = plan.steps[i] + if not ps then + afterWalk() + return + end + local gs = museumGuySteps[head + i] + if guy and gs then ow:scriptMove(guy, gs, 1) end + ow:scriptMove(ow.player, ps, 1, tick) + end + tick() + end + + -- engine/overworld/movement.asm:737 (DoScriptedNPCMovement) + if guy then + guy.stepFrames = ow.player.stepFramesCur or ow.player.stepFrames + end + Music.play(game.data, "Music_MuseumGuy") + if guy and head > 0 then + local h = 0 + local function headTick() + h = h + 1 + if h > head then lockstep(); return end + ow:scriptMove(guy, museumGuySteps[h], 1, headTick) + end + headTick() + else + lockstep() + end + return true +end + M.PEWTER_CITY = { + museumEscort = { plan = museumPlan, guySteps = museumGuySteps }, talk = { - -- PewterCitySuperNerd1Text (scripts/PewterCity.asm): asks if you - -- checked out the museum; YES -> fossils comment, NO -> "you have - -- to go" (which in pokered also kicks off the escort script). + -- PewterCitySuperNerd1Text (scripts/PewterCity.asm:209-237): YES -> + -- fossils comment, NO -> "you have to go" and the museum escort TEXT_PEWTERCITY_SUPER_NERD1 = function(game, ow, npc, done) local t = text(game) ask(game, t._PewterCitySuperNerd1DidYouCheckOutMuseumText @@ -39,7 +165,10 @@ M.PEWTER_CITY = { or "Weren't those\nfossils from MT.\nMOON amazing?", done) else push(game, t._PewterCitySuperNerd1YouHaveToGoText - or "Really?\nYou absolutely\nhave to go!", done) + or "Really?\nYou absolutely\nhave to go!", function() + museumEscortWalk(game, ow) + if done then done() end + end) end end) end, diff --git a/data/scripts/oaks_lab.lua b/data/scripts/oaks_lab.lua index bb0b058f..039e519a 100644 --- a/data/scripts/oaks_lab.lua +++ b/data/scripts/oaks_lab.lua @@ -288,6 +288,7 @@ return { -- fanfare for the taunt/challenge exchange, same as the Yellow port -- (oaks_lab_yellow.lua); it was silently dropped here (#596). local rows = { + { "face_object", 1, "down" }, -- scripts/OaksLab.asm:347-351 { "face_player_dir", "up" }, { "stop_music" }, { "play_music", "Music_MeetRival" }, diff --git a/data/scripts/oaks_lab_yellow.lua b/data/scripts/oaks_lab_yellow.lua index 6c67a80f..729923d1 100644 --- a/data/scripts/oaks_lab_yellow.lua +++ b/data/scripts/oaks_lab_yellow.lua @@ -263,6 +263,7 @@ return { local rival = ow:npcByIndex(RIVAL) if not rival then return false end local rows = { + { "face_object", RIVAL, "down" }, -- pokeyellow scripts/OaksLab.asm:311-315 { "face_player_dir", "up" }, { "stop_music" }, { "play_music", "Music_MeetRival" }, diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 2941ac58..a4ad40ec 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -140,6 +140,10 @@ M.VIRIDIAN_CITY = { -- Daisy hands over the TOWN MAP once Oak's errand is under way -- (scripts/BluesHouse.asm BluesHouseDaisySittingText) M.BLUES_HOUSE = { + -- scripts/BluesHouse.asm:12-16 + onEnter = function(game, ow) + game.save.flags.EVENT_ENTERED_BLUES_HOUSE = true + end, talk = { TEXT_BLUESHOUSE_DAISY_SITTING = { { "face_player" }, diff --git a/data/scripts/story2.lua b/data/scripts/story2.lua index 08b3dee0..ffbfb585 100644 --- a/data/scripts/story2.lua +++ b/data/scripts/story2.lua @@ -87,6 +87,18 @@ end M.PALLET_TOWN = { talk = require("data.scripts.pallet_town").talk, escort = escort, + -- scripts/PalletTown.asm:133-144 + onEnter = function(game, ow) + local f = game.save.flags + if f.EVENT_GOT_TOWN_MAP and f.EVENT_ENTERED_BLUES_HOUSE + and not f.EVENT_DAISY_WALKING then + f.EVENT_DAISY_WALKING = true + local Commands = require("src.script.Commands") + local ctx = { save = game.save, game = game, overworld = ow } + Commands.hide_object(ctx, "BLUES_HOUSE", "BLUESHOUSE_DAISY1") + Commands.show_object(ctx, "BLUES_HOUSE", "BLUESHOUSE_DAISY2") + end + end, -- Red: stop at y==1 from (8,5). Yellow: stop at y==0 from (10,4), -- then a wild Pikachu battle before the lab escort (pokeyellow -- PalletTownPikachuBattleScript). diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index 4f0c507e..6e15987c 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -937,15 +937,10 @@ M.VERMILION_DOCK = { ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end) end puff(3, 15) - -- VermilionDock_EraseSSAnne deliberately leaves the blocks under the - -- player alone ("south of the player and won't be redrawn"), so skip - -- his own block: he must not spend the walk-out standing on water - local pbx = math.floor(ow.player.cellX / 2) - local pby = math.floor(ow.player.cellY / 2) + -- scripts/VermilionDock.asm:182-203 local rows = {} local function setBlock(bx, by, block) if bx < 1 or bx > 8 then return end - if bx == pbx and by == pby then return end rows[#rows + 1] = { "replace_block", bx, by, block } end rows[#rows + 1] = { "wait", 120 } diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 8e19da36..77d0e294 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -583,7 +583,8 @@ end local function stampOT(save, mon) save.player.id = save.player.id or math.random(0, 65535) mon.ot = mon.ot or save.player.name - mon.otId = mon.otId or save.player.id + -- engine/battle/experience.asm:69 + if not mon.traded then mon.otId = mon.otId or save.player.id end end BattleState.stampOT = stampOT @@ -5923,10 +5924,16 @@ function BattleState:drawTextArea() Font.drawCode(Font.BORDER.h, 32, 96) Font.drawCode(Font.BORDER.br, 80, 96) love.graphics.setColor(0, 0, 0, 1) - for i, mv in ipairs(self.player.curMoves) do - -- unknown ids (mod-injected moves) print raw instead of crashing - local def = self.data.moves[mv.id] - Font.draw(def and def.name or tostring(mv.id), 48, 96 + i * 8) + -- engine/battle/misc.asm:37 + for i = 1, 4 do + local mv = self.player.curMoves[i] + if mv then + -- unknown ids (mod-injected moves) print raw instead of crashing + local def = self.data.moves[mv.id] + Font.draw(def and def.name or tostring(mv.id), 48, 96 + i * 8) + else + Font.draw("-", 48, 96 + i * 8) + end end -- Swap cursor: SelectMenuItem parks the hollow arrow on the marked row -- (core.asm:2600-2607), then HandleMenuInput's PlaceMenuCursor writes the @@ -5953,13 +5960,16 @@ function BattleState:drawTextArea() end end elseif self.phase == "mimicSelect" then - -- Mimic's copy menu (MoveSelectionMenu .mimicmenu, core.asm: - -- 2506-2517): the enemy's move list in a 16x6 box at (0,7), names - -- single-spaced from (2,8), cursor at column 1 + -- Mimic's copy menu (MoveSelectionMenu .mimicmenu, core.asm:2506-2517): + -- 16x6 box at (0,7), names from (2,8), cursor at column 1 Font.drawBox(0, 7, 16, 6) love.graphics.setColor(0, 0, 0, 1) - for i, m in ipairs(self.mimicMoves) do - Font.draw(self.data.moves[m.id].name, 16, (7 + i) * 8) + -- engine/battle/misc.asm:37 + for i = 1, 4 do + local m = self.mimicMoves[i] + local def = m and self.data.moves[m.id] + Font.draw(m and (def and def.name or tostring(m.id)) or "-", + 16, (7 + i) * 8) end Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8) Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112) diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 2f0f5e60..ebe4f2d4 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -232,15 +232,15 @@ function EffectRegistry.runDamaging(battle, ctx, record) hitSfx = { sound = "Damage", pitch = 0x20 } end -- GetPlayerAnimationType / GetEnemyAnimationType (engine/battle/core.asm - -- :3159 / :5555): wAnimationType is 4 (blink the enemy pic) or 1 (shake - -- the screen vertically) for a damaging move with no added effect, and - -- 5 / 2 (a horizontal shake) as soon as the move HAS one -- which is why - -- Bubblebeam and Confusion shake instead of blinking (#354) + -- :3159 / :5555): 4 blinks the enemy pic, 1 shakes vertically, 5 / 2 once + -- the move has an added effect (#354) local added = move.effect ~= nil and move.effect ~= "NO_ADDITIONAL_EFFECT" + -- PlayApplyingAttackAnimation runs on both arms of the wOptions check + -- (engine/battle/animations.asm:424-437), so the blink is not gated (#1384) local hitFx = { sfx = hitSfx, animType = user.isPlayer and (added and 5 or 4) or (added and 2 or 1), - blink = battle:animationsOn() and target or nil } + blink = target } local totalDealt = 0 local landed, brokeSub = 0, false diff --git a/src/battle/gen2/AnimRunner.lua b/src/battle/gen2/AnimRunner.lua index 87bed2fc..302b8f5c 100644 --- a/src/battle/gen2/AnimRunner.lua +++ b/src/battle/gen2/AnimRunner.lua @@ -192,14 +192,8 @@ function Runner:loadGfx(names) end end --- BattleAnimCmd_BattlerGFX_1Row / _2Row. The battlers' pic tiles are --- APPENDED after whatever the script already loaded rather than replacing it, --- and they always land on the same two fixed tile ids. --- --- (pokegold's jumptable has these two labels the other way round from the --- macro names -- $d9 dispatches to BattleAnimCmd_BattlerGFX_1Row while --- anim_battlergfx_2row is $d9 -- so the names below follow the MACRO, which --- is what a script actually writes.) +-- engine/battle_anims/anim_commands.asm:755. The jumptable crosses the macro +-- names: $d9 (anim_battlergfx_2row) dispatches to _1Row (#1401) function Runner:loadBattlerGfx(rows) local tiles = rows == 2 and BATTLER_TILES.twoRow or BATTLER_TILES.oneRow local slot = 1 @@ -210,11 +204,11 @@ function Runner:loadBattlerGfx(rows) self.tileDict[slot] = { gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player } self.tileDict[slot + 1] = { gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy } self.loaded[#self.loaded + 1] = - { gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player, tiles = rows * 6, - battler = "player", rows = rows } - self.loaded[#self.loaded + 1] = - { gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy, tiles = rows * 7, + { gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player, tiles = rows * 7, battler = "enemy", rows = rows } + self.loaded[#self.loaded + 1] = + { gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy, tiles = rows * 6, + battler = "player", rows = rows } end -------------------------------------------------------------------------- diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 4f5014ad..05ea1c2e 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -80,6 +80,7 @@ Battle.SECONDARY_EFFECTS = { EFFECT_PARALYZE_HIT = "paralyze", EFFECT_SLEEP_HIT = "sleep", EFFECT_CONFUSE_HIT = "confuse", + EFFECT_SACRED_FIRE = "burn", -- data/moves/effects.asm:1696 } local function rand(random, n) @@ -1320,6 +1321,18 @@ function Battle:markMissed() if self.moveEvent then self.moveEvent.missed = true end end +-- engine/battle/effect_commands.asm:3615 +Battle.AI_FAIL_STATUSES = { + sleep = true, poison = true, toxic = true, paralyze = true, +} + +-- engine/battle/effect_commands.asm:3615 +function Battle:aiRandomFail(attacker, defender) + if self:sideOf(attacker) ~= "enemy" then return false end + if self:volatile(defender).lockOn then return false end + return rand(self.random, 256) < 64 +end + -- One attack, start to finish. function Battle:useMove(attacker, defender, moveId) local move = self:findMove(attacker, moveId) @@ -1478,10 +1491,10 @@ function Battle:useMove(attacker, defender, moveId) if charge and not charging then state.chargeMove = moveId state.vanished = charge.vanish or nil - -- DIG and FLY are the same effect in Gen 2 (both EFFECT_FLY), so the table - -- keyed by effect cannot tell them apart and DIG announced itself with - -- "flew up high!". BattleCommand_Fly picks the line off the MOVE, not the - -- effect: `cp DIG` and then the burrow text. + -- engine/battle/effect_commands.asm:5458 + if self.moveEvent then self.moveEvent.animParam = 1 end + -- BattleCommand_Charge picks the line off the MOVE, not the shared + -- EFFECT_FLY (`cp DIG`, effect_commands.asm:5464). local text = charge.text if moveId == "DIG" then text = "%s dug a hole!" end self:emit({ kind = "message", text = text:format(name) }) @@ -1811,20 +1824,20 @@ function Battle:useMove(attacker, defender, moveId) -- Defense Curl arms Rollout as well as raising Defense. if def.effect == "EFFECT_DEFENSE_CURL" then state.curled = true end - -- Stat changes: the primary ones always land, the *_HIT ones roll the - -- move's effect chance after a hit that connected. - -- - -- A refused primary change is a failure the cart detects BEFORE its anim - -- command: RaiseStat's `.cant_raise_stat` and StatDown's `.CantLower` / - -- `.Mist` all write wAttackMissed (effect_commands.asm:4191, :4380-4400), - -- and `statupanim` / `statdownanim` read it (:2022) from a slot AFTER - -- `attackup` / `attackdown` in the effect list (data/moves/effects.asm, - -- AttackUp). The *_HIT twins must NOT be marked: their `attackdown` runs - -- after `moveanim` (AttackDownHit), so the animation has already played. + -- A refused primary change writes wAttackMissed (effect_commands.asm:4191, + -- :4380-4400); the *_HIT twins animate first and must stay unmarked. local change = Effects.STAT_CHANGES[def.effect] if change then local target = change[3] == "self" and attacker or defender - if not self:changeStageAgainstMist(attacker, target, change[1], change[2]) + -- CheckMist first (effect_commands.asm:4290), then .ComputerMiss (:4318) + local misted = target ~= attacker and (change[2] or 0) < 0 + and self:volatile(target).mist + if not misted and change[3] == "foe" + and def.effect ~= "EFFECT_ACCURACY_DOWN_HIT" + and self:aiRandomFail(attacker, target) then + self:markMissed() + self:emit({ kind = "message", text = "But it failed!" }) + elseif not self:changeStageAgainstMist(attacker, target, change[1], change[2]) then self:markMissed() end @@ -1853,19 +1866,21 @@ function Battle:useMove(attacker, defender, moveId) local record = Battle.moveEffectRecordFor(self.data, def.effect) local status = record and record.kind == "primary" and record.status or nil if status and (def.power or 0) == 0 then - -- Every status command's already-statused / immune arm ends on - -- AnimateFailedMove (BattleCommand_Poison's `.failed`, - -- effect_commands.asm:3748-3750, and :6656-6659): LowerSub, MoveDelay, - -- RaiseSub and no LoadMoveAnim. AnimateCurrentMove only runs on the - -- success path (:3752), and the effect scripts carry no moveanim of their - -- own (data/moves/effects.asm, Toxic / DoPoison). The SECONDARY_EFFECTS - -- branch below is the opposite case: that move already hit and already - -- animated, so a refused secondary must leave the event unmarked. - if not self:applyStatus(defender, status, attacker) then self:markMissed() end + -- A refused primary status is a failed move (effect_commands.asm:3748, + -- :6656); a refused secondary already animated and stays unmarked (:3752). + if Battle.AI_FAIL_STATUSES[status] + and self:aiRandomFail(attacker, defender) then + self:markMissed() + self:emit({ kind = "message", text = "But it failed!" }) + elseif not self:applyStatus(defender, status, attacker) then + self:markMissed() + end else local secondary = record and record.kind == "secondary" and record.status or nil - if secondary and (defender.hp or 0) > 0 then + -- engine/battle/effect_commands.asm:6325 + if secondary and (defender.hp or 0) > 0 + and not self:safeguarded(defender) then local chance = def.effectChance or 0 if chance > 0 and rand(self.random, 100) < chance then self:applyStatus(defender, secondary, attacker) @@ -2425,6 +2440,15 @@ Battle.MOVE_EFFECTS.EFFECT_REFLECT = function(self, attacker) text = self:monName(attacker) .. "'s DEFENSE rose!" }) end +-- engine/battle/move_effects/safeguard.asm:1 +Battle.MOVE_EFFECTS.EFFECT_SAFEGUARD = function(self, attacker) + local side = self.screens[self:sideOf(attacker)] + if (side.safeguard or 0) > 0 then return fail(self) end + side.safeguard = Battle.SCREEN_TURNS + self:emit({ kind = "message", + text = self:monName(attacker) .. "'s covered by a veil!" }) +end + -- BattleCommand_Curse (engine/battle/move_effects/curse.asm): two moves in -- one body. A non-Ghost user trades a stage of Speed for one each of Attack -- and Defense, refused only when BOTH raises are already capped; a Ghost @@ -2910,6 +2934,11 @@ function Battle.statusPenaltyFor(data, mon, stat, value) return math.max(1, math.floor(value / math.max(1, penalty.div or 1))) end +-- engine/battle/effect_commands.asm:6325 +function Battle:safeguarded(mon) + return (self.screens[self:sideOf(mon)].safeguard or 0) > 0 +end + -- `source` is the battler that inflicted it, carried only so -- battle.status_inflicted can name it the way Gen 1's does. function Battle:applyStatus(mon, status, source) @@ -2917,7 +2946,14 @@ function Battle:applyStatus(mon, status, source) -- Confusion is SUBSTATUS_CONFUSED on the cart, not a status byte: it lives -- in the volatile beside the major status, so a confused mon can still be -- burned and a switch shakes the confusion off. - if status == "confuse" then return self:applyConfusion(mon) end + if status == "confuse" then return self:applyConfusion(mon, nil, source) end + -- engine/battle/effect_commands.asm:6338 + if source and self:sideOf(source) ~= self:sideOf(mon) + and self:safeguarded(mon) then + self:emit({ kind = "message", + text = self:monName(mon) .. " is protected by SAFEGUARD!" }) + return false + end -- One major status at a time. if mon.status then self:emit({ kind = "message", @@ -2953,8 +2989,15 @@ end -- as 256 turns. HELD_PREVENT_CONFUSE on the target blocks it outright. Battle.BERSERK_GENE_CONFUSE_TURNS = 256 -function Battle:applyConfusion(mon, turns) +function Battle:applyConfusion(mon, turns, source) if (mon.hp or 0) <= 0 then return false end + -- engine/battle/effect_commands.asm:6338 + if source and self:sideOf(source) ~= self:sideOf(mon) + and self:safeguarded(mon) then + self:emit({ kind = "message", + text = self:monName(mon) .. " is protected by SAFEGUARD!" }) + return false + end local state = self:volatile(mon) if (state.substitute or 0) > 0 then return false end local held = self:heldEffect(mon, "confuse") @@ -4434,14 +4477,20 @@ Battle.SCREEN_FALL_TEXT = { function Battle:tickScreens() for _, side in ipairs({ "player", "enemy" }) do local screens = self.screens[side] - for _, field in ipairs({ "lightScreen", "reflect" }) do + for _, field in ipairs({ "lightScreen", "reflect", "safeguard" }) do if (screens[field] or 0) > 0 then screens[field] = screens[field] - 1 if screens[field] <= 0 then screens[field] = nil - self:emit({ kind = "message", - text = Battle.SCREEN_SIDE_LABEL[side] - .. Battle.SCREEN_FALL_TEXT[field] }) + if field == "safeguard" then + -- engine/battle/core.asm:1527 + self:emit({ kind = "message", + text = self:monName(self[side]) .. "'s SAFEGUARD faded!" }) + else + self:emit({ kind = "message", + text = Battle.SCREEN_SIDE_LABEL[side] + .. Battle.SCREEN_FALL_TEXT[field] }) + end end end end diff --git a/src/battle/gen2/BgEffects.lua b/src/battle/gen2/BgEffects.lua index 6a90356b..f4b6f1a2 100644 --- a/src/battle/gen2/BgEffects.lua +++ b/src/battle/gen2/BgEffects.lua @@ -72,11 +72,11 @@ function Pool:reset() for row = 0, SCREEN_ROWS do self.lyBackup[row] = 0 end -- wBGP / wOBP0 / wOBP1, as DMG palette bytes. self.bgp, self.obp0, self.obp1 = NORMAL_PAL, NORMAL_PAL, NORMAL_PAL - -- Per-battler state the CGB paths write instead of touching wBGP: a DMG - -- shade byte the view remaps that battler's pic through, whether the pic is - -- hidden outright, and which of the six BG squares it is drawn at. + -- Per-battler state the CGB paths write instead of touching wBGP: shade + -- byte, hidden flag, lifted tile rows, and which BG square it is drawn at. self.monShade = { player = NORMAL_PAL, enemy = NORMAL_PAL } self.hidden = { player = false, enemy = false } + self.liftedRows = { player = nil, enemy = nil } self.picSize = { player = nil, enemy = nil } self.slide = { player = 0, enemy = 0 } -- wSurfWaveBGEffect: the $40-byte rolling wave Surf keeps beside the @@ -488,6 +488,7 @@ local function runPicResize(self, st, script) self.picSize[side] = step self.hidden[side] = false end + self.liftedRows[side] = nil incJt(st) elseif jt >= 1 and jt <= 2 then incJt(st) @@ -545,7 +546,7 @@ end -- The two battler-pic objects: the animation borrows the mon's own tiles as -- an OBJ so it can be moved without touching the tilemap. -local function battlerObj(self, st, objectPlayer, objectEnemy, clearRows) +local function battlerObj(self, st, objectPlayer, objectEnemy, rows) local jt = st.jt if jt == 0 then if self:flyDig(st) then @@ -562,25 +563,26 @@ local function battlerObj(self, st, objectPlayer, objectEnemy, clearRows) } elseif jt == 1 then incJt(st) - -- The rows the OBJ now covers are cleared out of the tilemap so the mon - -- is not drawn twice. - self.hidden[self:sideKey(st)] = clearRows + -- engine/battle_anims/bg_effects.asm:448-465: the rows the OBJ now covers + -- come out of the tilemap, and .five never puts them back. + self.liftedRows[self:sideKey(st)] = rows[self:sideKey(st)] elseif jt >= 2 and jt <= 4 then incJt(st) elseif jt == 5 then - self.hidden[self:sideKey(st)] = false endEffect(st) end end E.BATTLE_BG_EFFECT_BATTLEROBJ_1ROW = function(self, st) battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_1ROW", - "BATTLE_ANIM_OBJ_ENEMYFEET_1ROW", true) + "BATTLE_ANIM_OBJ_ENEMYFEET_1ROW", + { player = { 0, 1 }, enemy = { 6, 1 } }) end E.BATTLE_BG_EFFECT_BATTLEROBJ_2ROW = function(self, st) battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_2ROW", - "BATTLE_ANIM_OBJ_ENEMYFEET_2ROW", true) + "BATTLE_ANIM_OBJ_ENEMYFEET_2ROW", + { player = { 0, 2 }, enemy = { 5, 2 } }) end -- BGEffect_RapidCyclePals. On a CGB the palette is applied to ONE battler diff --git a/src/core/Music.lua b/src/core/Music.lua index 01ffc756..142bf78f 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -387,9 +387,9 @@ end -- overworld map theme; onBike/surfing override outdoor themes with the -- bike/surf songs and restore the map theme when they end -function Music.playMap(data, mapId, onBike, surfing, fade) - local song = data and data.audio and data.audio.mapSongs - and mapId and data.audio.mapSongs[mapId] or nil +function Music.playMap(data, mapId, onBike, surfing, fade, song) + song = song or (data and data.audio and data.audio.mapSongs + and mapId and data.audio.mapSongs[mapId]) or nil state.mapSong = song state.onBike = not not onBike state.surfing = not not surfing diff --git a/src/script/gen2/Specials.lua b/src/script/gen2/Specials.lua index 5b6c6451..3fc9b7dd 100644 --- a/src/script/gen2/Specials.lua +++ b/src/script/gen2/Specials.lua @@ -358,42 +358,33 @@ end -- is the three-way answer BugContestResults_DidNotLeaveMons branches on: -- BUGCONTEST_CAUGHT_MON 0, BUGCONTEST_BOXED_MON 1, BUGCONTEST_NO_CATCH 2 -- (constants/script_constants.asm). --- _CaughtAskNicknameText (data/text/common_2.asm:717). Not extracted: the --- routine that prints it is engine code and no script bytecode points at the --- string, so the extractor never reaches it. +-- _CaughtAskNicknameText (data/text/common_2.asm:717), engine-printed so +-- the extractor never reaches it. local CONTEST_NICKNAME_PROMPT = Strings.source("Give a nickname to\nthe {STRBUF} you\nreceived?") +-- GiveANickname_YesNo (engine/pokemon/caught_nickname.asm:123) +function Specials.askNickname(vm, mon) + nameMon(vm, mon.species) + showRawHeld(vm, Strings(CONTEST_NICKNAME_PROMPT)) + if coroutine.yield({ kind = "yesorno" }) then + -- InitNickname (engine/pokemon/move_mon.asm:1787) + local h = hooks(vm) + local name = h.renameMon and Specials.block(vm, function(done) + h.renameMon(mon, done, { blank = true }) + end) + -- _InitString's blank test (home/string.asm:6-30) + if name and name:gsub(" ", "") ~= "" then mon.nickname = name end + end +end + H.CheckPartyFullAfterContest = function(vm) local Breeding = require("src.core.gen2.Breeding") local result, mon = BugContest.collectCaughtMon(contestSave(vm), Breeding.PARTY_SIZE) - -- GiveANickname_YesNo sits on BOTH arms of CheckPartyFullAfterContest -- the - -- mon that joined the party and the one that went to the box -- and nowhere - -- else in the contest: BugContest_SetCaughtContestMon merely holds the catch - -- in wContestMon, so this is the only place the player is ever asked. - -- GetPokemonName runs first, which is what {STRBUF} reads. + -- GiveANickname_YesNo runs on both contest arms, party and box if mon and result ~= BugContest.NO_CATCH then - nameMon(vm, mon.species) - -- GiveANickname_YesNo (engine/pokemon/caught_nickname.asm:123) is - -- `PrintText / jp YesNoBox`, so the prompt goes up over the box this page - -- left standing. - showRawHeld(vm, Strings(CONTEST_NICKNAME_PROMPT)) - if coroutine.yield({ kind = "yesorno" }) then - -- `ld b, NAME_MON / callfar InitNickname`: the keyboard opens EMPTY on a - -- fresh catch (the Name Rater is the one that pre-fills), and InitNickname - -- copies the species name back over an empty entry -- so a cancelled - -- keyboard is the same as answering NO. - local h = hooks(vm) - local name = h.renameMon and Specials.block(vm, function(done) - h.renameMon(mon, done, { blank = true }) - end) - -- _InitString's own blank test (home/string.asm:6-30): "zero or more - -- spaces followed by a null". The keyboard's blank cells are real - -- typeable characters, so an all-space entry has to be discarded the - -- same way an empty one is, not stored as a name of spaces. - if name and name:gsub(" ", "") ~= "" then mon.nickname = name end - end + Specials.askNickname(vm, mon) end answer(vm, result) end @@ -1005,7 +996,7 @@ end -- check is transcribed here rather than left to the screen, because its two -- refusals are TEXT and the script has to see them before the machine opens: -- no coins at all, or no COIN_CASE to hold them. -local COIN_CASE = 0x47 -- constants/item_constants.asm +local COIN_CASE = 0x36 -- constants/item_constants.asm:62 -- _NoCoinsText / _NoCoinCaseText, data/text/common_1.asm. local NO_COINS_TEXT = "You have no coins." @@ -2409,7 +2400,9 @@ local STUB_ROWS = { { "WaitForOtherPlayerToExit", nil, "link cable: nobody to wait for" }, { "SetBitsForBattleRequest", nil, "link cable: no Gen 2 cable club" }, { "SetBitsForTimeCapsuleRequest", nil, "link cable: no Time Capsule" }, - { "CheckTimeCapsuleCompatibility", 2, "link cable: no Gen 1 partner" }, + -- maps/PokeCenter2F.asm:200-203: 2 is .MonMoveTooNew; 0 falls through to + -- WaitForLinkedFriend and lands on .FriendNotReady + { "CheckTimeCapsuleCompatibility", 0, "link cable: no Gen 1 partner" }, { "EnterTimeCapsule", nil, "link cable: no Time Capsule" }, { "TradeCenter", nil, "link cable: no trade room" }, { "Colosseum", nil, "link cable: no battle room" }, diff --git a/src/script/gen2/Vm.lua b/src/script/gen2/Vm.lua index b1138802..8b60c13b 100644 --- a/src/script/gen2/Vm.lua +++ b/src/script/gen2/Vm.lua @@ -544,7 +544,12 @@ local function runCmd(self, cmd, op) local level = cmd.level or (cmd.args and cmd.args[2]) or 5 local item = cmd.item or (cmd.args and cmd.args[3]) or 0 if self.givePokeFn then - self.givePokeFn(species, level, item) + local mon = self.givePokeFn(species, level, item) + -- engine/pokemon/move_mon.asm:1753-1757 + local trainer = cmd.trainer or (cmd.args and cmd.args[4]) or 0 + if mon and trainer == 0 then + Specials.askNickname(self, mon) + end end elseif op == "checkpoke" then -- Script_checkpoke: IsInArray over wPartySpecies. Party only, so a boxed diff --git a/src/ui/DexEntryMenu.lua b/src/ui/DexEntryMenu.lua index f5f1a09d..8114203c 100644 --- a/src/ui/DexEntryMenu.lua +++ b/src/ui/DexEntryMenu.lua @@ -14,6 +14,7 @@ local Font = require("src.render.Font") local Strings = require("src.core.Strings") +local Theme = require("src.ui.Theme") local DexEntryMenu = {} DexEntryMenu.__index = DexEntryMenu @@ -36,6 +37,62 @@ local function resolveArgs(speciesOrOpts) return speciesOrOpts, false end +local function ownedFor(game, def, forceOwned) + return forceOwned + or (game.save.pokedex and game.save.pokedex.owned[def.id]) or false +end + +-- home/text.asm:245 (), home/text.asm:204 () +local function descPages(game, def, forceOwned) + local e = def.dexEntry or {} + local owned = ownedFor(game, def, forceOwned) + local text = owned and e.text and game.data.text[e.text] or nil + if not text then return nil end + local pages = {} + for chunk in (text .. "\f"):gmatch("(.-)\f") do + local lines = {} + for line in (chunk:gsub("\v", "\n") .. "\n"):gmatch("(.-)\n") do + lines[#lines + 1] = line + end + while #lines > 0 and lines[#lines] == "" do table.remove(lines) end + if #lines > 0 then pages[#pages + 1] = lines end + end + if #pages == 0 then return nil end + local last = pages[#pages] + last[#last] = last[#last] .. "." + return pages +end + +-- engine/gfx/load_pokedex_tiles.asm: gfx/pokedex/pokedex.png, codes $60..$71 +local frameCache = {} +local function frameSheet(game) + local fx = game.data.field and game.data.field.overworldFx + local def = fx and fx.pokedexFrame + local path = def and def.path + if not path then return nil end + local hit = frameCache[path] + if hit ~= nil then return hit or nil end + local ok, img = pcall(love.graphics.newImage, path) + if not ok or not img then + frameCache[path] = false + return nil + end + local iw, ih = img:getDimensions() + local quads = {} + for i = 0, 17 do + quads[i] = love.graphics.newQuad((i % 3) * 8, + math.floor(i / 3) * 8, 8, 8, iw, ih) + end + frameCache[path] = { img = img, quads = quads } + return frameCache[path] +end + +-- engine/menus/pokedex.asm:601 +local DIVIDER = { + 0x68, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x6B, + 0x6B, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6A, +} + function DexEntryMenu.new(game, speciesOrOpts, onDone) local species, forceOwned = resolveArgs(speciesOrOpts) local self = setmetatable({ game = game, forceOwned = forceOwned, @@ -43,13 +100,14 @@ function DexEntryMenu.new(game, speciesOrOpts, onDone) self.def = game.data.pokemon[species] local path, trueColor = require("src.pokemon.Sprites").path( game.data, species, "front", { kind = "dex" }) - -- `path and pcall(...)` truncates to one value, so img was always nil and - -- every dex page drew without its pic (#307); the guard has to be a - -- statement for pcall's second return to survive. + -- pcall's second return has to survive the guard (#307) local ok, img = false, nil if path then ok, img = pcall(love.graphics.newImage, path) end self.sprite = ok and img or nil self.spriteTrueColor = self.sprite and trueColor or false + self.page = 1 + local pages = descPages(game, self.def, forceOwned) + self.pageCount = pages and #pages or 1 require("src.core.Sound").playCry(game.data, species) return self end @@ -57,6 +115,11 @@ end function DexEntryMenu:update(dt) local input = self.game.input if input:wasPressed("a") or input:wasPressed("b") then + -- home/text.asm:245 + if self.page < (self.pageCount or 1) then + self.page = self.page + 1 + return + end self.game.stack:pop() if self.onDone then self.onDone() end end @@ -64,64 +127,87 @@ end function DexEntryMenu:draw() DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned, - self.spriteTrueColor) + self.spriteTrueColor, self.page) 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). -function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor) +-- engine/menus/pokedex.asm:399 +function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page) + page = page or 1 love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) + local frame = frameSheet(game) + if frame then + local function tile(code, tx, ty) + love.graphics.draw(frame.img, frame.quads[code - 0x60], tx * 8, ty * 8) + end + -- engine/menus/pokedex.asm:418 + for tx = 1, 18 do + tile(0x64, tx, 0) + tile(0x6f, tx, 17) + end + for ty = 1, 16 do + tile(0x66, 0, ty) + tile(0x67, 19, ty) + end + tile(0x63, 0, 0) + tile(0x65, 19, 0) + tile(0x6c, 0, 17) + tile(0x6e, 19, 17) + -- engine/menus/pokedex.asm:445 + for tx = 0, 19 do + tile(DIVIDER[tx + 1], tx, 9) + end + end if sprite then - local y = math.max(0, 60 - sprite:getHeight()) - love.graphics.draw(sprite, 8, y) - -- a full-color pic has to sit out the SGB recolor, so mark its bounds - -- for the unshaded pass (#350). The printer path leaves trueColor nil: - -- it renders to its own PNG canvas, and a mark left behind there would - -- bleed into the next real frame. + -- engine/menus/pokedex.asm:503, home/pokemon.asm:96 (flipped) + local w, h = sprite:getDimensions() + local x = 8 + math.floor((8 - w / 8) / 2) * 8 + local y = 8 + (7 - h / 8) * 8 + love.graphics.draw(sprite, x + w, y, 0, -1, 1) + -- the unshaded pass needs the pic bounds (#350) if trueColor then - require("src.render.PaletteFX").markTrueColor(8, y, sprite:getDimensions()) + require("src.render.PaletteFX").markTrueColor(x, y, w, h) end end love.graphics.setColor(0, 0, 0, 1) - Font.draw(def.name, 72, 8) + -- engine/menus/pokedex.asm:454 + Font.draw(def.name, 72, 16) local e = def.dexEntry or {} - -- English R/B prints only the kind string (hlcoord 9,4 PlaceString). - -- PokeText ("#"/POKéMON) is an unreferenced JPN leftover in pokedex.asm; - -- appending " POKéMON" here clipped longer kinds ("LIZARD POKé"). - Font.draw(e.kind or "?", 72, 20) + -- engine/menus/pokedex.asm:468, kind string only (PokeText is unreferenced) + Font.draw(e.kind or "?", 72, 32) -- same number width as the list (constants.dexDigits), so a dex past 999 -- prints the extra digit everywhere at once local digits = (game.data.constants or {}).dexDigits or 3 - Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0), 72, 32) - local owned = forceOwned - or (game.save.pokedex and game.save.pokedex.owned[def.id]) - -- height/weight print only once owned, like the description - -- (pokedex.asm: "if the pokemon has not been owned, don't print the - -- height, weight, or description") + -- engine/menus/pokedex.asm:478 + Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0), + 16, 64) + local owned = ownedFor(game, def, forceOwned) + -- engine/menus/pokedex.asm:449, numbers only once owned if owned and e.heightFt then - -- feet/inches use the dex screen's ′/″ glyphs ("HT ?′??″" in - -- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via - -- engine/gfx/load_pokedex_tiles.asm) if e.heightM then - Font.draw((Strings("GR. %.1fm", e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 72, 44) - Font.draw((Strings("GEW. %.1fkg", e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 72, 54) + Font.draw((Strings("GR. %.1fm", e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 72, 48) + Font.draw((Strings("GEW. %.1fkg", e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 72, 64) else - Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 44) - Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54) + Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 48) + Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 64) end end - local text = owned and e.text and game.data.text[e.text] or nil - local y = 72 - if text then - for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do - if y > 132 then break end - Font.draw(line, 8, y) - y = y + 10 + local pages = descPages(game, def, forceOwned) + if pages then + -- engine/menus/pokedex.asm:568 + local lines = pages[page] or pages[#pages] + for i, line in ipairs(lines) do + Font.draw(line, 8, 72 + i * 16) + end + -- home/text.asm:245 + if page < #pages then + Font.drawCode(Theme.moreArrow, 144, 128) end else - Font.draw(Strings("Data unknown."), 8, y) + Font.draw(Strings("Data unknown."), 8, 88) end love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/LeaguePC.lua b/src/ui/LeaguePC.lua new file mode 100644 index 00000000..250cce8b --- /dev/null +++ b/src/ui/LeaguePC.lua @@ -0,0 +1,112 @@ +-- PKMN LEAGUE hall-of-fame viewer (engine/menus/league_pc.asm:1) + +local Font = require("src.render.Font") +local Strings = require("src.core.Strings") +local HallOfFame = require("src.ui.HallOfFame") + +local LeaguePC = {} +LeaguePC.__index = LeaguePC +LeaguePC.isOpaque = true + +-- constants/pokemon_data_constants.asm:65 +local CAPACITY = 50 + +-- SGB: SET_PAL_POKEMON_WHOLE_SCREEN per mon (engine/menus/league_pc.asm:95) +function LeaguePC:sgbPalettes(game) + local P = require("src.render.PaletteFX") + local mon = self:currentMon() + local c = mon and P.monPal(game.data, mon.species) + if c then return { P.whole(c) } end + return P.wholeNamed(game.data, "MEWMON") +end + +function LeaguePC.new(game, onDone) + local self = setmetatable({}, LeaguePC) + self.game = game + self.onDone = onDone + self.teams = (game.save and game.save.hallOfFame) or {} + self.teamIndex = math.max(1, #self.teams - CAPACITY + 1) + self.monIndex = 1 + self.sprites = {} + self.spriteTrueColor = {} + self:loadMon() + return self +end + +function LeaguePC:currentMon() + local team = self.teams[self.teamIndex] + return team and team[self.monIndex] or nil +end + +function LeaguePC:loadMon() + local mon = self:currentMon() + if not mon then return end + local species = mon.species + if self.sprites[species] == nil then + local path, trueColor = require("src.pokemon.Sprites").path( + self.game.data, species, "front", { kind = "hof" }) + local ok, img = false, nil + if path then ok, img = pcall(love.graphics.newImage, path) end + self.sprites[species] = ok and img or false + self.spriteTrueColor[species] = (ok and img and trueColor) or false + end + require("src.core.Sound").playCry(self.game.data, species) +end + +function LeaguePC:close() + self.game.stack:pop() + if self.onDone then self.onDone() end +end + +function LeaguePC:update(dt) + local input = self.game.input + if input:wasPressed("b") then + self:close() + return + end + if input:wasPressed("a") then + if not self:currentMon() then + self:close() + return + end + local team = self.teams[self.teamIndex] + if self.monIndex < #team then + self.monIndex = self.monIndex + 1 + elseif self.teamIndex < #self.teams then + self.teamIndex = self.teamIndex + 1 + self.monIndex = 1 + else + self:close() + return + end + self:loadMon() + end +end + +function LeaguePC:draw() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + local mon = self:currentMon() + if not mon then return end + local img = self.sprites[mon.species] + if img then + -- engine/menus/league_pc.asm:98 (hlcoord 12, 5) + local w, h = img:getDimensions() + local x = 96 + math.floor((8 - w / 8) / 2) * 8 + local y = 40 + (7 - h / 8) * 8 + love.graphics.draw(img, x, y) + if self.spriteTrueColor[mon.species] then + require("src.render.PaletteFX").markTrueColor(x, y, w, h) + end + end + -- engine/movie/hall_of_fame.asm:159 + HallOfFame.drawMonInfo(self, mon) + -- engine/menus/league_pc.asm:102 + Font.drawBox(0, 13, 20, 4) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(Strings("HALL OF FAME No"), 1 * 8, 15 * 8) + Font.draw(("%3d"):format(self.teamIndex), 16 * 8, 15 * 8) + love.graphics.setColor(1, 1, 1, 1) +end + +return LeaguePC diff --git a/src/ui/NamingScreen.lua b/src/ui/NamingScreen.lua index c35f4b9d..9c82cfb7 100644 --- a/src/ui/NamingScreen.lua +++ b/src/ui/NamingScreen.lua @@ -79,7 +79,16 @@ end function NamingScreen:enter() if self.presets and #self.presets > 0 then local Menu = require("src.ui.Menu") - local items = { { label = Strings("NEW NAME") } } + -- engine/movie/oak_speech/oak_speech2.asm:1 + self.choosing = true + self.isOpaque = false + local items = { { + label = Strings("NEW NAME"), + onSelect = function() + self.choosing = nil + self.isOpaque = nil + end, + } } for _, preset in ipairs(self.presets) do table.insert(items, { label = preset, @@ -193,6 +202,7 @@ function NamingScreen:update(dt) end function NamingScreen:draw() + if self.choosing then return end love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.setColor(0, 0, 0, 1) diff --git a/src/ui/TownMap.lua b/src/ui/TownMap.lua index ec6f305c..95780fed 100644 --- a/src/ui/TownMap.lua +++ b/src/ui/TownMap.lua @@ -220,6 +220,20 @@ function TownMap.new(game, opts) -- the player's current location (guard: overworld may not be running) 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 + local ok, img = pcall(love.graphics.newImage, red and red.image) + if ok and img then + self.playerSheet = img + self.playerQuad = love.graphics.newQuad(0, 0, 16, 16, + img:getDimensions()) + end + end self.sel = 1 -- LoadTownMap_Fly always opens with hl on wFlyLocationsList[0], the FIRST -- fly destination (PALLET_TOWN), never the player's current town (#795). @@ -345,18 +359,16 @@ function TownMap:draw() love.graphics.setColor(1, 1, 1, 1) return end - -- the player's current location blinks (slow phase). Paint it with a - -- palette-safe DARK shade (red 0), not red: this screen composites through - -- the TOWNMAP SGB shade-remap shader (PaletteFX.shader), which keys ONLY on - -- the red channel, and a red-0.75 dot lands in the c1 bucket = TOWNMAP - -- {165,214,255}, the exact light-blue used for the water and the town-square - -- fill, so the marker was drawn but recolored invisible (#152). Red 0 -> c3 - -- {25,16,16} = a solid dark "you are here" dot, visible on land and water. + -- engine/items/town_map.asm:347; fallback dot stays red 0 for PaletteFX (#152) if self.playerLoc and self.blink < 20 then local x, y = markerXY(self.playerLoc) - love.graphics.setColor(0, 0, 0, 1) - love.graphics.rectangle("fill", x + 2, y + 2, 4, 4) - love.graphics.setColor(1, 1, 1, 1) + if self.playerSheet then + love.graphics.draw(self.playerSheet, self.playerQuad, x - 4, y - 3) + else + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("fill", x + 2, y + 2, 4, 4) + love.graphics.setColor(1, 1, 1, 1) + end end -- blinking cursor on the selected location. markerXY is the 8x8 cell's -- top-left; the cursor asset is a 16x16 hollow frame centered on its own @@ -389,11 +401,16 @@ function TownMap:draw() drawSquare(loc) end if self.playerLoc and self.blink < 20 then - -- palette-safe dark, same red-channel shade-remap reason as the primary - -- grid path above (#152); stale-asset builds hit this fallback square - love.graphics.setColor(0, 0, 0, 1) - love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2, - self.playerLoc.y * 8 + 2, 4, 4) + -- engine/items/town_map.asm:347; fallback dot stays red 0 for PaletteFX (#152) + if self.playerSheet then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(self.playerSheet, self.playerQuad, + self.playerLoc.x * 8 - 4, self.playerLoc.y * 8 - 3) + else + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2, + self.playerLoc.y * 8 + 2, 4, 4) + end end if selected and self.blink % 16 < 10 then love.graphics.setColor(0, 0, 0, 1) diff --git a/src/ui/gen2/BattleAnimView.lua b/src/ui/gen2/BattleAnimView.lua index 38f848dd..13991c53 100644 --- a/src/ui/gen2/BattleAnimView.lua +++ b/src/ui/gen2/BattleAnimView.lua @@ -150,6 +150,10 @@ end -- animation (most of them) skips the canvas entirely. local function needsCanvas(runner) local bg = runner.bg + -- engine/battle_anims/bg_effects.asm:448-465: a lifted battler row stays + -- out of the BG until the next pic redraw, so those frames stay baked too. + local lifted = bg.liftedRows + if lifted and (lifted.player or lifted.enemy) then return true end if bg.scx ~= 0 or bg.scy ~= 0 then return true end if not bg.lcdc then return false end if bg.lyEnd <= bg.lyStart then return false end @@ -229,13 +233,12 @@ end -- times, and the grouping is by VALUE so a table that happens to repeat costs -- nothing extra. local function bgpBands(bg) + local base = bg.bgp or GbcPalette.BGP_IDENTITY local order, bands = {}, {} for row = 0, SCREEN_H - 1 do local inWindow = row >= bg.lyStart and row < bg.lyEnd - -- Outside the window the register still reads whatever wBGP holds, which - -- for every effect that aims hLCDCPointer at rBGP is the identity. - local byte = inWindow and (bg.lyBackup[row] or GbcPalette.BGP_IDENTITY) - or GbcPalette.BGP_IDENTITY + -- Outside the window the register still reads whatever wBGP holds. + local byte = inWindow and (bg.lyBackup[row] or base) or base local band = bands[byte] if not band then band = { byte = byte, rows = {} } @@ -244,24 +247,56 @@ local function bgpBands(bg) end band.rows[#band.rows + 1] = row end - -- Identity first so the fillBackground below it happens before any blit and - -- the common band is the one drawn from the first bake. + -- The base band first so the fillBackground below it happens before any blit + -- and the common band is the one drawn from the first bake. table.sort(order, function(a, b) if a.byte == b.byte then return false end - if a.byte == GbcPalette.BGP_IDENTITY then return true end - if b.byte == GbcPalette.BGP_IDENTITY then return false end + if a.byte == base then return true end + if b.byte == base then return false end return a.rows[1] < b.rows[1] end) return order end --- Runs `drawBg` (the battle panel) and then puts it on screen through the --- animation's BG registers. Returns without a canvas when nothing is --- displacing anything, which is the common case and costs nothing. -function BattleAnimView:present(runner, drawBg) +-- engine/battle_anims/anim_commands.asm:1293 BattleAnim_SetBGPals +function BattleAnimView:panelPalettes(battle) + local list = {} + local shades = {} + for index = 1, 4 do shades[index] = GbcPalette.color(nil, index) end + list[#list + 1] = shades + local function bracket(pair) + if not (pair and pair[1] and pair[2]) then return end + list[#list + 1] = { + { 255, 255, 255 }, + { pair[1][1], pair[1][2], pair[1][3] }, + { pair[2][1], pair[2][2], pair[2][3] }, + { 0, 0, 0 }, + } + end + for _, side in ipairs({ "player", "enemy" }) do + local mon = battle and battle[side] + local colors = mon + and Palettes.monColors(self.palettes, mon.species, mon.shiny) + if colors then list[#list + 1] = colors end + end + local hpBar = self.palettes and self.palettes.hpBar + if hpBar then + bracket(hpBar.green) + bracket(hpBar.yellow) + bracket(hpBar.red) + end + bracket(self.palettes and self.palettes.expBar) + return list +end + +-- Runs `drawBg` (the battle panel) and puts it on screen through the +-- animation's BG registers; skips the canvas when nothing needs one. +function BattleAnimView:present(runner, drawBg, battle) if not (love and love.graphics) then return end local bg = runner.bg - if not needsCanvas(runner) then + local invert = bg.bgp and bg.bgp ~= GbcPalette.BGP_IDENTITY + and bg.lcdc ~= "BGP" and GbcPalette.remapShader() ~= nil + if not invert and not needsCanvas(runner) then drawBg() return end @@ -292,9 +327,10 @@ function BattleAnimView:present(runner, drawBg) self:bake(drawBg, nil) - -- A shifted scanline exposes whatever the BG map holds beside the pic, which - -- outside the two pic boxes is the blank tile. Without this the exposed - -- strip is the canvas's own transparency and every shake shows a seam. + local remapped = invert + and GbcPalette.useRemap(self:panelPalettes(battle), bg.bgp) + -- A shifted scanline exposes the blank tile beside the pic boxes; without + -- this the exposed strip is the canvas's own transparency. self:fillBackground() G.setColor(1, 1, 1, 1) -- hSCX / hSCY move the whole background; the per-scanline overrides only @@ -314,6 +350,7 @@ function BattleAnimView:present(runner, drawBg) self:blitRow(row, dx, dy) end end + if remapped then GbcPalette.clear() end -- Shaderless boot: the panel is raw grayscale, so there are no palettes to -- permute and the entry's BRIGHTNESS is the only thing left to reproduce. if bg.lcdc == "BGP" then @@ -417,5 +454,6 @@ end BattleAnimView.SCREEN_W = SCREEN_W BattleAnimView.SCREEN_H = SCREEN_H +BattleAnimView.needsCanvas = needsCanvas return BattleAnimView diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 57f055e6..2d65eeda 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -49,6 +49,9 @@ BattleState.isOpaque = true -- the victory jingle can keep looping through the post-win prompts. local MESSAGE_FRAMES = 48 +-- engine/battle/effect_commands.asm:6661 +local MOVE_DELAY_FRAMES = 40 + -- home/hm_moves.asm:17-25 IsHMMove's .HMMoves. local HM_MOVES = { CUT = true, FLY = true, SURF = true, STRENGTH = true, FLASH = true, @@ -239,15 +242,8 @@ function BattleState.new(game, opts) self.menuIndex = 1 self.moveIndex = 1 self.picCache = {} - -- Which side's pic box the tilemap has been left EMPTY in. BattleBGEffect_ - -- ReturnMon's last row (what swallows a mon into a thrown ball) and - -- MonFaintedAnimation both clear the box and neither puts anything back: it - -- stays blank until something DRAWS a pic into it, which on the cart is only - -- ever a send-out (ShowSetEnemyMonAndSendOutAnimation / SendOutPlayerMon). - -- Without this latch the pic came back the instant the animation let go of - -- the screen, so a caught mon stood there through "Gotcha!" and a fainted one - -- popped back up for its own faint line. See stepAnim for why it is latched - -- at those two moments rather than off the runner's own last frame. + -- Which side's pic box stays EMPTY until a send-out redraws it: a catch + -- latches from stepAnim (data/moves/animations.asm:379), a faint from the slide. self.picHidden = { player = false, enemy = false } -- engine/battle/sliding_intro.asm: 72 frames of the two halves sliding in -- from opposite sides before the first message. @@ -665,7 +661,7 @@ function BattleState:drawPic(mon, back) -- the mon drawn at this frame. local scale = self:picScale(path, mon, back) if anim then - px = px + (anim.slide or 0) + if not self.liftedPass then px = px + (anim.slide or 0) end local resized = anim.size and PIC_RESIZE_TILES[anim.size] if resized then scale = scale * (resized / boxTiles) end end @@ -718,11 +714,57 @@ function BattleState:drawPic(mon, back) -- A mod-supplied pic that says it is already coloured is drawn as it is: -- pokemon.sprite's ctx.trueColor, the same flag Gen 1's Sprites.path hands -- back to its own draw site. - if colors and not trueColor and GbcPalette.available() then - GbcPalette.with(colors, body) - else - body() + local function paint() + if colors and not trueColor and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end end + local lifted = anim and anim.lifted + if not lifted then + paint() + return + end + -- engine/battle_anims/bg_effects.asm:448-465: the ClearBoxed band is off the BG. + local bandY = (back and BattleState.PLAYER_PIC_TILE_Y + or BattleState.ENEMY_PIC_TILE_Y) * 8 + lifted[1] * 8 + local bandH = lifted[2] * 8 + local psx, psy, psw, psh + if G.getScissor then psx, psy, psw, psh = G.getScissor() end + if self.liftedPass then + G.setScissor(0, bandY, 160, bandH) + paint() + else + if bandY > 0 then + G.setScissor(0, 0, 160, bandY) + paint() + end + local below = 144 - bandY - bandH + if below > 0 then + G.setScissor(0, bandY + bandH, 160, below) + paint() + end + end + if psx then G.setScissor(psx, psy, psw, psh) else G.setScissor() end +end + +-- MonsterSpriteGFX (gfx/sprites.asm:82): the facing-DOWN 16x16 frame for the +-- enemy's frontpic, facing-UP for the player's backpic. +function BattleState:substituteDoll(back) + if self.subDoll == nil then + local ok, image = pcall(Assets.image, "assets/generated/sprites/monster.png") + if ok and image then + local w, h = image:getDimensions() + self.subDoll = { image = image, + down = love.graphics.newQuad(0, 0, 16, 16, w, h), + up = love.graphics.newQuad(0, 16, 16, 16, w, h) } + else + self.subDoll = false + end + end + if not self.subDoll then return nil end + return self.subDoll.image, back and self.subDoll.up or self.subDoll.down end -- MonsterSpriteGFX (gfx/sprites.asm:82): the facing-DOWN 16x16 frame for the @@ -1050,10 +1092,10 @@ function BattleState:afterAnimFor(side) return "ANIM_PLAYER_DAMAGE" end -function BattleState:animForMove(moveId, side) +function BattleState:animForMove(moveId, side, param) local key = self.anims and self.anims.moves and self.anims.moves[moveId] local started = self:startAnim(key, { - turn = self:turnFor(side), animId = moveId, isMove = true, + turn = self:turnFor(side), animId = moveId, isMove = true, param = param, }) if started then -- BattleAnimRunScript (anim_commands.asm:55-72): after the move script @@ -1091,14 +1133,22 @@ function BattleState:animForId(idName, side, param) }) end +-- data/moves/animations.asm:379 +function BattleState:latchCaughtPic() + local anim = self.anim + if anim and anim.animId == "ANIM_THROW_POKE_BALL" + and self.ballThrow and self.ballThrow.caught then + self.picHidden.enemy = true + end +end + -- One logic frame of a running animation. B cuts it short, the way holding B -- pages a text box. function BattleState:stepAnim(input) if not self.anim then return end if input and (input:wasPressed("b") or input:wasPressed("start")) then - -- Cut short: the BG effects never reached their own last step, so the - -- tilemap is whatever they had got to and nothing is latched -- the - -- explicit latches (a catch) are the only ones that survive a skip. + -- Cut short: only the explicit latches (a caught mon) survive a skip. + self:latchCaughtPic() self.anim = nil -- Cart still reaches the after-anim arm after a move script ends; a skip -- of the move should not drop the hit shake that follows it. @@ -1106,6 +1156,7 @@ function BattleState:stepAnim(input) return self:endSendOutAnim() end if not self.anim:step() then + self:latchCaughtPic() -- pokegold data/moves/animations.asm .Click: anim_keepsprites means -- the OAM outlives the script, so keep the runner for drawing too. if not self.anim.keepSprites then self.anim = nil end @@ -1132,6 +1183,7 @@ function BattleState:animPicState(side) local bg = self.anim.bg return { hidden = bg.hidden[side], + lifted = bg.liftedRows and bg.liftedRows[side] or nil, size = bg.picSize[side], slide = bg.slide[side] or 0, shade = bg.monShade[side], @@ -1391,16 +1443,14 @@ function BattleState:advanceQueue() end if event.text then self.message = event.text - -- Lines that must not hold the queue for A/B: - -- move UsedMoveText -> text_end, then moveanim - -- level GrewToLevel is text_end (battle.asm:336-343), then the stats - -- box's WaitPressAorB is the real hold - -- experience keeps the wait: _ExpPointsText ends in `prompt` - -- (common_1.asm:1660-1665). update() runs stepExpAnim before that wait, - -- so the bar crawls under the line and A dismisses it before the battle - -- can end. + -- move/level lines do not hold for A/B (battle.asm:336-343); experience + -- keeps the wait (common_1.asm:1660-1665). if event.kind == "move" or event.kind == "level" then self.messageTimer = 0 + -- engine/battle/effect_commands.asm:1958-1961 + if event.kind == "move" and event.missed then + self.messageDelay = MOVE_DELAY_FRAMES + end else self.messageTimer = MESSAGE_FRAMES end @@ -1422,17 +1472,12 @@ function BattleState:advanceQueue() if event.waitSfx then self.waitSfx = event.sfx end end end - -- The move's own animation plays over its "used X!" line, which is where - -- PlayBattleAnim sits in the effect command list. Its after-anim (the hit - -- shake) is chained by animForMove / stepAnim, matching BattleAnimRunScript. - -- BattleCommand_MoveAnimNoSub (engine/battle/effect_commands.asm:1958) opens - -- with `ld a, [wAttackMissed] / and a / jp nz, BattleCommand_MoveDelay`: a - -- move that missed burns the delay and plays nothing. Battle:markMissed sets - -- event.missed on every wAttackMissed path. + -- engine/battle/effect_commands.asm:1958: a missed move burns the delay + -- and plays nothing; the after-anim chain is animForMove / stepAnim's. if event.kind == "move" and not event.missed then self.afterAnimPlayed = nil self.pendingAfterAnim = nil - if not self:animForMove(event.move, event.side) then + if not self:animForMove(event.move, event.side, event.animParam) then -- BATTLE SCENE off skips the move script but still runs wBattleAfterAnim -- (anim_commands.asm:55-72 .disabled fallthrough). local options = self.game and self.game.options @@ -1854,6 +1899,11 @@ function BattleState:update(_dt) -- (core.asm:6881-6888), with that line still on screen. Run the crawl -- before any PromptButton wait so the bar does not sit frozen until A. if self:stepExpAnim() then return end + -- engine/battle/effect_commands.asm:6661 + if (self.messageDelay or 0) > 0 then + self.messageDelay = self.messageDelay - 1 + return + end if self.messageTimer > 0 then if self.tutorial then -- PromptButton waits for the button; the tutorial cannot press it, so @@ -2463,14 +2513,6 @@ function BattleState:pushCaught(enemy, itemId) local save = self.save self.battle.over = true self.battle.outcome = "caught" - -- The mon is INSIDE the ball from here on. BattleAnim_ThrowPokeBall's caught - -- arm ends on the return-mon BG effect, which leaves the enemy pic box - -- cleared, and PokeBallEffect never draws a frontpic again -- there is no - -- send-out left in a battle that is already over. Latched here as well as - -- from the animation's own last step so that a throw the player skipped with - -- B (BattleAnimRunScript has no such skip; this port does) cannot put the - -- caught mon back on the field for the "Gotcha!" line. - self.picHidden.enemy = true -- PokeBallEffect's FRIEND_BALL arm: the caught mon's happiness is set to -- FRIEND_BALL_HAPPINESS (200) instead of the base 70. That is the ball's -- whole effect; its catch rate is a plain ball's. It applies on the box @@ -2932,6 +2974,7 @@ function BattleState:useItem(itemId) -- wThrownBallWobbleCount 0, then `predef PlayBattleAnim`. Everything -- pushed above is drained only once the ball has finished wobbling. self:startBallAnim(self:ballAnimParam(itemId), itemId) + if caught and not self.anim then self.picHidden.enemy = true end self.message = nil self.messageTimer = 0 self.phase = "resolving" @@ -3440,6 +3483,36 @@ function BattleState:drawScene() end end +-- data/battle_anims/objects.asm:390-397: the lifted band rides at ABSOLUTE_X, +-- outside the scanline blit, so the attacker's SCX never moves it. +function BattleState:drawLiftedRows() + local battle = self.battle + if not battle then return end + local enemy = self:animPicState("enemy") + local player = self:animPicState("player") + local enemyLift = enemy and enemy.lifted + local playerLift = player and player.lifted + if not (enemyLift or playerLift) then return end + local G = love.graphics + if not self.liftCanvas then + self.liftCanvas = G.newCanvas(160, 144) + self.liftCanvas:setFilter("nearest", "nearest") + end + local previous = G.getCanvas() + G.setCanvas(self.liftCanvas) + G.clear(0, 0, 0, 0) + G.push() + G.origin() + self.liftedPass = true + if enemyLift then self:drawPic(battle.enemy, false) end + if playerLift then self:drawPic(battle.player, true) end + self.liftedPass = nil + G.pop() + G.setCanvas(previous) + G.setColor(1, 1, 1, 1) + G.draw(self.liftCanvas, 0, 0) +end + function BattleState:drawSceneBody() local panel = function() self:drawPanel() end if self.animView and self.slideFrame < BattleAnimView.SLIDE_FRAMES then @@ -3460,7 +3533,8 @@ function BattleState:drawSceneBody() return end if self.anim and self.animView then - self.animView:present(self.anim, panel) + self.animView:present(self.anim, panel, self.battle) + self:drawLiftedRows() self.animView:drawObjects(self.anim, self.battle) return end diff --git a/src/ui/gen2/PokedexMenu.lua b/src/ui/gen2/PokedexMenu.lua index 53487347..480bd9ef 100644 --- a/src/ui/gen2/PokedexMenu.lua +++ b/src/ui/gen2/PokedexMenu.lua @@ -144,12 +144,7 @@ function PokedexMenu.new(game, opts) self.game = game self.save = opts.save or (game and game.save) local data = game and game.data or {} - -- Held, not just read into the fields below. CRY resolves its sample - -- through data.audio.cries and AREA resolves nests and landmark names - -- through data.maps / data.landmarks, and every one of those reads - -- `self.data` -- which nothing assigned, so `cries` folded to nil, playCry - -- returned before it reached Sound, and the button did nothing at all. - -- Taken by reference so a mod's merged cry or landmark is the one used. + -- engine/pokedex/pokedex.asm:447 self.data = data self.dex = opts.pokedex or data.gen2Pokedex self.pokemon = opts.pokemon or data.pokemon @@ -866,15 +861,6 @@ function PokedexMenu:drawArea() self:text(region == "kanto" and "KANTO" or "JOHTO", 1, 1) local G = love.graphics - local table_ = self.data and self.data.landmarks - local byIndex = self.landmarkByIndex - if not byIndex then - byIndex = {} - for _, entry in pairs((table_ and table_.landmarks) or {}) do - if entry and entry.index then byIndex[entry.index] = entry end - end - self.landmarkByIndex = byIndex - end if #nests == 0 then -- A species with no grass, water or roamer entry in this region. The cart @@ -883,11 +869,11 @@ function PokedexMenu:drawArea() return end - -- Blinking markers, the way the cart flashes its OBJs. + -- engine/pokegear/pokegear.asm:2427 local on = ((self.areaBlink or 0) % 32) < 20 if cells and on then for _, index in ipairs(nests) do - local mark = byIndex[index] + local mark = Nests.landmark(self.data, index) if mark and mark.x and mark.y then G.setColor(0, 0, 0, 1) G.rectangle("fill", mark.x - 2, mark.y - 2, 5, 5) @@ -900,7 +886,7 @@ function PokedexMenu:drawArea() -- Name the first one in words as well as on the map: the flashing dot is -- unreadable at this size on a modern display, and the landmark name is what -- a player actually wants off this screen. - local first = byIndex[nests[1]] + local first = Nests.landmark(self.data, nests[1]) if first and first.name then local name = tostring(first.name):gsub("\n", " ") self:text(name, 1, 16) diff --git a/src/world/NPC.lua b/src/world/NPC.lua index 14b48378..0c926faf 100644 --- a/src/world/NPC.lua +++ b/src/world/NPC.lua @@ -33,6 +33,7 @@ function NPC.new(data, mapId, objDef) self.facing = FACING_FROM_RANGE[objDef.range] or "down" self.moving = false self.progress = 0 + self.animClock = 0 self.stepFlip = false self.frozen = false -- scripts freeze NPCs while talking self.wanders = objDef.movement == "WALK" @@ -52,31 +53,15 @@ function NPC:facePlayer(player) end function NPC:update(map, entities) - -- An NPC tile is 32 frames, half the player's rate: TryWalking loads - -- WALKANIMATIONCOUNTER with $10 and UpdateSpriteInWalkingAnimation adds - -- the 1px step vector once per call (engine/overworld/movement.asm), but - -- UpdateSprites runs once per OverworldLoop pass and every pass opens - -- with two DelayFrame calls (home/overworld.asm) -- so those 16 ticks - -- cost 32 frames for one 16px cell, against AdvancePlayerSprite's 8 - -- ticks of 2px. That halving is why pokeyellow's NormalPikachuFollow - -- needs TryDoubleAddPikachuStepVectorToScreenPixelCoords to keep up. - -- - -- self.stepFrames overrides the shared walk for an object whose - -- step has to stay in phase with something else: Yellow's follower - -- Pikachu takes the player's own step length, halved while it is more - -- than a cell behind (FastPikachuFollow, engine/pikachu/ - -- pikachu_follow.asm). self.hopStep is the same file's $5-$8 hop - -- command: two cells of travel inside one step's frames - -- (DoubleAddPikachuStepVectorToScreenPixelCoords), which is why the - -- pixel span doubles while the frame count does not. Nothing else sets - -- either field, so every other object keeps the constant (#410, #409). + -- engine/overworld/movement.asm:301, 32 frames per NPC cell; stepFrames is + -- the follower's own step length (#410, #409). local stepLen = self.stepFrames or STEP_FRAMES local span = self.hopStep and 2 or 1 if self.moving then self.progress = self.progress + 1 - -- NPC_CHANGE_FACING: animate the walk cycle in place, no translation - -- (movement.asm ChangeFacingDirection zeroes the delta); px/py stay - -- pinned to the current cell while walkPhase() cycles. + self.animClock = (self.animClock or 0) + 1 + -- NPC_CHANGE_FACING (movement.asm ChangeFacingDirection): walk cycle in + -- place, no translation. if self.marching then if self.progress >= stepLen then self.progress = 0 @@ -122,18 +107,20 @@ end function NPC:walkPhase() if not self.moving then return 0 end - local stepLen = self.stepFrames or STEP_FRAMES - local p = self.progress % stepLen - return (p >= stepLen / 4 and p < stepLen * 3 / 4) and 1 or 0 + -- engine/overworld/movement.asm:301 + local p = (self.animClock or 0) % 16 + return (p >= 4 and p < 12) and 1 or 0 end --- Same contract as Player:pose -- the sheet, position, facing and step --- phase this frame renders to -- so a render pipeline can pose an NPC --- without caring which kind of entity it is. An NPC never hops, so the --- trailing hop flag is always false. +-- Same contract as Player:pose; an NPC never hops, so the trailing hop +-- flag is always false. function NPC:pose() + local flip = self.stepFlip + if self.moving then + flip = math.floor((self.animClock or 0) / 16) % 2 == 1 + end return self.sprite, self.px, self.py, self.facing, - self:walkPhase(), self.stepFlip, false + self:walkPhase(), flip, false end function NPC:draw(camX, camY) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index be17de27..6037233a 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -3090,9 +3090,12 @@ function OverworldState:finishNurseHeal(bye, onDone, npc) end)) end if not npc then farewell() return end - npc.frameOverride = 3 + -- engine/events/pokecenter.asm:36-39; Yellow's walk-down pose when the + -- sheet has it (pokeyellow engine/events/pokecenter.asm:82-88) + local yellow = GameVersion.isYellow() + npc.frameOverride = (yellow and npc.sprite.frames[3]) and 3 or 1 -- bubble = false is the silent world hold, this port's DelayFrames - self.emote = { npc = npc, frames = 20, bubble = false, onDone = function() + self.emote = { npc = npc, frames = yellow and 40 or 20, bubble = false, onDone = function() npc.frameOverride = nil npc:facePlayer(self.player) farewell() diff --git a/src/world/gen2/MapPreview.lua b/src/world/gen2/MapPreview.lua index 27847d45..d02411ed 100644 --- a/src/world/gen2/MapPreview.lua +++ b/src/world/gen2/MapPreview.lua @@ -6,6 +6,7 @@ local Assets = require("src.render.Assets") local BorderFill = require("src.world.gen2.BorderFill") local GbcPalette = require("src.render.GbcPalette") local Palettes = require("src.world.gen2.Palettes") +local PixelCanvas = require("src.render.PixelCanvas") local MapPreview = {} @@ -96,9 +97,8 @@ function MapPreview.bake(baker, map, daytime) local blocks = tileset.blocks local tilesPerRow = tileset.tilesPerRow or 16 local pw, ph = map.width * 32, map.height * 32 - local okCanvas, canvas = pcall(love.graphics.newCanvas, pw, ph) + local okCanvas, canvas = pcall(PixelCanvas.new, pw, ph, "nearest") if not okCanvas or not canvas then return nil end - if canvas.setFilter then canvas:setFilter("nearest", "nearest") end local quads = {} local function quadFor(tile) local q = quads[tile] diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index fd1fc787..a62f677f 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -47,6 +47,7 @@ local NPC = require("src.world.gen2.Npc") local Party = require("src.pokemon.Party") local Permissions = require("src.world.gen2.Permissions") local Pipelines = require("src.render.Pipelines") +local PixelCanvas = require("src.render.PixelCanvas") local Player = require("src.world.gen2.Player") local Pokerus = require("src.core.gen2.Pokerus") local Roamers = require("src.core.gen2.Roamers") @@ -1074,11 +1075,12 @@ function World:load() save.pokedex = save.pokedex or { seen = {}, caught = {} } save.pokedex.seen[mon.species] = true save.pokedex.caught[mon.species] = true - -- AddPartyMon's `.registerunowndex` runs on the same path, so a gifted - -- Unown lands in the form list too (nothing in Gold gives one, but the - -- cart's check is on the species, not on where it came from). + -- AddPartyMon's `.registerunowndex` runs on the same path, so a + -- gifted Unown lands in the form list too (move_mon.asm:347). Unown.registerCatch(save, mon) end + -- engine/pokemon/move_mon.asm:1632-1645 + return mon end, giveItem = function(itemIndex, qty) local data = self.game and self.game.data @@ -2402,12 +2404,43 @@ function World:rollWild() return { species = def.index, level = roll.level } end +-- GetMapMusic (home/map.asm:2550) +function World.mapMusicLabel(audio, musicByte, rocketsMahogany, rocketsRadioTower) + if type(musicByte) ~= "number" then return nil end + local MUSIC_MAHOGANY_MART = 100 -- constants/music_constants.asm:100 + local RADIO_TOWER_MUSIC = 0x80 -- constants/music_constants.asm:109 + local order = audio and audio.musicOrder + local songs = audio and audio.songs + local label + if musicByte == MUSIC_MAHOGANY_MART then + label = rocketsMahogany and "Music_RocketHideout" or "Music_CherrygroveCity" + elseif musicByte >= RADIO_TOWER_MUSIC then + label = rocketsRadioTower and "Music_RocketTheme" + or (order and order[(musicByte - RADIO_TOWER_MUSIC) + 1]) + else + return nil + end + if label and label ~= "Music_Nothing" and songs and songs[label] then + return label + end + return nil +end + +function World:mapMusicSong(mapId) + local audio = self.game and self.game.data and self.game.data.audio + local def = self.maps and self.maps[mapId] + -- ENGINE_ROCKETS_IN_MAHOGANY / _RADIO_TOWER (data/events/engine_flags.asm:40,:36) + return World.mapMusicLabel(audio, def and def.music, + self:engineFlag(22), self:engineFlag(18)) +end + function World:playMapMusic() local data = self.game and self.game.data if data and data.audio and data.audio.runtime and self.map then -- SpecialMapMusic (home/audio.asm:397) Music.playMap(data, self.map.id, nil, - FieldMoves.isSurfing(self.playerState)) + FieldMoves.isSurfing(self.playerState), nil, + self:mapMusicSong(self.map.id)) end end @@ -3255,7 +3288,8 @@ function World:surfStartStep(mon) if audio and audio.runtime and self.map then -- SpecialMapMusic (home/audio.asm:397) Music.playMap(self.game.data, self.map.id, nil, - FieldMoves.isSurfing(self.playerState)) + FieldMoves.isSurfing(self.playerState), nil, + self:mapMusicSong(self.map.id)) end if p.scriptStep then p:scriptStep(p.facing) end self.fieldMove = { phase = "step" } @@ -3565,7 +3599,7 @@ function World:rareWildMon() local entry = self.encounters and self.encounters.grass and map and self.encounters.grass[map.id] if not entry or not entry.slots then return nil end - local key = (self.daytime == "DARK") and "NITE" or (self.daytime or "DAY") + local key = self.tod or "DAY" local slots = entry.slots[key] or entry.slots.DAY if not slots then return nil end local rare = slots[4 + math.random(3)] @@ -3958,7 +3992,7 @@ function World:rollEncounter(kind, terrain, tables, vanilla) -- Same guard World:rockRandom uses: a headless suite has no love global. rng = (love and love.math and love.math.random) or math.random, kind = kind, - daytime = self.daytime, + daytime = self.tod, environment = map and map.def and map.def.environment, tables = tables, data = self.game and self.game.data, @@ -4021,7 +4055,8 @@ function World:tryWildEncounter() if onWater then rate = Encounter.waterRate(tables, map.id) else - rate = Encounter.grassRate(tables, map.id, self.daytime) + -- engine/overworld/wildmons.asm:283 + rate = Encounter.grassRate(tables, map.id, self.tod) end -- ApplyMusicEffectOnEncounterRate runs first (wildmons.asm:213-215). rate = World.musicEncounterRate(rate, Music.mapSong()) @@ -4315,6 +4350,14 @@ function World:rollFishing(rod) -- the flag and nothing about the map changes. Roamers.Swarm.fishing is the -- same store CheckSwarmFlag clears when the swarm expires. local swarm = Roamers.Swarm.fishing(game.save) + -- engine/events/fish.asm:24-30 + local groupRow = self.encounters.fishGroups + and self.encounters.fishGroups[ + Encounter.fishGroupFor(self.encounters, group, swarm)] + if groupRow and groupRow.chance + and not Encounter.triggers(groupRow.chance, nil) then + return "nibble" + end local roll if Runtime.wantsHook("encounter.fishing") then -- Gen 1's three arguments, in Gen 1's order: the rod, the map, and the @@ -5100,7 +5143,7 @@ function World:sweetScentEncounter() local tables = self:wildTables() local onWater = FieldMoves.encounterTable(collision) == "water" local rate = onWater and Encounter.waterRate(tables, map.id) - or Encounter.grassRate(tables, map.id, self.daytime) + or Encounter.grassRate(tables, map.id, self.tod) if not (rate and rate > 0) then return false end -- CheckEncounterRoamMon, the first thing ChooseWildEncounter itself does: -- a beast REPLACES the map's own slot rather than adding to it. @@ -5374,7 +5417,8 @@ function World:runSurf(result) if audio and audio.runtime and self.map then -- SpecialMapMusic (home/audio.asm:397) Music.playMap(self.game.data, self.map.id, nil, - FieldMoves.isSurfing(self.playerState)) + FieldMoves.isSurfing(self.playerState), nil, + self:mapMusicSong(self.map.id)) end if self.player and self.player.scriptStep then self.player:scriptStep(self.player.facing) @@ -5886,6 +5930,8 @@ function World:startBattle(opts, onDone) -- World:startCatchTutorial sets it. tutorial = opts.tutorial, onDone = function(outcome) + -- WildBattleScript's reloadmapafterbattle (engine/overworld/events.asm:1158-1162) + self.wildCooldown = 5 self.battleActive = nil game.stack:pop() -- wBattleResult (constants/battle_constants.asm): WIN 0, LOSE 1, DRAW 2. @@ -7567,8 +7613,9 @@ function World:bakeMapImage(map, daytime, flicker) local blocks = tileset.blocks local tilesPerRow = tileset.tilesPerRow or 16 local pw, ph = map.width * 32, map.height * 32 - local canvas = love.graphics.newCanvas(pw, ph) - canvas:setFilter("nearest", "nearest") + -- Map pixels, not the screen's: a DPI-scaled canvas bakes them non-square + -- (#208, see src/render/PixelCanvas.lua). + local canvas = PixelCanvas.new(pw, ph, "nearest") local quads = {} local function quadFor(tile) local q = quads[tile] @@ -8095,12 +8142,11 @@ function World:scrollStrip(mapDef, tileset, tile, scroll) local cached = self.scrollStrips[key] if cached ~= nil then return cached or nil end local atlas = self:atlasFor(mapDef) - local ok, canvas = pcall(love.graphics.newCanvas, 8, 8 * 8) + local ok, canvas = pcall(PixelCanvas.new, 8, 8 * 8, "nearest") if not (atlas and ok and canvas) then self.scrollStrips[key] = false return nil end - canvas:setFilter("nearest", "nearest") local perRow = tileset.tilesPerRow or 16 local sx, sy = (tile % perRow) * 8, math.floor(tile / perRow) * 8 local aw, ah = atlas:getDimensions() @@ -8540,7 +8586,8 @@ function World:setMap(mapId, cx, cy, facing, opts) if audio and audio.runtime then if not (FieldMoves.isBiking(self.playerState) and self:playBikeMusic()) then Music.playMap(self.game.data, mapId, nil, - FieldMoves.isSurfing(self.playerState)) + FieldMoves.isSurfing(self.playerState), nil, + self:mapMusicSong(mapId)) end end -- Fires with the map fully built and BEFORE the map's own scene script, so a @@ -8928,7 +8975,8 @@ function World:movePlayer(dir) local audio = self.game and self.game.data and self.game.data.audio if audio and audio.runtime then Music.playMap(self.game.data, map.id, nil, - FieldMoves.isSurfing(self.playerState)) + FieldMoves.isSurfing(self.playerState), nil, + self:mapMusicSong(map.id)) end end end @@ -9898,8 +9946,7 @@ function World:drawTilted(w, h, s, gw, gh) if self.tiltCanvas and self.tiltCanvas.release then self.tiltCanvas:release() end - self.tiltCanvas = G.newCanvas(gw, gh) - self.tiltCanvas:setFilter("linear", "linear") + self.tiltCanvas = PixelCanvas.new(gw, gh, "linear") end local previous = G.getCanvas() diff --git a/tests/engine/battle_move_slot_dashes_bug1343.lua b/tests/engine/battle_move_slot_dashes_bug1343.lua new file mode 100644 index 00000000..9c292062 --- /dev/null +++ b/tests/engine/battle_move_slot_dashes_bug1343.lua @@ -0,0 +1,102 @@ +-- engine/battle/misc.asm:37 FormatMovesString .printDashLoop + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local BattleState = require("src.battle.BattleState") +local Font = require("src.render.Font") + +local realDraw, realDrawCode, realDrawBox = Font.draw, Font.drawCode, Font.drawBox +local drawn + +local function stubFont() + drawn = {} + Font.draw = function(text, x, y) drawn[#drawn + 1] = { text = text, x = x, y = y } end + Font.drawCode = function() end + Font.drawBox = function() end +end + +local function unstubFont() + Font.draw, Font.drawCode, Font.drawBox = realDraw, realDrawCode, realDrawBox +end + +-- a mon with fewer than four moves: the remaining rows must be dashes, not +-- simply absent (ipairs used to stop at the last known move). +do + stubFont() + local screen = setmetatable({ + phase = "moveSelect", + player = { curMoves = { { id = "TACKLE", pp = 35 } } }, + data = { moves = { TACKLE = { name = "TACKLE", pp = 35, type = "NORMAL" } } }, + moveIndex = 1, frame = 0, + }, { __index = BattleState }) + local ok, err = pcall(function() screen:drawTextArea() end) + T.check(ok, "moveSelect draws without error (" .. tostring(err) .. ")") + + local rows = {} + for _, d in ipairs(drawn) do + if d.x == 48 and d.y >= 104 and d.y <= 128 then rows[#rows + 1] = d.text end + end + T.eq(#rows, 4, "all four move rows are drawn, even the unused ones") + T.eq(rows[1], "TACKLE", "the one real move prints its name") + T.eq(rows[2], "-", "an empty slot is a dash") + T.eq(rows[3], "-", "so is the next one") + T.eq(rows[4], "-", "and the last one") + unstubFont() +end + +-- a full four-move mon: no dashes anywhere. +do + stubFont() + local screen = setmetatable({ + phase = "moveSelect", + player = { curMoves = { + { id = "TACKLE", pp = 35 }, { id = "GROWL", pp = 40 }, + { id = "TACKLE", pp = 35 }, { id = "GROWL", pp = 40 }, + } }, + data = { moves = { + TACKLE = { name = "TACKLE", pp = 35, type = "NORMAL" }, + GROWL = { name = "GROWL", pp = 40, type = "NORMAL" }, + } }, + moveIndex = 1, frame = 0, + }, { __index = BattleState }) + screen:drawTextArea() + local rows = {} + for _, d in ipairs(drawn) do + if d.x == 48 and d.y >= 104 and d.y <= 128 then rows[#rows + 1] = d.text end + end + T.eq(#rows, 4, "still exactly four rows") + for i, want in ipairs({ "TACKLE", "GROWL", "TACKLE", "GROWL" }) do + T.eq(rows[i], want, "row " .. i .. " keeps its own move name") + end + unstubFont() +end + +-- the Mimic menu shares FormatMovesString on the cart, so it gets the same +-- dash treatment. +do + stubFont() + local screen = setmetatable({ + phase = "mimicSelect", + mimicMoves = { { id = "TACKLE" }, { id = "GROWL" } }, + data = { moves = { + TACKLE = { name = "TACKLE" }, GROWL = { name = "GROWL" }, + } }, + mimicIndex = 1, frame = 0, + }, { __index = BattleState }) + local ok = pcall(function() screen:drawTextArea() end) + T.check(ok, "mimicSelect draws without error") + local rows = {} + for _, d in ipairs(drawn) do + if d.x == 16 and d.y >= 64 and d.y <= 88 then rows[#rows + 1] = d.text end + end + T.eq(rows[1], "TACKLE", "mimic row 1 is the enemy's first move") + T.eq(rows[2], "GROWL", "mimic row 2 is its second") + T.eq(rows[3], "-", "an enemy with fewer than four moves dashes out the rest") + T.eq(rows[4], "-", "including the last row") + unstubFont() +end + +T.finish("battle move slot dashes bug 1343") diff --git a/tests/engine/blues_house_daisy_walking_bug1338.lua b/tests/engine/blues_house_daisy_walking_bug1338.lua new file mode 100644 index 00000000..f66aa591 --- /dev/null +++ b/tests/engine/blues_house_daisy_walking_bug1338.lua @@ -0,0 +1,80 @@ +-- #1338: after the TOWN MAP, Daisy has to swap from the sitting object to +-- the walking one -- PalletTownDaisyScript, gated on both +-- EVENT_GOT_TOWN_MAP and EVENT_ENTERED_BLUES_HOUSE. +-- scripts/BluesHouse.asm:12-16; scripts/PalletTown.asm:133-144 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +local Story = assert(loadfile("data/scripts/story.lua"))() +local Story2 = assert(loadfile("data/scripts/story2.lua"))() + +T.check(type(Story.BLUES_HOUSE.onEnter) == "function", + "M.BLUES_HOUSE.onEnter exists") +T.check(type(Story2.PALLET_TOWN.onEnter) == "function", + "M.PALLET_TOWN.onEnter exists") + +-- Entering Blue's House alone must set EVENT_ENTERED_BLUES_HOUSE and touch +-- nothing else: BluesHouseDefaultScript is a plain SetEvent, no swap here. +do + local game = { save = { flags = {} } } + Story.BLUES_HOUSE.onEnter(game, {}) + T.check(game.save.flags.EVENT_ENTERED_BLUES_HOUSE == true, + "onEnter sets EVENT_ENTERED_BLUES_HOUSE") + T.check(game.save.flags.EVENT_DAISY_WALKING == nil, + "and does not itself start the swap") +end + +-- PALLET_TOWN's onEnter is the swap: both events must be set, and it must +-- write the toggle even though BLUES_HOUSE is not the live map (the fix's +-- own precondition, verified against src/script/Commands.lua's toggleObject +-- writing save.objectToggles before its live-NPC early return). +do + local game = { + save = { + flags = { EVENT_GOT_TOWN_MAP = true, EVENT_ENTERED_BLUES_HOUSE = true }, + }, + } + local ow = { map = { id = "PALLET_TOWN" } } + Story2.PALLET_TOWN.onEnter(game, ow) + T.check(game.save.flags.EVENT_DAISY_WALKING == true, + "both prerequisites set: EVENT_DAISY_WALKING fires") + local toggles = game.save.objectToggles and game.save.objectToggles.BLUES_HOUSE + T.check(toggles ~= nil, "the swap reaches BLUES_HOUSE's toggle table") + T.eq(toggles and toggles.BLUESHOUSE_DAISY1, false, + "the sitting Daisy (DAISY1) is hidden") + T.eq(toggles and toggles.BLUESHOUSE_DAISY2, true, + "the walking Daisy (DAISY2) is shown") +end + +-- Only having the map, without ever entering the house, must not swap her: +-- EVENT_ENTERED_BLUES_HOUSE is a real gate, not a formality. +do + local game = { + save = { flags = { EVENT_GOT_TOWN_MAP = true } }, + } + Story2.PALLET_TOWN.onEnter(game, { map = { id = "PALLET_TOWN" } }) + T.check(game.save.flags.EVENT_DAISY_WALKING == nil, + "without EVENT_ENTERED_BLUES_HOUSE the swap does not fire") +end + +-- Re-entering Pallet Town after she has already swapped must not re-run +-- the toggle writes (EVENT_DAISY_WALKING itself is the guard). +do + local game = { + save = { + flags = { + EVENT_GOT_TOWN_MAP = true, + EVENT_ENTERED_BLUES_HOUSE = true, + EVENT_DAISY_WALKING = true, + }, + objectToggles = {}, + }, + } + local ow = { map = { id = "PALLET_TOWN" } } + Story2.PALLET_TOWN.onEnter(game, ow) + T.check(next(game.save.objectToggles) == nil, + "already-walking Daisy: onEnter writes no toggle a second time") +end + +T.finish("blues_house_daisy_walking_bug1338") diff --git a/tests/engine/exp_traded_ot_survives_reload_bug1265.lua b/tests/engine/exp_traded_ot_survives_reload_bug1265.lua new file mode 100644 index 00000000..26619855 --- /dev/null +++ b/tests/engine/exp_traded_ot_survives_reload_bug1265.lua @@ -0,0 +1,49 @@ +-- engine/battle/experience.asm:69 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local BattleState = require("src.battle.BattleState") + +local function newSave() + return { player = { id = 12345, name = "RED" } } +end + +do + local save = newSave() + local homegrown = {} + BattleState.stampOT(save, homegrown) + T.eq(homegrown.otId, 12345, "a home-grown mon is still stamped with the player id") + T.eq(homegrown.ot, "RED", "and the player's own name") +end + +-- The bug: a mon that arrived traded (traded = true) but whose OT id was +-- never recorded (a legacy peer, a link mon with no otId in its packet) used +-- to get save.player.id written into otId on the very first load, which then +-- reads identically to a mon the player caught -- awardExp's OT-id compare +-- (BattleState.lua ~4009) permanently loses the 1.5x boost. +do + local save = newSave() + local tradedNoId = { traded = true } + BattleState.stampOT(save, tradedNoId) + T.eq(tradedNoId.otId, nil, + "a traded mon with no OT id is left unstamped, not silently adopted") + T.eq(tradedNoId.ot, "RED", + "the OT NAME fill still happens (cosmetic, not the boost gate)") + -- a second stampOT pass (a second save/load cycle) must not adopt it either + BattleState.stampOT(save, tradedNoId) + T.eq(tradedNoId.otId, nil, "repeated reloads do not eventually stamp it") +end + +-- A mon with its own foreign OT id (the ordinary traded-in case) is untouched +-- either way; this is the arm the regression never broke. +do + local save = newSave() + local tradedWithId = { traded = true, otId = 777 } + BattleState.stampOT(save, tradedWithId) + T.eq(tradedWithId.otId, 777, "a recorded foreign OT id is never overwritten") +end + +T.finish("exp traded ot survives reload bug 1265") diff --git a/tests/engine/gen2_ball_throw_pic_latch_bug1232.lua b/tests/engine/gen2_ball_throw_pic_latch_bug1232.lua new file mode 100644 index 00000000..6d8685d3 --- /dev/null +++ b/tests/engine/gen2_ball_throw_pic_latch_bug1232.lua @@ -0,0 +1,82 @@ +-- data/moves/animations.asm:379 + +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 function newSelf(opts) + opts = opts or {} + return setmetatable({ + anim = opts.anim, + ballThrow = opts.ballThrow, + picHidden = { player = false, enemy = false }, + pendingAfterAnim = nil, + afterSendOut = nil, + }, { __index = UI }) +end + +-- pushCaught itself must never touch picHidden: only the animation's own +-- steps (stepAnim) are allowed to latch the enemy pic box. +do + local self1 = setmetatable({ + battle = {}, tutorial = true, save = nil, + queue = {}, picHidden = { player = false, enemy = false }, + }, { __index = UI }) + self1:pushCaught({ species = "RATTATA" }, "POKE_BALL") + T.eq(self1.picHidden.enemy, false, + "pushCaught alone does not hide the enemy pic") + T.eq(self1.battle.outcome, "caught", "pushCaught still marks the battle caught") +end + +-- stepAnim, natural end (anim:step() returns false): a caught ball throw +-- latches, everything else does not. +do + local caughtAnim = { animId = "ANIM_THROW_POKE_BALL", + step = function() return false end, keepSprites = false } + local s = newSelf({ anim = caughtAnim, ballThrow = { caught = true } }) + s:stepAnim(nil) + T.eq(s.picHidden.enemy, true, "caught ball throw latches at the natural end") + T.eq(s.anim, nil, "the finished runner is cleared") +end + +do + local breakFreeAnim = { animId = "ANIM_THROW_POKE_BALL", + step = function() return false end, keepSprites = false } + local s = newSelf({ anim = breakFreeAnim, ballThrow = { caught = false } }) + s:stepAnim(nil) + T.eq(s.picHidden.enemy, false, "a break-free throw does not latch") +end + +do + local otherAnim = { animId = "ANIM_HYDRO_PUMP", + step = function() return false end, keepSprites = false } + local s = newSelf({ anim = otherAnim, ballThrow = { caught = true } }) + s:stepAnim(nil) + T.eq(s.picHidden.enemy, false, "an unrelated animation never latches") +end + +-- stepAnim, cut short with B: the property the latch exists for -- a caught +-- mon must not reappear even if the player skips past "Gotcha!". +do + local caughtAnim = { animId = "ANIM_THROW_POKE_BALL", + step = function() return true end, keepSprites = false } + local s = newSelf({ anim = caughtAnim, ballThrow = { caught = true } }) + local input = { wasPressed = function(_, key) return key == "b" end } + s:stepAnim(input) + T.eq(s.picHidden.enemy, true, "a B-skipped catch still latches") + T.eq(s.anim, nil, "B cuts the runner short") +end + +do + local breakFreeAnim = { animId = "ANIM_THROW_POKE_BALL", + step = function() return true end, keepSprites = false } + local s = newSelf({ anim = breakFreeAnim, ballThrow = { caught = false } }) + local input = { wasPressed = function(_, key) return key == "b" end } + s:stepAnim(input) + T.eq(s.picHidden.enemy, false, "a B-skipped break-free does not latch") +end + +T.finish("gen2 ball throw pic latch bug 1232") diff --git a/tests/engine/gen2_battler_gfx_rows_bug1231.lua b/tests/engine/gen2_battler_gfx_rows_bug1231.lua new file mode 100644 index 00000000..e74a036a --- /dev/null +++ b/tests/engine/gen2_battler_gfx_rows_bug1231.lua @@ -0,0 +1,58 @@ +-- engine/battle_anims/anim_commands.asm:755 BattleAnimCmd_BattlerGFX_1Row + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local AnimRunner = require("src.battle.gen2.AnimRunner") + +local function findLoaded(runner, gfx) + for _, entry in ipairs(runner.loaded) do + if entry.gfx == gfx then return entry end + end + return nil +end + +do + local runner = AnimRunner.new({}) + runner:start(nil) + runner:loadBattlerGfx(1) + local head = findLoaded(runner, "BATTLE_ANIM_GFX_PLAYERHEAD") + local feet = findLoaded(runner, "BATTLE_ANIM_GFX_ENEMYFEET") + T.check(head and feet, "both pseudo-sheets registered") + T.eq(head.battler, "enemy", + "GFX_PLAYERHEAD's tiles are the ENEMY's feet row") + T.eq(head.tiles, 7, "seven tiles, the enemy pic's width") + T.eq(head.tile, (0x80 - 6 - 7) - 49, "at the asm's fixed base") + T.eq(feet.battler, "player", + "GFX_ENEMYFEET's tiles are the PLAYER's head row") + T.eq(feet.tiles, 6, "six tiles, the backpic's width") + T.eq(feet.tile, (0x80 - 6) - 49, "at the asm's fixed base") + T.eq(head.rows, 1, "one row each") + T.eq(feet.rows, 1, "on both sheets") +end + +do + local runner = AnimRunner.new({}) + runner:start(nil) + runner:loadBattlerGfx(2) + local head = findLoaded(runner, "BATTLE_ANIM_GFX_PLAYERHEAD") + local feet = findLoaded(runner, "BATTLE_ANIM_GFX_ENEMYFEET") + T.eq(head.battler, "enemy", "2ROW keeps the same crossing") + T.eq(head.tiles, 14, "two enemy rows") + T.eq(feet.battler, "player", "on both sides") + T.eq(feet.tiles, 12, "two player rows") + T.eq(head.tile, (0x80 - 6 * 2 - 7 * 2) - 49, "2ROW base") + T.eq(feet.tile, (0x80 - 6 * 2) - 49, "2ROW base") +end + +do + local runner = AnimRunner.new({}) + runner:start(nil) + AnimRunner.COMMANDS.battlergfx_1row(runner) + local head = findLoaded(runner, "BATTLE_ANIM_GFX_PLAYERHEAD") + T.eq(head and head.battler, "enemy", "the script command routes the same way") +end + +T.finish("gen2 battler gfx row attribution bug 1231") diff --git a/tests/engine/gen2_battler_row_lift_bug1231.lua b/tests/engine/gen2_battler_row_lift_bug1231.lua new file mode 100644 index 00000000..687be2ba --- /dev/null +++ b/tests/engine/gen2_battler_row_lift_bug1231.lua @@ -0,0 +1,68 @@ +-- engine/battle_anims/bg_effects.asm:406-471 BattleBGEffect_BattlerObj_1Row + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local BgEffects = require("src.battle.gen2.BgEffects") +local BattleAnimView = require("src.ui.gen2.BattleAnimView") + +do + local bg = BgEffects.new(nil, { battleTurn = 0 }) + bg:queue("BATTLE_BG_EFFECT_BATTLEROBJ_1ROW", 0, 0, 0) + bg:playFrame() + local spawns = bg:takeSpawns() + T.eq(spawns[1] and spawns[1].object, "BATTLE_ANIM_OBJ_ENEMYFEET_1ROW", + "player attacking: the enemy's feet row becomes an OBJ") + T.eq(spawns[1] and spawns[1].x, 16 * 8 + 4, "at the asm's fixed x") + T.eq(bg.liftedRows.enemy, nil, "the tilemap row is intact on frame one") + T.eq(BattleAnimView.needsCanvas({ bg = bg }), false, + "an intact tilemap with no scroll skips the bake canvas") + bg:playFrame() + local lifted = bg.liftedRows.enemy + T.check(lifted and lifted[1] == 6 and lifted[2] == 1, + "frame two ClearBoxes row 6 of the enemy box (hlcoord 12, 6)") + T.eq(bg.hidden.enemy, false, "the rest of the pic stays on the BG") + T.eq(BattleAnimView.needsCanvas({ bg = bg }), true, + "a lifted row keeps the panel on the bake canvas even with scx 0 and no" + .. " lcdc pointer, so drawPic's 160x144 scissor stays in canvas space") + for _ = 1, 4 do bg:playFrame() end + T.eq(bg:activeCount(), 0, ".five ends the effect") + lifted = bg.liftedRows.enemy + T.check(lifted and lifted[1] == 6 and lifted[2] == 1, + ".five never restores the row") + T.eq(BattleAnimView.needsCanvas({ bg = bg }), true, + "and the wait frames after .five stay baked as well") + bg:queue("BATTLE_BG_EFFECT_SHOW_MON", 0, 0, 0) + bg:playFrame() + T.eq(bg.liftedRows.enemy, nil, "SHOW_MON's box redraw puts the row back") + T.eq(BattleAnimView.needsCanvas({ bg = bg }), false, + "after which the plain no-canvas path returns") +end + +do + local bg = BgEffects.new(nil, { battleTurn = 1 }) + bg:queue("BATTLE_BG_EFFECT_BATTLEROBJ_2ROW", 0, 0, 0) + bg:playFrame() + local spawns = bg:takeSpawns() + T.eq(spawns[1] and spawns[1].object, "BATTLE_ANIM_OBJ_PLAYERHEAD_2ROW", + "enemy attacking: the player's head rows become an OBJ") + T.eq(spawns[1] and spawns[1].x, 6 * 8, "at the asm's fixed x") + bg:playFrame() + local lifted = bg.liftedRows.player + T.check(lifted and lifted[1] == 0 and lifted[2] == 2, + "rows 0-1 of the player box (hlcoord 2, 6, two rows)") + T.eq(bg.liftedRows.enemy, nil, "the attacker keeps its own rows") +end + +do + local bg = BgEffects.new(nil, { battleTurn = 0, flying = { enemy = true } }) + bg:queue("BATTLE_BG_EFFECT_BATTLEROBJ_1ROW", 0, 0, 0) + bg:playFrame() + T.eq(#bg:takeSpawns(), 0, "a flying target spawns nothing") + T.eq(bg:activeCount(), 0, "and the effect ends at once") + T.eq(bg.liftedRows.enemy, nil, "with no row lifted") +end + +T.finish("gen2 battler row lift bug 1231") diff --git a/tests/engine/gen2_charge_move_anim_param_bug1293.lua b/tests/engine/gen2_charge_move_anim_param_bug1293.lua new file mode 100644 index 00000000..5619fcf5 --- /dev/null +++ b/tests/engine/gen2_charge_move_anim_param_bug1293.lua @@ -0,0 +1,94 @@ +-- engine/battle/effect_commands.asm:5458 BattleCommand_Charge + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local Battle = require("src.battle.gen2.Battle") +local Mon = require("src.battle.gen2.Mon") + +local TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + GROUND = { id = "GROUND", index = 1, category = "physical" }, + FLYING = { id = "FLYING", index = 2, category = "physical" }, +} + +local MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" }, + DIG = { id = "DIG", name = "DIG", power = 60, type = "GROUND", + accuracy = 100, pp = 10, effect = "EFFECT_FLY" }, + FLY = { id = "FLY", name = "FLY", power = 70, type = "FLYING", + accuracy = 95, pp = 15, effect = "EFFECT_FLY" }, +} + +local POKEMON = { + growthRates = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, + }, + MACHOP = { id = "MACHOP", index = 66, name = "MACHOP", + baseStats = { hp = 70, attack = 80, defense = 50, speed = 35, + specialAttack = 35, specialDefense = 35 }, + types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63, + levelMoves = {}, evolutions = {} }, +} + +local DATA = { pokemon = POKEMON, moves = MOVES, + type_chart = { types = TYPES, matchups = {} }, items = {} } + +local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } +perfect.hp = Mon.hpDV(perfect) + +local function highRoll(n) return (n or 1) - 1 end + +local function newBattle(moveId) + local player = Mon.new(DATA, "MACHOP", 50, { dvs = perfect }) + player.moves = { { id = moveId, pp = 20, maxPp = 20 } } + local wild = Mon.new(DATA, "MACHOP", 50, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + return Battle.new({ data = DATA, party = { player }, wild = wild, + random = highRoll }), player, wild +end + +local function moveEvent(events) + for _, e in ipairs(events or {}) do + if e.kind == "move" then return e end + end +end + +do + local battle, player, wild = newBattle("DIG") + battle.events = {} + battle:useMove(player, wild, "DIG") + local ev = moveEvent(battle.events) + T.check(ev ~= nil, "the charge turn queues a move event") + T.eq(ev and ev.animParam, 1, + "DIG's charge (burrow) turn carries animParam 1, the take-cover script arm") + battle.events = {} + battle:useMove(player, wild, "DIG") + local ev2 = moveEvent(battle.events) + T.check(ev2 ~= nil, "the strike turn also queues a move event") + T.eq(ev2 and ev2.animParam, nil, + "DIG's strike turn leaves animParam nil, the hit script arm") +end + +do + local battle, player, wild = newBattle("FLY") + battle.events = {} + battle:useMove(player, wild, "FLY") + local ev = moveEvent(battle.events) + T.eq(ev and ev.animParam, 1, "FLY's take-off turn also carries animParam 1") +end + +-- a plain hit-and-run move never sets a parameter at all +do + local battle, player, wild = newBattle("DIG") + player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + battle.events = {} + battle:useMove(player, wild, "TACKLE") + local ev = moveEvent(battle.events) + T.eq(ev and ev.animParam, nil, "a non-charge move never carries animParam") +end + +T.finish("gen2 charge move anim param bug 1293") diff --git a/tests/engine/gen2_coin_case_bug1251.lua b/tests/engine/gen2_coin_case_bug1251.lua new file mode 100644 index 00000000..4ce1278b --- /dev/null +++ b/tests/engine/gen2_coin_case_bug1251.lua @@ -0,0 +1,83 @@ +-- #1251: the Game Corner's `CheckCoinsAndCoinCase` transcription must ask +-- the bag about the real COIN_CASE item id, not SILVER_WING. +-- constants/item_constants.asm:62 (COIN_CASE = $36); SILVER_WING is $47. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +local Vm = require("src.script.gen2.Vm") +local Specials = require("src.script.gen2.Specials") +local Events = require("src.world.gen2.Events") + +local COIN_CASE = 0x36 +local SILVER_WING = 0x47 + +-- 1) the id the handler actually queries the bag with +local seenId +local vm = Vm.new({ generation = 2 }, {}, Events.new(), { + specials = { + coins = function() return 100 end, + hasItem = function(id) seenId = id return true end, + gameCornerGame = function(_, done) done() end, + }, +}) +vm.showTextFn = function() end +vm.co = coroutine.create(function() Specials.HANDLERS.SlotMachine(vm) end) +coroutine.resume(vm.co) +T.eq(seenId, COIN_CASE, + "SlotMachine's CheckItem call carries the real COIN_CASE id ($36)") +T.check(seenId ~= SILVER_WING, + "and specifically not SILVER_WING ($47), the pre-fix value") + +-- 2) a bag holding ONLY the real coin case (not the silver wing) must be +-- enough to open both machines: this is what would still fail if the id +-- above were merely logged and not actually used to gate the machine. +local bag = { [COIN_CASE] = true } +local slotsOpened, flipOpened +local vm2 = Vm.new({ generation = 2 }, {}, Events.new(), { + specials = { + coins = function() return 50 end, + hasItem = function(id) return bag[id] == true end, + gameCornerGame = function(kind, done) slotsOpened = kind done() end, + }, +}) +vm2.showTextFn = function() end +vm2.co = coroutine.create(function() Specials.HANDLERS.SlotMachine(vm2) end) +coroutine.resume(vm2.co) +T.eq(slotsOpened, "slots", + "a bag with the real COIN CASE and coins opens the slot machine") + +local vm3 = Vm.new({ generation = 2 }, {}, Events.new(), { + specials = { + coins = function() return 50 end, + hasItem = function(id) return bag[id] == true end, + gameCornerGame = function(kind, done) flipOpened = kind done() end, + }, +}) +vm3.showTextFn = function() end +vm3.co = coroutine.create(function() Specials.HANDLERS.CardFlip(vm3) end) +coroutine.resume(vm3.co) +T.eq(flipOpened, "cardflip", + "and the same bag opens card flip too") + +-- 3) the mirror case: SILVER_WING in the bag, no coin case, must still +-- refuse with _NoCoinCaseText (this is the exact symptom in #1251, and it +-- is the yield the coroutine parks on, not a call, so opening never runs +-- behind it). +local wrongBag = { [SILVER_WING] = true } +local opened = false +local vm4 = Vm.new({ generation = 2 }, {}, Events.new(), { + specials = { + coins = function() return 50 end, + hasItem = function(id) return wrongBag[id] == true end, + gameCornerGame = function() opened = true end, + }, +}) +vm4.showTextFn = function() end +vm4.co = coroutine.create(function() Specials.HANDLERS.SlotMachine(vm4) end) +local _, refusal = coroutine.resume(vm4.co) +T.eq(refusal and refusal.text, "You don't have a\nCOIN CASE.", + "holding only SILVER_WING gets the real _NoCoinCaseText refusal") +T.check(not opened, "and the machine never opens behind it") + +T.finish("gen2_coin_case_bug1251") diff --git a/tests/engine/gen2_enemy_move_fail_text_bug1296.lua b/tests/engine/gen2_enemy_move_fail_text_bug1296.lua new file mode 100644 index 00000000..b100c061 --- /dev/null +++ b/tests/engine/gen2_enemy_move_fail_text_bug1296.lua @@ -0,0 +1,166 @@ +-- engine/battle/effect_commands.asm:1958-1961 (the 40 frame hold), +-- engine/battle/effect_commands.asm:3615 (.CheckAIRandomFail, the 25% roll) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local Battle = require("src.battle.gen2.Battle") +local Mon = require("src.battle.gen2.Mon") +local UI = require("src.ui.gen2.BattleState") + +local TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + ELECTRIC = { id = "ELECTRIC", index = 1, category = "special" }, +} + +local MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" }, + GROWL = { id = "GROWL", name = "GROWL", power = 0, type = "NORMAL", + accuracy = 100, pp = 40, effect = "EFFECT_ATTACK_DOWN" }, + THUNDER_WAVE = { id = "THUNDER_WAVE", name = "THUNDER WAVE", power = 0, + type = "ELECTRIC", accuracy = 100, pp = 20, effect = "EFFECT_PARALYZE" }, +} + +local POKEMON = { + growthRates = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, + }, + MACHOP = { id = "MACHOP", index = 66, name = "MACHOP", + baseStats = { hp = 70, attack = 80, defense = 50, speed = 35, + specialAttack = 35, specialDefense = 35 }, + types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63, + levelMoves = {}, evolutions = {} }, +} + +local DATA = { pokemon = POKEMON, moves = MOVES, + type_chart = { types = TYPES, matchups = {} }, items = {} } + +local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } +perfect.hp = Mon.hpDV(perfect) + +-- a controllable roll queue; falls back to a high roll (never fails an AI +-- check) once drained +local rolls +local function rng(n) + if rolls and #rolls > 0 then return table.remove(rolls, 1) % math.max(1, n) end + return (n or 1) - 1 +end + +local function newBattle(pmoves, emoves) + local player = Mon.new(DATA, "MACHOP", 50, { dvs = perfect }) + player.moves = pmoves + local wild = Mon.new(DATA, "MACHOP", 50, { dvs = perfect }) + wild.moves = emoves + return Battle.new({ data = DATA, party = { player }, wild = wild, + random = rng }), player, wild +end + +local function findText(events, sub) + for _, e in ipairs(events or {}) do + if e.kind == "message" and e.text and e.text:find(sub, 1, true) then + return true + end + end + return false +end +local function moveEvent(events) + for _, e in ipairs(events or {}) do + if e.kind == "move" then return e end + end +end + +-- ---------------------------------------------------------------- gap 2: +-- the AI's 25% "miss" on a support move, and who is exempt from the roll. +do + local battle, player, wild = newBattle( + { { id = "TACKLE", pp = 35, maxPp = 35 } }, + { { id = "GROWL", pp = 40, maxPp = 40 } }) + battle.events = {} + rolls = { 0, 10 } -- accuracy roll, then the AI roll (10 < 64: fails) + battle:useMove(wild, player, "GROWL") + T.check(findText(battle.events, "But it failed!"), + "enemy GROWL fails 25% of the time with the specific line") + T.eq(moveEvent(battle.events) and moveEvent(battle.events).missed, true, + "an AI-failed move is marked missed (feeds the 40 frame hold)") +end + +do + local battle, player, wild = newBattle( + { { id = "TACKLE", pp = 35, maxPp = 35 } }, + { { id = "GROWL", pp = 40, maxPp = 40 } }) + battle.events = {} + rolls = { 0, 200 } -- AI roll passes (>=64): lands + battle:useMove(wild, player, "GROWL") + T.check(not findText(battle.events, "But it failed!"), + "the same move lands when the AI roll passes") +end + +do + local battle, player, wild = newBattle( + { { id = "GROWL", pp = 40, maxPp = 40 } }, + { { id = "TACKLE", pp = 35, maxPp = 35 } }) + battle.events = {} + rolls = { 0, 10 } -- if the player rolled too, 10 would fail it + battle:useMove(player, wild, "GROWL") + T.check(not findText(battle.events, "But it failed!"), + "the player's own GROWL is exempt from the AI roll") +end + +-- --------------------------------------------------------------- gap 1: +-- the reported symptom -- the "used X!" line must hold before a failure. +do + local input = { wasPressed = function() return false end } + local ui = setmetatable({ + game = { input = input }, + phase = "resolving", slideFrame = 999, messageTimer = 0, + picHidden = { player = false, enemy = false }, + queue = { + { kind = "move", side = "enemy", move = "GROWL", + text = "Enemy MACHOP used GROWL!", missed = true }, + { kind = "message", text = "But it failed!" }, + }, + updateAlarm = function() end, + stepHpAnim = function() return false end, + stepExpAnim = function() return false end, + }, { __index = UI }) + + ui:advanceQueue() + T.eq(ui.message, "Enemy MACHOP used GROWL!", "the used-move line is shown first") + T.eq(ui.messageDelay, 40, + "a missed move arms the 40 frame delay (effect_commands.asm's MoveDelay)") + T.eq(ui.messageTimer, 0, "no separate A/B hold on the move line itself") + + local frames = 0 + for _ = 1, 100 do + if ui.message == "But it failed!" then break end + ui:update(1 / 60) + frames = frames + 1 + end + T.eq(ui.message, "But it failed!", "the queue eventually reaches the failure line") + T.eq(frames, 41, "the used line held for exactly the 40 delay frames") + T.check(ui.messageTimer > 0, "the failure line itself still holds for A/B") +end + +do + local input = { wasPressed = function() return false end } + local ui2 = setmetatable({ + game = { input = input }, + phase = "resolving", slideFrame = 999, messageTimer = 0, + picHidden = { player = false, enemy = false }, + queue = { { kind = "move", side = "player", move = "TACKLE", + text = "MACHOP used TACKLE!" } }, + updateAlarm = function() end, + stepHpAnim = function() return false end, + stepExpAnim = function() return false end, + animForMove = function() return false end, + }, { __index = UI }) + ui2:advanceQueue() + T.eq(ui2.messageDelay or 0, 0, "a move that lands arms no delay at all") +end + +T.finish("gen2 enemy move fail text bug 1296") diff --git a/tests/engine/gen2_fishing_bite_gate_bug1368.lua b/tests/engine/gen2_fishing_bite_gate_bug1368.lua new file mode 100644 index 00000000..8594b4eb --- /dev/null +++ b/tests/engine/gen2_fishing_bite_gate_bug1368.lua @@ -0,0 +1,92 @@ +-- The fishgroup bite roll, missing entirely before #1368: .Fish rolls the +-- group's OWN chance byte before the rod's cumulative list even runs +-- (engine/events/fish.asm:24-30), so every rod bites at whatever that byte +-- says (vanilla Gold is 50 percent + 1 for every group, not 2/3 or 1/2 by +-- rod). A cache built before the extractor carried the byte has no +-- `chance` field on the group row at all and must keep fishing unconditionally. +-- luajit tests/engine/gen2_fishing_bite_gate_bug1368.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local World = require("src.world.gen2.World") + +local DATA = { + pokemon = { + MAGIKARP = { + name = "MAGIKARP", types = { "WATER", "WATER" }, + baseStats = { hp = 20, attack = 10, defense = 55, speed = 80, + specialAttack = 15, specialDefense = 20 }, + levelMoves = {}, + }, + }, +} + +local COLL_FLOOR, COLL_WATER = 0x00, 0x29 + +local function fakeMap(waterCell) + return { + id = "TEST_MAP", + def = { fishGroup = "FISHGROUP_POND" }, + cellCollision = function(_, x, y) + return (x == waterCell[1] and y == waterCell[2]) + and COLL_WATER or COLL_FLOOR + end, + } +end + +-- Rod lists that always hand back a species once a bite happens, so only the +-- group gate (or its absence) decides the outcome below. +local function fishGroups(chance) + return { + FISHGROUP_POND = { + chance = chance, + old = { { chance = 256, species = "MAGIKARP", level = 10 } }, + good = { { chance = 256, species = "MAGIKARP", level = 20 } }, + super = { { chance = 256, species = "MAGIKARP", level = 40 } }, + }, + } +end + +local function fakeWorld(chance) + local game = { data = DATA, save = { party = {} } } + local world = World.new(game) + world.map = fakeMap({ 5, 4 }) + world.maps = { TEST_MAP = world.map.def } + world.encounters = { fishGroups = fishGroups(chance) } + world.player = { cellX = 5, cellY = 5, facing = "up" } + return world +end + +-- ---- chance 0: the group byte fails Random every time, always a nibble --- +-- engine/events/fish.asm:24-30 +do + local world = fakeWorld(0) + for rod = 1, 3 do + local outcome = world:rollFishing(({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" })[rod]) + eq(outcome, "nibble", + "chance 0 nibbles on " .. ({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" })[rod]) + end +end + +-- ---- chance 256: the group byte always passes, every rod finds the mon --- +do + local world = fakeWorld(256) + for _, rod in ipairs({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }) do + local outcome, wild = world:rollFishing(rod) + eq(outcome, "battle", "chance 256 always bites on " .. rod) + check(wild and wild.species == "MAGIKARP", + "and the rod's own list still resolves a species") + end +end + +-- ---- no chance field at all: an old cache keeps fishing unconditionally -- +do + local world = fakeWorld(nil) + local outcome = world:rollFishing("OLD_ROD") + eq(outcome, "battle", + "a cache with no group chance byte is not gated at all") +end + +T.finish("gen2 fishing bite gate bug1368") diff --git a/tests/engine/gen2_grass_encounter_tod_bug1389.lua b/tests/engine/gen2_grass_encounter_tod_bug1389.lua new file mode 100644 index 00000000..05adce91 --- /dev/null +++ b/tests/engine/gen2_grass_encounter_tod_bug1389.lua @@ -0,0 +1,115 @@ +-- Grass encounters must key off the CLOCK (wTimeOfDay), never the palette +-- set a map header pins (wTimeOfDayPal): engine/overworld/wildmons.asm:283 +-- reads wTimeOfDay for both the rate (GetMapEncounterRate) and the slot list +-- (ChooseWildEncounter). A PALETTE_DAY tower like Sprout Tower must still +-- roll its night table after dark (#1389, Gastly unobtainable), and a +-- PALETTE_NITE cave must still roll its morning/day table at noon. +-- luajit tests/engine/gen2_grass_encounter_tod_bug1389.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local World = require("src.world.gen2.World") + +local DATA = { + pokemon = { + RATTATA = { + name = "RATTATA", types = { "NORMAL", "NORMAL" }, + baseStats = { hp = 30, attack = 56, defense = 35, speed = 72, + specialAttack = 25, specialDefense = 35 }, + levelMoves = {}, + }, + GASTLY = { + name = "GASTLY", types = { "GHOST", "POISON" }, + baseStats = { hp = 30, attack = 35, defense = 30, speed = 80, + specialAttack = 100, specialDefense = 35 }, + levelMoves = {}, + }, + }, +} + +local function fullList(species) + local slots = {} + for i = 1, 7 do slots[i] = { species = species, level = 10 } end + return slots +end + +-- data/wild/johto_grass.asm's own shape for a pinned tower: day is common +-- and worthless, night is the whole reason the room exists. A second, +-- separate map stands in for a PALETTE_NITE dungeon (Ilex Forest, Mt Moon): +-- the rates are flipped so the two fixtures cannot agree by accident. +local ENCOUNTERS = { + grass = { + SPROUT_TOWER_2F = { + rates = { MORN = 0, DAY = 0, NITE = 256 }, + slots = { + MORN = fullList("RATTATA"), + DAY = fullList("RATTATA"), + NITE = fullList("GASTLY"), + }, + }, + ILEX_FOREST = { + rates = { MORN = 256, DAY = 256, NITE = 0 }, + slots = { + MORN = fullList("RATTATA"), + DAY = fullList("RATTATA"), + NITE = fullList("GASTLY"), + }, + }, + }, +} + +local COLL_FLOOR = 0x00 + +local function fakeWorld(mapId, tod, daytime) + local game = { data = DATA, + save = { party = { { species = "RATTATA", level = 5 } } } } + local world = World.new(game) + world.map = { + id = mapId, + def = { environment = "DUNGEON" }, + cellCollision = function() return COLL_FLOOR end, + } + world.maps = { [mapId] = world.map.def } + world.encounters = ENCOUNTERS + world.player = { cellX = 5, cellY = 5, facing = "down" } + -- World:applyPalettes writes both fields every load; a PALETTE_DAY tower + -- pins `daytime` to DAY no matter the hour, while `tod` keeps tracking the + -- clock (src/world/gen2/World.lua:8271-8326). + world.tod = tod + world.daytime = daytime + local battled + world.startBattle = function(_, opts) + battled = opts.wild and opts.wild.species + return true + end + return world, function() return battled end +end + +-- ---- PALETTE_DAY tower, at night: the clock says NITE, the pin says DAY -- +do + local world, battled = fakeWorld("SPROUT_TOWER_2F", "NITE", "DAY") + check(world:tryWildEncounter(), "the tower rolls at night despite the pin") + eq(battled(), "GASTLY", + "the night list wins because the lookup is the clock, not the pin") +end + +-- ---- the same tower at actual daytime: the clock and the pin now agree --- +do + local world, battled = fakeWorld("SPROUT_TOWER_2F", "DAY", "DAY") + check(not world:tryWildEncounter(), + "DAY's rate is zero, so a daytime step in the tower rolls nothing") + eq(battled(), nil, "and nothing battled") +end + +-- ---- a PALETTE_NITE dungeon at actual noon: the pin says NITE, clock DAY -- +do + local world, battled = fakeWorld("ILEX_FOREST", "DAY", "NITE") + check(world:tryWildEncounter(), + "a pinned-night map still rolls its day table at the clock's noon") + eq(battled(), "RATTATA", + "the day list wins because the lookup ignores the palette pin") +end + +T.finish("gen2 grass encounter tod bug1389") diff --git a/tests/engine/gen2_map_bake_dpi.lua b/tests/engine/gen2_map_bake_dpi.lua new file mode 100644 index 00000000..b53c2aba --- /dev/null +++ b/tests/engine/gen2_map_bake_dpi.lua @@ -0,0 +1,54 @@ +-- Gen 2 map bakes take dpiscale 1 so map pixels stay square LCD pixels +-- (#208 #1301, constants/hardware.inc:932; see src/render/PixelCanvas.lua). +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +local g = love.graphics +local seen = {} +local realNewCanvas = g.newCanvas +g.newCanvas = function(w, h, settings) + seen[#seen + 1] = { w = w, h = h, dpiscale = settings and settings.dpiscale } + local c = realNewCanvas(w, h) + c.renderTo = function(_, fn) fn() end + c.setFilter = function() end + return c +end + +local World = require("src.world.gen2.World") +local MapPreview = require("src.world.gen2.MapPreview") + +local atlas = { + getDimensions = function() return 128, 128 end, + setFilter = function() end, +} +local tileset = { blocks = { [1] = {} }, tilesPerRow = 16 } +local map = { def = { tileset = "TILESET_JOHTO" }, width = 20, height = 18, + blocks = {}, borderBlock = 0 } + +local world = setmetatable({ + atlasCache = {}, + atlasFor = function() return atlas, tileset end, +}, { __index = World }) + +World.bakeMapImage(world, map, nil, nil) +local bakeCount = #seen +T.check(bakeCount >= 1, "the map bake allocated a canvas") + +World.scrollStrip(world, map.def, tileset, 3, { h = 1, v = 0 }) +T.check(#seen > bakeCount, "the scroll strip allocated a canvas") +local stripCount = #seen + +local origAtlasFor = MapPreview.atlasFor +MapPreview.atlasFor = function() return atlas, tileset end +MapPreview.bake({ tilesets = {}, atlasCache = {}, mapImages = {} }, map, "DAY") +MapPreview.atlasFor = origAtlasFor +T.check(#seen > stripCount, "the save-editor bake allocated a canvas") + +for i, c in ipairs(seen) do + T.eq(c.dpiscale, 1, + ("canvas %d (%dx%d) is allocated at dpiscale 1"):format(i, c.w, c.h)) +end + +g.newCanvas = realNewCanvas +T.finish("gen2 map bake dpi") diff --git a/tests/engine/gen2_pokedex_area_landmark_bug1267.lua b/tests/engine/gen2_pokedex_area_landmark_bug1267.lua new file mode 100644 index 00000000..8791c325 --- /dev/null +++ b/tests/engine/gen2_pokedex_area_landmark_bug1267.lua @@ -0,0 +1,88 @@ +-- Gold #DEX AREA page drew no nest markers or landmark name because +-- PokedexMenu:drawArea read the non-existent self.data.landmarks instead of +-- the gen2Landmarks table Nests already resolves through (#1267). +-- engine/pokegear/pokegear.asm:2427 +-- luajit tests/engine/gen2_pokedex_area_landmark_bug1267.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 PokedexMenu = require("src.ui.gen2.PokedexMenu") +local Nests = require("src.core.gen2.Nests") + +-- one Johto landmark (index 5), one species that nests there +local data = { + gen2Encounters = { + grass = { + ROUTE_30 = { slots = { day = { { species = "RATTATA" } } } }, + }, + }, + gen2Maps = { + ROUTE_30 = { landmark = 5 }, + }, + gen2Landmarks = { + landmarks = { + LANDMARK_ROUTE_30 = { index = 5, x = 40, y = 60, name = "ROUTE 30" }, + }, + }, +} + +-- sanity: Nests.landmark itself resolves the index (never broken, per the +-- verifier) so a failure below is isolated to drawArea's own lookup +eq(Nests.landmark(data, 5) and Nests.landmark(data, 5).name, "ROUTE 30", + "Nests.landmark resolves index 5 to the ROUTE 30 record") + +-- capture what drawArea actually paints, without needing a real tile sheet +-- or font: fill/blank/current/monName are stubbed on the instance, which +-- Lua resolves before the PokedexMenu metatable's own methods. +local function newSelf() + local texts = {} + local rects = {} + local self = setmetatable({ + game = { save = {} }, + data = data, + mapGfx = { maps = { johto = { 1 } } }, -- non-nil `cells`, no real sheet + areaRegion = "johto", + areaBlink = 0, -- (0 % 32) < 20, so markers are in their "on" phase + current = function() return { species = "RATTATA" } end, + monName = function() return "RATTATA" end, + fill = function() end, + blank = function() end, + text = function(_, str, tx, ty) + texts[#texts + 1] = { str = str, tx = tx, ty = ty } + end, + }, { __index = PokedexMenu }) + return self, texts, rects +end + +local realRect = love.graphics.rectangle +local self, texts, rects +do + self, texts, rects = newSelf() + love.graphics.rectangle = function(mode, x, y, w, h) + rects[#rects + 1] = { mode = mode, x = x, y = y, w = w, h = h } + end + self:drawArea() + love.graphics.rectangle = realRect +end + +local function hasRect(x, y) + for _, r in ipairs(rects) do + if r.x == x and r.y == y then return true end + end + return false +end +check(hasRect(40 - 2, 60 - 2), "the nest marker is drawn at the landmark's x-2,y-2") + +local function hasText(str) + for _, t in ipairs(texts) do + if t.str == str then return true end + end + return false +end +check(hasText("ROUTE 30"), "the landmark name is printed on row 16") + +T.finish("gen2 pokedex area landmark bug 1267") diff --git a/tests/engine/gen2_rocket_map_music_bug1385.lua b/tests/engine/gen2_rocket_map_music_bug1385.lua new file mode 100644 index 00000000..fb6d638b --- /dev/null +++ b/tests/engine/gen2_rocket_map_music_bug1385.lua @@ -0,0 +1,93 @@ +-- GetMapMusic (pokegold home/map.asm:2550), #1385 +-- luajit tests/engine/gen2_rocket_map_music_bug1385.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +love = require("tests.love_stub") + +local Source = {} +Source.__index = Source +function Source:play() self.playing = true end +function Source:stop() self.playing = false end +function Source:pause() self.playing = false end +function Source:isPlaying() return self.playing end +function Source:setLooping() end +function Source:setVolume(v) self.volume = v end +function Source:setPitch() end +function Source:setFilter() end +function Source:getDuration() return 1 end + +local made = {} +love.audio = { + newSource = function(file, mode) + made[file] = setmetatable({ file = file, mode = mode }, Source) + return made[file] + end, +} + +local World = require("src.world.gen2.World") +local Music = require("src.core.Music") + +-- constants/music_constants.asm:100,:109 +local MUSIC_MAHOGANY_MART = 100 +local RADIO_TOWER_SENTINEL = 0x80 + 61 + +local order = {} +order[61 + 1] = "Music_GoldenrodCity" +local audio = { + musicOrder = order, + songs = { + Music_RocketHideout = { file = "rocket_hideout.wav" }, + Music_CherrygroveCity = { file = "cherrygrove.wav" }, + Music_RocketTheme = { file = "rocket_theme.wav" }, + Music_GoldenrodCity = { file = "goldenrod.wav" }, + }, +} + +eq(World.mapMusicLabel(audio, MUSIC_MAHOGANY_MART, true, false), + "Music_RocketHideout", + "MAHOGANY_MART_1F with rockets in the mart plays the hideout theme") +eq(World.mapMusicLabel(audio, MUSIC_MAHOGANY_MART, false, false), + "Music_CherrygroveCity", + "MAHOGANY_MART_1F after the hideout is cleared plays Cherrygrove") +eq(World.mapMusicLabel(audio, RADIO_TOWER_SENTINEL, false, true), + "Music_RocketTheme", + "RADIO_TOWER floors during the takeover play the rocket theme") +eq(World.mapMusicLabel(audio, RADIO_TOWER_SENTINEL, false, false), + "Music_GoldenrodCity", + "RADIO_TOWER floors otherwise fall back to the low bits of the byte") +eq(World.mapMusicLabel(audio, 72, true, true), nil, + "a plain song id stays with the mapSongs table") +eq(World.mapMusicLabel(audio, nil, true, true), nil, + "a missing music byte resolves to nothing") + +local data = { audio = { + songs = { + Music_RocketHideout = { file = "rocket_hideout.wav" }, + Music_Victory = { file = "victory.wav" }, + }, + mapSongs = {}, +} } + +local function playing() + for file, src in pairs(made) do + if src.playing then return file end + end + return "(silence)" +end + +Music.stop() +Music.playMap(data, "MAHOGANY_MART_1F", false, false, nil, + "Music_RocketHideout") +eq(playing(), "rocket_hideout.wav", + "the resolved song overrides the empty mapSongs table") + +Music.play(data, "Music_Victory", nil, { reason = "battle" }) +eq(playing(), "victory.wav", "the battle result theme takes over") +Music.restoreMap(data) +eq(playing(), "rocket_hideout.wav", + "restoreMap replays the resolved song, ending the victory loop") + +T.finish("gen2_rocket_map_music_bug1385") diff --git a/tests/engine/gen2_safeguard_bug1388.lua b/tests/engine/gen2_safeguard_bug1388.lua new file mode 100644 index 00000000..9d3bc5e8 --- /dev/null +++ b/tests/engine/gen2_safeguard_bug1388.lua @@ -0,0 +1,171 @@ +-- engine/battle/move_effects/safeguard.asm:1, engine/battle/effect_commands.asm:6325 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local Battle = require("src.battle.gen2.Battle") +local Mon = require("src.battle.gen2.Mon") + +local TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + ELECTRIC = { id = "ELECTRIC", index = 1, category = "special" }, + FIRE = { id = "FIRE", index = 2, category = "special" }, +} + +local MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" }, + SAFEGUARD = { id = "SAFEGUARD", name = "SAFEGUARD", power = 0, + type = "NORMAL", accuracy = 100, pp = 25, effect = "EFFECT_SAFEGUARD" }, + THUNDER_WAVE = { id = "THUNDER_WAVE", name = "THUNDER WAVE", power = 0, + type = "ELECTRIC", accuracy = 100, pp = 20, effect = "EFFECT_PARALYZE" }, + SACRED_FIRE = { id = "SACRED_FIRE", name = "SACRED FIRE", power = 100, + type = "FIRE", accuracy = 95, pp = 5, effect = "EFFECT_SACRED_FIRE", + effectChance = 50 }, +} + +local POKEMON = { + growthRates = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, + }, + MACHOP = { id = "MACHOP", index = 66, name = "MACHOP", + baseStats = { hp = 70, attack = 80, defense = 50, speed = 35, + specialAttack = 35, specialDefense = 35 }, + types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63, + levelMoves = {}, evolutions = {} }, +} + +local DATA = { pokemon = POKEMON, moves = MOVES, + type_chart = { types = TYPES, matchups = {} }, items = {} } + +local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } +perfect.hp = Mon.hpDV(perfect) + +local rolls +local function rng(n) + if rolls and #rolls > 0 then return table.remove(rolls, 1) % math.max(1, n) end + return (n or 1) - 1 +end + +local function newBattle(pmoves, emoves) + local player = Mon.new(DATA, "MACHOP", 50, { dvs = perfect }) + player.moves = pmoves + local wild = Mon.new(DATA, "MACHOP", 50, { dvs = perfect }) + wild.moves = emoves + return Battle.new({ data = DATA, party = { player }, wild = wild, + random = rng }), player, wild +end + +local function findText(events, sub) + for _, e in ipairs(events or {}) do + if e.kind == "message" and e.text and e.text:find(sub, 1, true) then + return true + end + end + return false +end +local function moveEvent(events) + for _, e in ipairs(events or {}) do + if e.kind == "move" then return e end + end +end + +-- ------------------------------------------------------------- 1388a +-- Safeguard sets the USER's own side, not the target's. +do + local battle, player, wild = newBattle( + { { id = "SAFEGUARD", pp = 25, maxPp = 25 } }, + { { id = "THUNDER_WAVE", pp = 20, maxPp = 20 } }) + battle.events = {} + battle:useMove(player, wild, "SAFEGUARD") + T.eq(battle.screens.player.safeguard, 5, "Safeguard sets the CASTER's side for 5 turns") + T.check((battle.screens.enemy.safeguard or 0) == 0, + "and never touches the opposing side") + T.check(findText(battle.events, "covered by a veil"), "the veil line is emitted") +end + +do + local battle, player, wild = newBattle( + { { id = "SAFEGUARD", pp = 25, maxPp = 25 } }, + { { id = "THUNDER_WAVE", pp = 20, maxPp = 20 } }) + battle.events = {} + battle:useMove(player, wild, "SAFEGUARD") + battle.events = {} + rolls = { 200 } -- force the enemy's AI roll not to fail on its own + battle:useMove(wild, player, "THUNDER_WAVE") + T.check(findText(battle.events, "protected by SAFEGUARD"), + "an incoming status move from the OTHER side is blocked, loudly") + T.eq(player.status, nil, "and the paralysis never lands") + local ev = moveEvent(battle.events) + T.eq(ev and ev.missed, true, "the blocked move is marked missed") +end + +do + local battle, player, wild = newBattle( + { { id = "SAFEGUARD", pp = 25, maxPp = 25 } }, + { { id = "TACKLE", pp = 35, maxPp = 35 } }) + battle.events = {} + battle:useMove(player, wild, "SAFEGUARD") + battle.events = {} + battle:useMove(player, wild, "SAFEGUARD") + T.check(findText(battle.events, "But it failed!"), + "using it again while it is already up simply fails") +end + +do + -- the player's OWN status move against a safeguarded enemy is blocked too: + -- the effect reads whichever side is being TARGETED, not just "the enemy". + local battle, player, wild = newBattle( + { { id = "THUNDER_WAVE", pp = 20, maxPp = 20 } }, + { { id = "TACKLE", pp = 35, maxPp = 35 } }) + battle.screens.enemy.safeguard = 5 + battle.events = {} + battle:useMove(player, wild, "THUNDER_WAVE") + T.check(findText(battle.events, "protected by SAFEGUARD"), + "the player's status move is blocked by the enemy's own safeguard") + T.eq(wild.status, nil, "the enemy stays unstatused under its own screen") +end + +do + local battle = newBattle( + { { id = "TACKLE", pp = 35, maxPp = 35 } }, + { { id = "TACKLE", pp = 35, maxPp = 35 } }) + battle.screens.player.safeguard = 1 + battle.events = {} + battle:tickScreens() + T.check(findText(battle.events, "SAFEGUARD faded"), "it fades after its five turns") + T.eq(battle.screens.player.safeguard, nil, "and clears off the side entirely") +end + +-- ------------------------------------------------------------- 1388b +-- Sacred Fire's burn was never implemented at all. +do + local battle, player, wild = newBattle( + { { id = "TACKLE", pp = 35, maxPp = 35 } }, + { { id = "SACRED_FIRE", pp = 5, maxPp = 5 } }) + battle.events = {} + rolls = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } -- everything rolls low: hit, no + -- crit, and the 50% secondary effect chance all pass + battle:useMove(wild, player, "SACRED_FIRE") + T.eq(player.status, "burn", "Sacred Fire can now burn its target") +end + +do + local battle, player, wild = newBattle( + { { id = "TACKLE", pp = 35, maxPp = 35 } }, + { { id = "SACRED_FIRE", pp = 5, maxPp = 5 } }) + battle.screens.player.safeguard = 5 + battle.events = {} + rolls = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } + battle:useMove(wild, player, "SACRED_FIRE") + T.eq(player.status, nil, "Safeguard blocks the burn a damaging move carries") + T.check(not findText(battle.events, "protected by SAFEGUARD"), + "the secondary block is silent (SafeCheckSafeguard, not CheckSafeguard)") + local ev = moveEvent(battle.events) + T.check(ev and not ev.missed, + "the hit itself still lands and is not marked missed") +end + +T.finish("gen2 safeguard bug 1388") diff --git a/tests/engine/gen2_shadow_ball_bgp_bug1269.lua b/tests/engine/gen2_shadow_ball_bgp_bug1269.lua new file mode 100644 index 00000000..82b07123 --- /dev/null +++ b/tests/engine/gen2_shadow_ball_bgp_bug1269.lua @@ -0,0 +1,89 @@ +-- engine/battle_anims/anim_commands.asm:603 BattleAnimCmd_BGP + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local AnimRunner = require("src.battle.gen2.AnimRunner") +local BgEffects = require("src.battle.gen2.BgEffects") +local GbcPalette = require("src.render.GbcPalette") + +-- data/moves/animations.asm:4509 BattleAnim_ShadowBall +local SHADOW_BALL = { + { "2gfx", "BATTLE_ANIM_GFX_EGG", "BATTLE_ANIM_GFX_SMOKE" }, + { "bgp", 0x1b }, + { "sound", 6 * 4 + 2, 0 }, + { "obj", "BATTLE_ANIM_OBJ_SHADOW_BALL", 64, 92, 0x2 }, + { "wait", 32 }, +} + +do + local runner = AnimRunner.new({ + data = { scripts = { SHADOW_BALL = SHADOW_BALL } }, + }) + runner:start("SHADOW_BALL") + T.eq(runner.bg.bgp, BgEffects.NORMAL_PAL, "identity ramp before the script") + T.check(runner:step(), "the script is still running after frame one") + T.eq(runner.bg.bgp, 0x1b, + "anim_bgp $1b lands in wBGP: the inverted ramp the view must apply") + runner.bg:reset() + T.eq(runner.bg.bgp, BgEffects.NORMAL_PAL, + "BattleAnim_RevertPals puts the identity back") +end + +do + T.eq(GbcPalette.BGP_IDENTITY, 0xe4, "dc 3, 2, 1, 0") + local colors = { "c0", "c1", "c2", "c3" } + local out = GbcPalette.remap(colors, 0x1b) + T.eq(out[1], "c3", "$1b is dc 0, 1, 2, 3: colour 0 shows shade 3") + T.eq(out[2], "c2", "colour 1 shows shade 2") + T.eq(out[3], "c1", "colour 2 shows shade 1") + T.eq(out[4], "c0", "colour 3 shows shade 0") + T.check(GbcPalette.remap(colors, 0xe4) == colors, + "the identity byte returns the palette untouched") +end + +-- engine/battle_anims/anim_commands.asm:1293 BattleAnim_SetBGPals +do + local BattleAnimView = require("src.ui.gen2.BattleAnimView") + local palettes = { + pokemon = { + [6] = { normal = { { 200, 100, 50 }, { 80, 40, 20 } } }, + [25] = { normal = { { 230, 200, 40 }, { 150, 90, 20 } } }, + }, + hpBar = { + green = { { 100, 220, 100 }, { 30, 160, 30 } }, + yellow = { { 230, 220, 90 }, { 180, 150, 20 } }, + red = { { 230, 100, 90 }, { 180, 30, 20 } }, + }, + expBar = { { 120, 140, 230 }, { 40, 60, 160 } }, + } + local view = BattleAnimView.new({}, palettes) + local battle = { player = { species = 6 }, enemy = { species = 25 } } + local list = view:panelPalettes(battle) + T.eq(#list, 7, "shades + two mons + three hp bars + exp bar") + local src, dst, count, ambiguous = GbcPalette.remapTable(list, 0x1b) + T.check(count > 0 and count <= GbcPalette.REMAP_MAX, + "the table fits the shader array") + T.eq(ambiguous, 0, "no colour maps two ways") + local function mapped(from) + for i = 1, count do + if src[i][1] == from[1] and src[i][2] == from[2] + and src[i][3] == from[3] then + return dst[i] + end + end + end + local black = mapped({ 255, 255, 255 }) + T.check(black and black[1] == 0 and black[2] == 0 and black[3] == 0, + "white inverts to black") + local white = mapped({ 0, 0, 0 }) + T.check(white and white[1] == 255 and white[2] == 255 and white[3] == 255, + "black inverts to white") + local mid = mapped({ 200, 100, 50 }) + T.check(mid and mid[1] == 80 and mid[2] == 40 and mid[3] == 20, + "the mon's colour 1 shows its colour 2") +end + +T.finish("gen2 shadow ball bgp bug 1269") diff --git a/tests/engine/gen2_shadow_ball_bgp_view_bug1269.lua b/tests/engine/gen2_shadow_ball_bgp_view_bug1269.lua new file mode 100644 index 00000000..132f006f --- /dev/null +++ b/tests/engine/gen2_shadow_ball_bgp_view_bug1269.lua @@ -0,0 +1,86 @@ +-- engine/battle_anims/anim_commands.asm:1293 BattleAnim_SetBGPals +-- +-- gen2_shadow_ball_bgp_bug1269.lua proves the runner lands bg.bgp and that +-- panelPalettes/remapTable would invert correctly; it never calls +-- BattleAnimView:present, which is where #1269 actually lived (the byte +-- was landed but nothing read it). This suite drives present() itself and +-- watches the shader binding around the backdrop draw. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +-- A remap shader that never touches the GPU: present() only needs +-- love.graphics.newShader to succeed so GbcPalette.remapShader() is +-- non-nil, which is the gate `present` checks before it will bake+remap. +local sentShader = { calls = {} } +function sentShader:send(name, ...) self.calls[#self.calls + 1] = name end +love.graphics.newShader = function() return sentShader end + +-- The stub's stand-in Quad has no setViewport/getViewport, which blitRow +-- (the scanline blit present() drives 144 times a frame) needs; the real +-- love.graphics.Quad has both. +love.graphics.newQuad = function(x, y, w, h) + local q = { x = x, y = y, w = w, h = h } + function q:setViewport(x2, y2, w2, h2) self.x, self.y, self.w, self.h = x2, y2, w2, h2 end + function q:getViewport() return self.x, self.y, self.w, self.h end + return q +end + +local T = require("tests.harness") +local GbcPalette = require("src.render.GbcPalette") +local BattleAnimView = require("src.ui.gen2.BattleAnimView") + +local shaderDuringFill = "unset" +local realRectangle = love.graphics.rectangle +love.graphics.rectangle = function(...) + if shaderDuringFill == "unset" then + shaderDuringFill = love.graphics.getShader() + end + return realRectangle(...) +end + +local view = BattleAnimView.new({}, nil) +-- data/moves/animations.asm:4509 BattleAnim_ShadowBall's anim_bgp $1b, with +-- no scroll and no rBGP window queued, is exactly the frame that used to +-- fall through the old `needsCanvas`-only early-out untouched. +local runner = { + bg = { + bgp = 0x1b, + lcdc = nil, + scx = 0, + scy = 0, + lyStart = 0, + lyEnd = 0, + lyBackup = {}, + }, +} + +T.check(love.graphics.getShader() == nil, "no shader bound before present") + +view:present(runner, function() end, nil) + +T.check(shaderDuringFill == sentShader, + "the panel backdrop is drawn through the remap shader, not plainly") +T.check(love.graphics.getShader() == nil, + "the shader is unbound again once present returns, so drawObjects is unaffected") + +local sawRemapSend = false +for _, name in ipairs(sentShader.calls) do + if name == "remapSrc" then sawRemapSend = true end +end +T.check(sawRemapSend, "GbcPalette.useRemap actually sent a remap table, not just bound the shader") + +-- The identity byte must take the plain path: no bake, no shader, ever. +shaderDuringFill = "unset" +local identityRunner = { + bg = { bgp = GbcPalette.BGP_IDENTITY, lcdc = nil, scx = 0, scy = 0, + lyStart = 0, lyEnd = 0, lyBackup = {} }, +} +local plainDrawCalled = false +view:present(identityRunner, function() plainDrawCalled = true end, nil) +T.check(plainDrawCalled, "identity rBGP takes the plain drawBg() path") +T.check(shaderDuringFill == "unset", + "identity rBGP never touches the remap shader") + +T.finish("gen2 shadow ball bgp view bug 1269") diff --git a/tests/engine/gen2_starter_nickname_bug1228.lua b/tests/engine/gen2_starter_nickname_bug1228.lua new file mode 100644 index 00000000..59bcb293 --- /dev/null +++ b/tests/engine/gen2_starter_nickname_bug1228.lua @@ -0,0 +1,90 @@ +-- #1228: `givepoke` with the 3-argument (untrained) form must run +-- GiveANickname_YesNo, the same as a wild catch does. +-- engine/pokemon/move_mon.asm:1632-1645, 1753-1757, 1787 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +local Vm = require("src.script.gen2.Vm") +local Events = require("src.world.gen2.Events") + +-- Elm's `givepoke CYNDAQUIL, 5, BERRY` shape: no trainer operand, so +-- Vm.lua's arg4 fallback reads it as 0 (untrained). +local scripts = { + generation = 2, + ["s:give"] = { + { op = "givepoke", species = 155, level = 5, item = 0, trainer = 0 }, + { op = "end" }, + }, +} + +local mon = { species = 155, level = 5 } +local renamed +local vm = Vm.new(scripts, {}, Events.new(), { + givePoke = function() return mon end, + yesorno = function(onChoose) onChoose(true) end, + showText = function(_, onDone) onDone() end, + specials = { + monName = function(species) return species == 155 and "CYNDAQUIL" or "?" end, + renameMon = function(m, done, opts) + renamed = { mon = m, blank = opts and opts.blank } + done("SPARKY") + end, + }, +}) + +T.check(vm:start("s:give"), "starter script starts") +for _ = 1, 10 do vm:update() end +T.check(not vm:running(), "script finished") +T.eq(mon.nickname, "SPARKY", + "givepoke with trainer=0 runs the nickname prompt and stores the answer") +T.check(renamed ~= nil and renamed.mon == mon, + "renameMon opened on the mon givepoke just handed over") +T.check(renamed.blank == true, + "the keyboard opens blank, same as InitNickname on a fresh catch") + +-- The trade-gift form (trainer ~= 0, e.g. GiftSpearowName's 8-byte +-- Route35GoldenrodGate.asm:30 shape) must never open the keyboard. +local mon2 = { species = 155, level = 5 } +local renamed2 = false +local scripts2 = { + generation = 2, + ["s:give2"] = { + { op = "givepoke", species = 155, level = 5, item = 0, trainer = 1 }, + { op = "end" }, + }, +} +local vm2 = Vm.new(scripts2, {}, Events.new(), { + givePoke = function() return mon2 end, + yesorno = function() error("a trainer-labelled gift must never prompt") end, + showText = function(_, onDone) onDone() end, + specials = { renameMon = function() renamed2 = true end }, +}) +T.check(vm2:start("s:give2"), "trainer-gift script starts") +for _ = 1, 10 do vm2:update() end +T.check(mon2.nickname == nil, "trainer arm leaves the nickname untouched") +T.check(not renamed2, "and never opens the keyboard") + +-- A NO answer, and an all-spaces keyboard entry, both leave the species +-- name standing (_InitString's blank test, home/string.asm:6-30). +local mon3 = { species = 155, level = 5 } +local scripts3 = { + generation = 2, + ["s:give3"] = { + { op = "givepoke", species = 155, level = 5, item = 0, trainer = 0 }, + { op = "end" }, + }, +} +local vm3 = Vm.new(scripts3, {}, Events.new(), { + givePoke = function() return mon3 end, + yesorno = function(onChoose) onChoose(false) end, + showText = function(_, onDone) onDone() end, + specials = { + renameMon = function() error("NO must not open the keyboard") end, + }, +}) +T.check(vm3:start("s:give3"), "NO-answer script starts") +for _ = 1, 10 do vm3:update() end +T.check(mon3.nickname == nil, "answering NO leaves the nickname unset") + +T.finish("gen2_starter_nickname_bug1228") diff --git a/tests/engine/league_pc_bug1282.lua b/tests/engine/league_pc_bug1282.lua new file mode 100644 index 00000000..40a2eeb3 --- /dev/null +++ b/tests/engine/league_pc_bug1282.lua @@ -0,0 +1,94 @@ +-- PKMN LEAGUE (the post-E4 Hall of Fame viewer) never had a screen backing +-- it, so a PC row wired up to open it would have had nowhere to go (#1282). +-- src/ui/LeaguePC.lua is the missing viewer; it resolves through the +-- registry's builtin fallback with no id table edit needed, because every +-- unregistered id falls through to `require("src.ui." .. id)`. +-- engine/menus/league_pc.asm:1, constants/pokemon_data_constants.asm:65 (cap 50) +-- luajit tests/engine/league_pc_bug1282.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 Screens = require("src.ui.Screens") +local LeaguePC = require("src.ui.LeaguePC") + +local function team(species, level) + return { { species = species, level = level, nickname = nil } } +end + +local function newGame(teamCount) + local teams = {} + for i = 1, teamCount do teams[i] = team("RATTATA", i) end + local popped = false + local game = { + data = { pokemon = {}, text = {} }, + save = { hallOfFame = teams }, + stack = { pop = function() popped = true end }, + } + return game, function() return popped end +end + +-- the registry needs no LeaguePC id entry: an unregistered id falls +-- through resolve()'s builtin path straight to src/ui/LeaguePC.lua +local factory = Screens.get({ data = {} }, "LeaguePC") +check(factory == LeaguePC, "Screens.get(\"LeaguePC\") resolves to this module") +check(type(factory.new) == "function", "the resolved factory is constructible") + +-- 60 recorded teams: HOF_TEAM_CAPACITY (50) means the oldest 10 are gone, +-- so the viewer opens on the OLDEST STILL-RECORDED team, index 11 +do + local game = newGame(60) + local pc = LeaguePC.new(game) + eq(pc.teamIndex, 11, "60 teams over a cap of 50 starts at team 11 (60-50+1)") + eq(pc.monIndex, 1, "starts on the first mon of that team") + check(pc:currentMon() ~= nil, "a current mon is resolved") +end + +-- A steps through every remaining team (49 more presses, 11 -> 60), then +-- one more A on the last team's only mon closes the whole viewer +do + local game, wasPopped = newGame(60) + local pc = LeaguePC.new(game) + local doneCalled = false + pc.onDone = function() doneCalled = true end + for _ = 1, 49 do + game.input = { wasPressed = function(_, b) return b == "a" end } + pc:update(0) + end + eq(pc.teamIndex, 60, "49 A-presses walk from team 11 to team 60") + check(not wasPopped(), "the viewer is still open on the last team") + game.input = { wasPressed = function(_, b) return b == "a" end } + pc:update(0) + check(wasPopped(), "one more A on the last team's last mon closes the viewer") + check(doneCalled, "onDone fires on close") +end + +-- B always closes immediately, from any position +do + local game, wasPopped = newGame(3) + local pc = LeaguePC.new(game) + game.input = { wasPressed = function(_, b) return b == "b" end } + pc:update(0) + check(wasPopped(), "B closes the viewer") +end + +-- an empty Hall of Fame (no wins recorded yet, or the extreme edge case of +-- a save with the row reachable but no completed run) must not crash: A on +-- a nil current mon closes cleanly instead of indexing into nothing +do + local game, wasPopped = newGame(0) + local pc = LeaguePC.new(game) + eq(pc.teamIndex, 1, "an empty roster clamps teamIndex to 1, not 0 or negative") + check(pc:currentMon() == nil, "there is no current mon") + local ok = pcall(function() + game.input = { wasPressed = function(_, b) return b == "a" end } + pc:update(0) + end) + check(ok, "A on an empty Hall of Fame does not raise") + check(wasPopped(), "...and closes the viewer instead") +end + +T.finish("league pc bug 1282") diff --git a/tests/engine/naming_screen_opacity_bug1329.lua b/tests/engine/naming_screen_opacity_bug1329.lua new file mode 100644 index 00000000..f34efc49 --- /dev/null +++ b/tests/engine/naming_screen_opacity_bug1329.lua @@ -0,0 +1,86 @@ +-- The NEW NAME / preset box drew on top of a full letter grid because +-- NamingScreen stayed isOpaque while the preset Menu was up, so the stack's +-- visibleBase never fell through to the screen underneath (#1329). +-- engine/movie/oak_speech/oak_speech2.asm:1 +-- luajit tests/engine/naming_screen_opacity_bug1329.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") + +package.loaded["src.core.Sound"] = { play = function() end } + +local StateStack = require("src.core.StateStack") +local NamingScreen = require("src.ui.NamingScreen") + +local function newGame() + local stack = setmetatable({}, { __index = StateStack }) + stack:init() + local game = { data = {} } + game.stack = stack + game.input = { + queue = {}, + wasPressed = function(self, btn) return self.queue[btn] or false end, + isDown = function() return false end, + } + return game, stack +end + +local game, stack = newGame() + +-- stand-in for OakSpeech: opaque, and the state a fixed background lives on +local backgroundDraws = 0 +local background = { isOpaque = true, draw = function() backgroundDraws = backgroundDraws + 1 end } +stack:push(background) + +local result = { name = nil } +local ns = NamingScreen.new(game, + { presets = { "RED", "ASH", "JACK" }, onDone = function(n) result.name = n end }) +stack:push(ns) -- StateStack:push calls ns:enter(), which pushes the preset Menu + +eq(ns.choosing, true, "the screen marks itself as choosing a preset") +eq(ns.isOpaque, false, "isOpaque is shadowed false while the preset box is up") + +local menu = stack:top() +check(menu ~= nil and menu ~= ns, "the preset Menu is on top of the naming screen") +eq(#menu.items, 4, "NEW NAME plus the three presets") +eq(menu.items[1].label, "NEW NAME", "row 1 is NEW NAME") + +eq(stack:visibleBase(), 1, "the background (index 1) is visible, not the naming screen") + +backgroundDraws = 0 +stack:draw() +eq(backgroundDraws, 1, "the background actually got a draw call this frame") + +-- picking NEW NAME (row 1) restores the grid's normal opacity +menu.index = 1 +menu.game.input.queue.a = true +menu:update(0) +menu.game.input.queue.a = false + +check(stack:top() == ns, "the Menu popped itself, the naming screen is back on top") +eq(ns.choosing, nil, "choosing cleared") +eq(rawget(ns, "isOpaque"), nil, "the instance field is cleared, unshadowing the class default") +eq(ns.isOpaque, true, "isOpaque now reads true again, through the class default") +eq(stack:visibleBase(), 2, "the naming screen itself is now the opaque base") + +-- a preset pick instead closes the whole naming flow with that name +local game2, stack2 = newGame() +local background2 = { isOpaque = true, draw = function() end } +stack2:push(background2) +local result2 = { name = nil } +local ns2 = NamingScreen.new(game2, + { presets = { "RED", "ASH", "JACK" }, onDone = function(n) result2.name = n end }) +stack2:push(ns2) +local menu2 = stack2:top() +eq(menu2.items[4].label, "JACK", "row 4 is the third preset") +menu2.index = 4 -- "JACK" +menu2.game.input.queue.a = true +menu2:update(0) + +eq(result2.name, "JACK", "selecting a preset pops the whole flow with that name") +eq(#stack2.states, 1, "only the background remains on the stack") + +T.finish("naming screen opacity bug 1329") diff --git a/tests/engine/npc_walk_cadence_bug1303.lua b/tests/engine/npc_walk_cadence_bug1303.lua new file mode 100644 index 00000000..6a547cfa --- /dev/null +++ b/tests/engine/npc_walk_cadence_bug1303.lua @@ -0,0 +1,96 @@ +-- The walking-NPC animation cadence, which the port ran at half the cart's +-- rate (#1303). UpdateSpriteInWalkingAnimation advances one animation frame +-- every 4 fixed steps regardless of how long the whole cell takes +-- (engine/overworld/movement.asm:301), so a 32-frame NPC cell must show the +-- same two-pulse cadence Player:pose already shows across its own 16. +-- luajit tests/engine/npc_walk_cadence_bug1303.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +love = require("tests.love_stub") + +local NPC = require("src.world.NPC") + +local DATA = { + sprites = { + SPRITE_TEST_NPC = { image = "fixture_npc.png", frames = 6, walker = true }, + }, +} + +local function newNpc() + return NPC.new(DATA, "TEST_MAP", { + index = 1, x = 5, y = 5, sprite = "SPRITE_TEST_NPC", range = "ANY_DIR", + movement = "STAY", + }) +end + +-- ---- the pure cadence: two walk pulses across one 32-frame NPC step ------ +do + local npc = newNpc() + npc.moving = true + local phases = {} + for clock = 0, 31 do + npc.animClock = clock + phases[clock] = npc:walkPhase() + end + local risingEdges = 0 + for clock = 1, 31 do + if phases[clock] == 1 and phases[clock - 1] == 0 then + risingEdges = risingEdges + 1 + end + end + eq(risingEdges, 2, "a 32-frame NPC cell shows two walk pulses, not one") + eq(phases[0], 0, "frame 0 stands") + eq(phases[4], 1, "frame 4 opens the first walk pulse") + eq(phases[11], 1, "frame 11 is still the first pulse") + eq(phases[12], 0, "frame 12 closes it back to standing") + eq(phases[20], 1, "frame 20 opens the second walk pulse") + eq(phases[27], 1, "frame 27 is still the second pulse") + eq(phases[28], 0, "frame 28 closes the cycle back to standing") +end + +-- ---- the flip half-cycle: one flip per 16-frame half, not per whole cell - +do + local npc = newNpc() + npc.moving = true + npc.animClock = 0 + local _, _, _, _, _, flip0 = npc:pose() + npc.animClock = 15 + local _, _, _, _, _, flip15 = npc:pose() + npc.animClock = 16 + local _, _, _, _, _, flip16 = npc:pose() + npc.animClock = 31 + local _, _, _, _, _, flip31 = npc:pose() + check(not flip0, "the first half of the cell is unflipped") + check(not flip15, "still unflipped just before frame 16") + check(flip16, "frame 16 flips, matching Player:pose's own half-cycle") + check(flip31, "and stays flipped through the second half") +end + +-- ---- standing keeps the externally-written flip (Pikachu idle contract) -- +do + local npc = newNpc() + npc.moving = false + npc.stepFlip = true + local _, _, _, _, phase, flip = npc:pose() + eq(phase, 0, "a standing NPC has no walk phase") + check(flip, "and pose() reads stepFlip back exactly, not the moving formula") +end + +-- ---- the wiring: NPC:update advances animClock alongside progress ------- +do + local npc = newNpc() + npc.facing = "down" + npc.moving = true + npc.targetX, npc.targetY = npc.cellX, npc.cellY + 1 + local map = {} + for i = 1, 16 do + npc:update(map, {}) + eq(npc.animClock, i, "animClock ticks once per update, step " .. i) + end + check(npc.moving, "still mid-cell at 16 of the 32 ticks") +end + +T.finish("npc walk cadence bug1303") diff --git a/tests/engine/nurse_bow_bug995.lua b/tests/engine/nurse_bow_bug995.lua index f4cd69be..845443b2 100644 --- a/tests/engine/nurse_bow_bug995.lua +++ b/tests/engine/nurse_bow_bug995.lua @@ -65,7 +65,7 @@ T.check(pushed[1].text:find(BYE, 1, true) == nil, pushed[1].onDone() T.eq(#pushed, 1, "the farewell waits for the bow") -T.eq(nurse.frameOverride, 3, "image index $1: the nurse bows") +T.eq(nurse.frameOverride, 1, "image index $14: the nurse bows") T.check(fakeSelf.emote ~= nil, "the bow is a world hold, not a text pause") local hold = fakeSelf.emote or {} T.eq(hold.npc, nurse, "the hold is anchored on the nurse") diff --git a/tests/engine/oaks_lab_rival_faces_down_bug1279.lua b/tests/engine/oaks_lab_rival_faces_down_bug1279.lua new file mode 100644 index 00000000..f10e2ceb --- /dev/null +++ b/tests/engine/oaks_lab_rival_faces_down_bug1279.lua @@ -0,0 +1,69 @@ +-- #1279: the rival must face DOWN at his own table cell before the taunt, +-- same as SetSpriteFacingDirectionAndDelay does before DisplayTextID -- not +-- just the player turning to face him. +-- scripts/OaksLab.asm:347-351 (Red/Blue); pokeyellow scripts/OaksLab.asm:311-315 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +local function fakeOw(rival) + return { + npcByIndex = function(_, i) return i == 1 and rival or nil end, + map = { + inBounds = function() return true end, + isWalkableCell = function() return true end, + }, + runner = { run = function(_, rows, opts) return rows, opts end }, + } +end + +-- capture the rows the runner was handed, since `run` is only a stub +local function captureRun(ow) + local captured + ow.runner.run = function(_, rows, opts) captured = rows return true end + return function() return captured end +end + +local function baseGame() + return { + save = { + flags = { + EVENT_GOT_STARTER = true, + EVENT_BATTLED_RIVAL_IN_OAKS_LAB = false, + EVENT_CHOSE_BULBASAUR = true, + }, + }, + } +end + +-- Red/Blue +do + local M = assert(loadfile("data/scripts/oaks_lab.lua"))() + local rival = { id = "rival" } + local ow = fakeOw(rival) + local getRows = captureRun(ow) + local ok = M.onStep(baseGame(), ow, 4, 6) + T.check(ok == true, "onStep claims the rival-challenge step") + local rows = getRows() + T.check(rows ~= nil, "the challenge rows reached the runner") + T.same(rows[1], { "face_object", 1, "down" }, + "the FIRST row faces the rival down at his table (#1279)") + T.eq(rows[2][1], "face_player_dir", "the player-facing row still follows it") + T.eq(rows[2][2], "up", "and still turns the player up, unchanged") +end + +-- Yellow +do + local M = assert(loadfile("data/scripts/oaks_lab_yellow.lua"))() + local rival = { id = "rival" } + local ow = fakeOw(rival) + local getRows = captureRun(ow) + local ok = M.onStep(baseGame(), ow, 4, 6) + T.check(ok == true, "yellow onStep claims the rival-challenge step") + local rows = getRows() + T.check(rows ~= nil, "the yellow challenge rows reached the runner") + T.same(rows[1], { "face_object", 1, "down" }, + "yellow's first row faces the rival (object 1) down too (#1279)") +end + +T.finish("oaks_lab_rival_faces_down_bug1279") diff --git a/tests/engine/pewter_museum_escort_bug1391.lua b/tests/engine/pewter_museum_escort_bug1391.lua new file mode 100644 index 00000000..ba99ebdc --- /dev/null +++ b/tests/engine/pewter_museum_escort_bug1391.lua @@ -0,0 +1,73 @@ +-- #1391: the Pewter museum guide's lockstep walk, exercised from all four +-- trigger cells around him, the way PewterGuys (engine/events/ +-- pewter_guys.asm:1-49) builds the preamble and PewterCitySuperNerd1Shows +-- PlayerMuseumScript (scripts/PewterCity.asm:47-113) walks it out. +-- +-- `museumEscort` had zero consumers and zero test coverage before this: a +-- future edit to the RLE tables or the preamble map could silently break +-- the walk and nothing would fail. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +local M = assert(loadfile("data/scripts/flavor/pewter_city.lua"))() +local esc = M.PEWTER_CITY.museumEscort +T.check(type(esc) == "table", "museumEscort is exported") +T.check(type(esc.plan) == "function", "museumEscort.plan is exported") +T.check(type(esc.guySteps) == "table", "museumEscort.guySteps is exported") + +-- RLEList_PewterMuseumGuy (engine/overworld/auto_movement.asm:199-204): +-- UP 6, LEFT 13, UP 3, LEFT 1 -- 23 steps by content, not just by count. +local wantGuySteps = {} +for _ = 1, 6 do wantGuySteps[#wantGuySteps + 1] = "up" end +for _ = 1, 13 do wantGuySteps[#wantGuySteps + 1] = "left" end +for _ = 1, 3 do wantGuySteps[#wantGuySteps + 1] = "up" end +wantGuySteps[#wantGuySteps + 1] = "left" +T.same(esc.guySteps, wantGuySteps, + "guySteps is exactly UP x6, LEFT x13, UP x3, LEFT x1") + +local function apply(x, y, dirs) + for _, d in ipairs(dirs) do + if d == "up" then y = y - 1 + elseif d == "down" then y = y + 1 + elseif d == "left" then x = x - 1 + elseif d == "right" then x = x + 1 end + end + return x, y +end + +-- PewterMuseumGuyCoords (engine/events/pewter_guys.asm:58-75): the four +-- cells adjacent to the guy's spawn (27,17), each with its own preamble. +local triggers = { { 27, 18 }, { 27, 16 }, { 26, 17 }, { 28, 17 } } +for _, c in ipairs(triggers) do + local plan = esc.plan(c[1], c[2]) + T.check(plan ~= nil, + ("(%d,%d) is a real trigger cell and must produce a plan"):format(c[1], c[2])) + T.eq(#plan.steps, 23, + ("(%d,%d): 23-step walk, same length regardless of approach side") + :format(c[1], c[2])) + T.eq(plan.guyHeadStart, 0, + ("(%d,%d): no NO_INPUT head padding in the museum preamble") + :format(c[1], c[2])) + + local px, py = apply(c[1], c[2], plan.steps) + T.check(px == 14 and py == 8, + ("(%d,%d): player's walk ends at (14,8), by the museum door") + :format(c[1], c[2])) + + local guySub = {} + for i = plan.guyHeadStart + 1, plan.guyHeadStart + #plan.steps do + guySub[#guySub + 1] = esc.guySteps[i] + end + local gx, gy = apply(27, 17, guySub) + T.check(gx == 13 and gy == 8, + ("(%d,%d): guy's walk ends at (13,8), beside the player") + :format(c[1], c[2])) +end + +-- Any cell that is not one of the four adjacent trigger cells must not +-- start the escort at all. +T.check(esc.plan(10, 10) == nil, "a non-adjacent cell returns no plan") +T.check(esc.plan(27, 17) == nil, "the guy's own cell is not a trigger either") + +T.finish("pewter_museum_escort_bug1391") diff --git a/tests/engine/pokedex_entry_layout_bug1341.lua b/tests/engine/pokedex_entry_layout_bug1341.lua new file mode 100644 index 00000000..f208bdb3 --- /dev/null +++ b/tests/engine/pokedex_entry_layout_bug1341.lua @@ -0,0 +1,95 @@ +-- The Pokedex entry page laid out its fields at the port's own invented +-- coordinates instead of the cart's, ran the whole description together on +-- one page with no trailing full stop, and never let A/B advance past page +-- one (#1341). +-- engine/menus/pokedex.asm:399, home/text.asm:245 (), :204 () +-- luajit tests/engine/pokedex_entry_layout_bug1341.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: DexEntryMenu 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, +} + +local DexEntryMenu = require("src.ui.DexEntryMenu") + +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 game = { + data = { + pokemon = { + BULBASAUR = { + id = "BULBASAUR", + name = "BULBASAUR", + dex = 1, + dexEntry = { + kind = "SEED POKEMON", + heightFt = 2, heightIn = 4, weight = 69, + text = "_BulbasaurDexEntry", + }, + }, + }, + text = { + -- \f is the extractor's break; two pages, three lines each + _BulbasaurDexEntry = "A strange seed was\nplanted on its\nback at birth\f" + .. "The plant sprouts\nand grows with\nthis POKEMON", + }, + constants = { dexDigits = 3 }, + }, + save = { pokedex = { owned = { BULBASAUR = true } } }, +} + +local ns = DexEntryMenu.new(game, "BULBASAUR") +eq(ns.pageCount, 2, "the entry has two -separated pages") +eq(ns.page, 1, "starts on page 1") + +ns:draw() +check(hasText("BULBASAUR", 72, 16), "name at (72,16), not the port's old (72,8)") +check(hasText("SEED POKEMON", 72, 32), "kind at (72,32)") +check(hasText("No.001", 16, 64), "dex number under the pic, at (16,64)") +check(hasText("HT 2\226\128\178" .. "04\226\128\179", 72, 48), "HT at (72,48)") +check(hasText("WT 6.9lb", 72, 64), "WT at (72,64)") +check(hasText("A strange seed was", 8, 88), "page 1 line 1 at y=88 (row 11)") +check(hasText("planted on its", 8, 104), "page 1 line 2 at y=104") +check(hasText("back at birth", 8, 120), "page 1 line 3, unmodified: not the last page") +check(hasCode(0xEE, 144, 128), "the more-below arrow shows on a non-final page") + +-- A on a non-final page turns it, it does not close the screen +local popped, doneCalled = false, false +game.stack = { pop = function() popped = true end } +game.input = { wasPressed = function(_, b) return b == "a" end } +ns.onDone = function() doneCalled = true end +ns:update(0) +eq(ns.page, 2, "A advances to page 2 instead of closing") +check(not popped, "the screen did not pop on a non-final page") + +calls = {} +ns:draw() +check(hasText("this POKEMON.", 8, 120), "the final page's last line gets the trailing full stop") +check(not hasCode(0xEE, 144, 128), "no more-below arrow on the last page") + +-- A on the final page closes the screen +ns:update(0) +check(popped, "A on the final page pops the screen") +check(doneCalled, "onDone fires once the last page closes") + +T.finish("pokedex entry layout bug 1341") diff --git a/tests/engine/town_map_player_sprite_bug1344.lua b/tests/engine/town_map_player_sprite_bug1344.lua new file mode 100644 index 00000000..e41dea43 --- /dev/null +++ b/tests/engine/town_map_player_sprite_bug1344.lua @@ -0,0 +1,86 @@ +-- The TOWN MAP drew a blinking black square for the player instead of their +-- walk sprite, because the marker was a placeholder rectangle instead of the +-- OAM sprite the cart draws (#1344). +-- engine/items/town_map.asm:347 +-- luajit tests/engine/town_map_player_sprite_bug1344.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 TownMap = require("src.ui.TownMap") + +local function newGame() + return { + data = { + field = { + townMap = { PALLET_TOWN = { x = 1, y = 2, name = "PALLET TOWN" } }, + playerSprites = { walk = "SPRITE_RED" }, + -- no townMap.background: forces the stale-asset fallback draw path, + -- which is the one #1344's repro screenshot came from + }, + sprites = { SPRITE_RED = { image = "assets/generated/sprites/red_walk.png" } }, + maps = {}, + }, + save = {}, + overworld = { map = { id = "PALLET_TOWN" } }, + } +end + +local game = newGame() +local tm = TownMap.new(game, {}) + +eq(tm.mode, "grid", "one located entry puts the screen in grid mode") +check(tm.bg == nil, "no background.map means the stale-asset fallback draws") +check(tm.playerLoc ~= nil, "the player's map resolved to a location") +check(tm.playerSheet ~= nil, + "TownMap.new resolved the walk sheet (the fix this test guards)") + +-- capture what :draw() actually paints, without a real screen +local draws, rects = {}, {} +local realDraw, realRect = love.graphics.draw, love.graphics.rectangle +love.graphics.draw = function(img, quadOrX, x, y) + draws[#draws + 1] = { img = img, quad = quadOrX, x = x, y = y } +end +love.graphics.rectangle = function(mode, x, y, w, h) + rects[#rects + 1] = { mode = mode, x = x, y = y, w = w, h = h } +end + +tm.blink = 0 -- (0 < 20): the player marker is in its "on" blink phase +tm:draw() + +love.graphics.draw = realDraw +love.graphics.rectangle = realRect + +local function drewSprite() + for _, d in ipairs(draws) do + if d.img == tm.playerSheet and d.quad == tm.playerQuad then + return d + end + end + return nil +end + +local spriteDraw = drewSprite() +check(spriteDraw ~= nil, "the player's walk sprite was drawn, not a placeholder") +if spriteDraw then + -- engine/items/town_map.asm:449 WriteTownMapSpriteOAM's -4,-3 carry quirk + eq(spriteDraw.x, tm.playerLoc.x * 8 - 4, "sprite x is markerXY - 4") + eq(spriteDraw.y, tm.playerLoc.y * 8 - 3, "sprite y is markerXY - 3") +end + +local function blackDotAtPlayer() + for _, r in ipairs(rects) do + if r.mode == "fill" and r.w == 4 and r.h == 4 + and r.x == tm.playerLoc.x * 8 + 2 and r.y == tm.playerLoc.y * 8 + 2 then + return true + end + end + return false +end +check(not blackDotAtPlayer(), + "the old 4x4 placeholder square is not drawn once a sprite is available") + +T.finish("town map player sprite bug 1344") diff --git a/tests/gen2_wild_cooldown_bug1229_test.lua b/tests/gen2_wild_cooldown_bug1229_test.lua new file mode 100644 index 00000000..139a163f --- /dev/null +++ b/tests/gen2_wild_cooldown_bug1229_test.lua @@ -0,0 +1,116 @@ +-- The post-battle grace period, which the port never re-armed after an +-- UNSCRIPTED wild battle (#1229): World:tryWildEncounter's own +-- self:startBattle({ wild = wild }) carries no onDone, so no VM resume ever +-- ran the script's own `reloadmapafterbattle` (which is where the cart's +-- SetUpFiveStepWildEncounterCooldown lives, engine/overworld/events.asm:1158- +-- 1162), and the counter sat at zero for the very next step. What is +-- asserted here is the real World:startBattle -> onDone chain, same as +-- tests/gen2_canlose_test.lua exercises for the loss arm. +-- +-- GOLD_CACHE=".../gold" luajit tests/gen2_wild_cooldown_bug1229_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 wild cooldown") +local check, eq = S.check, S.eq + +local World = require("src.world.gen2.World") +local Mon = require("src.battle.gen2.Mon") +local Screens = require("src.ui.Screens") + +local cache = os.getenv("GOLD_CACHE") +if not cache then + local home = os.getenv("HOME") or "" + cache = home .. "/Library/Application Support/LOVE/gold-dev/gold" +end +local probe = io.open(cache .. "/data/generated/pokemon.lua", "r") +if not probe then + check(true, "gold cache absent (SKIP)") + S.finish() + return +end +probe:close() + +local function loadLua(rel) return assert(loadfile(cache .. "/" .. rel))() end +local pokemon = loadLua("data/generated/pokemon.lua") +local moves = loadLua("data/generated/moves.lua") + +-- The battle screen as a registry fake, same shape gen2_canlose_test uses: +-- the real World:startBattle pushes it and parks its onDone for the test. +-- Screens.get caches by id process-wide, so a suite dofile'd earlier in +-- tests/run_tests.lua can leave a stale "Gen2BattleState" behind. +Screens.invalidate() +local battleDone +local registry = { + Gen2BattleState = { new = function(_g, opts) + battleDone = opts.onDone + return { screenId = "Gen2BattleState" } + end }, +} + +local function makeStack() + local stack = { items = {} } + function stack:push(inst) self.items[#self.items + 1] = inst end + function stack:pop() + local top = self.items[#self.items] + self.items[#self.items] = nil + return top + end + return stack +end + +local function makeWorld() + battleDone = nil + local game = { + data = { audio = {}, screens = registry, pokemon = pokemon, moves = moves }, + -- No roamer state and no map at all: World:pushBattleTransition needs + -- self.map to push a wipe and finds none, so pushBattle() runs straight + -- away, exactly like a headless battle would. World:restoreMapMusic and + -- World:battleMusicContext both already guard a nil self.map. + save = { player = { name = "GOLD", money = 3000 }, party = {} }, + stack = makeStack(), + } + local mon = Mon.new(game.data, "CYNDAQUIL", 5) + check(mon ~= nil, "the cache can build the player's starter") + game.save.party[1] = mon + local w = World.new(game) + return w, game +end + +-- ---- the unscripted path itself: World:tryWildEncounter's own call ------- +do + local w, game = makeWorld() + local wild = Mon.new(game.data, "MAGIKARP", 10) + check(wild ~= nil, "the cache can build the wild mon") + -- A grace period already partway spent, so a re-arm is the only way to + -- land back on 5. + w.wildCooldown = 2 + + check(w:startBattle({ wild = wild }), + "the unscripted wild path starts a battle") + check(battleDone ~= nil, "the battle screen is up") + + battleDone("win") + eq(w.wildCooldown, 5, + "reloadmapafterbattle's SetUpFiveStepWildEncounterCooldown re-arms " .. + "the counter (events.asm:1158-1162), even with no script waiting") +end + +-- ---- the counter itself, once re-armed: four blocked steps then a roll -- +do + local w = makeWorld() + w.wildCooldown = 5 + for step = 1, 4 do + check(w:wildCooldownStep(), + "step " .. step .. " of the grace period is still blocked") + end + check(not w:wildCooldownStep(), "the fifth step may roll") +end + +-- ---- a battle NOTHING started re-arms nothing: only startBattle's onDone - +do + local w = makeWorld() + w.wildCooldown = 0 + check(w.wildCooldown == 0, "a fresh world never re-arms on its own") +end + +S.finish() diff --git a/tests/parity_ss_anne_departure.lua b/tests/parity_ss_anne_departure.lua index b5ac20d1..f7a196d3 100644 --- a/tests/parity_ss_anne_departure.lua +++ b/tests/parity_ss_anne_departure.lua @@ -183,32 +183,24 @@ do end end check(dirsEqual(bow, { 4, 3, 2, 1 }), "the bow sails west a column a step") - -- 7 is missing because that is the block the player is stood on - check(dirsEqual(wake, { 8, 6, 5, 4, 3, 2, 1 }), + check(dirsEqual(wake, { 8, 7, 6, 5, 4, 3, 2, 1 }), "water closes in astern, stern column first") - -- EraseSSAnne leaves the player's own block alone ("south of the player - -- and won't be redrawn"), so he never stands on water on the way out - local pbx, pby = 7, 1 for _, r in ipairs(rowsOfKind(rows, "replace_block")) do - check(not (r[2] == pbx and r[3] == pby), - "the block under the player is never rewritten") check(r[2] >= 1 and r[2] <= DOCK_HULL.x1, "the slide stays inside the dock's water, off the pier column 0") end - -- she has to end up gone: every hull block bar the player's is water by - -- the last edit that touches it + -- scripts/VermilionDock.asm:182-203: the tile fill covers the whole ship, + -- the gangway block under the player included (#1211) local final = {} for _, r in ipairs(rowsOfKind(rows, "replace_block")) do final[r[2] .. "," .. r[3]] = r[4] end for bx = DOCK_HULL.x0, DOCK_HULL.x1 do for by = DOCK_HULL.y0, DOCK_HULL.y1 do - if not (bx == pbx and by == pby) then - check(WATER[final[bx .. "," .. by]], - ("hull block (%d,%d) ends as open water"):format(bx, by)) - end + check(WATER[final[bx .. "," .. by]], + ("hull block (%d,%d) ends as open water"):format(bx, by)) end end diff --git a/tests/parity_ss_anne_guard.lua b/tests/parity_ss_anne_guard.lua index 9700e9ff..8ad004c5 100644 --- a/tests/parity_ss_anne_guard.lua +++ b/tests/parity_ss_anne_guard.lua @@ -178,8 +178,8 @@ do end check(sawSurf, "departure plays Music_Surfing") check(ow._queued ~= nil, "departure queues the sail-away script") - -- #360: the surf override must NOT ride into Vermilion City, she has to - -- sail west block by block, and the block under the player stays dry + -- #360: the surf override must NOT ride into Vermilion City, and she + -- sails west block by block local kept, horns, slid, underPlayer = false, 0, 0, false for _, row in ipairs(ow._queued or {}) do if row[1] == "play_music" and row[3] and row[3].keep then kept = true end @@ -194,7 +194,8 @@ do eq(kept, false, "departure lets VERMILION_CITY's own theme take the warp") eq(horns, 2, "the horn blows before and after she sails") check(slid > 8, "she sails west block by block instead of vanishing") - eq(underPlayer, false, "the block under the player is never watered over") + -- scripts/VermilionDock.asm:182-203 (#1211) + eq(underPlayer, true, "the gangway block under the player is erased too") end -- Captain rub jingle: play_once Music_PkmnHealed sits after the rub text. diff --git a/tests/parity_true_color_ui.lua b/tests/parity_true_color_ui.lua index 0bfa5015..627cabb5 100644 --- a/tests/parity_true_color_ui.lua +++ b/tests/parity_true_color_ui.lua @@ -49,7 +49,10 @@ local dex = DexEntryMenu.new(game, "PIKACHU") check(dex.spriteTrueColor == true, "Pokedex keeps a Pokemon sprite's trueColor flag") local dexRects = uiRects(function() dex:draw() end) -local dx, dy = 8, math.max(0, 60 - dex.sprite:getHeight()) +-- engine/menus/pokedex.asm:503: the pic sits in the 7x7 window at (8,8) +local dw, dh = dex.sprite:getDimensions() +local dx = 8 + math.floor((8 - dw / 8) / 2) * 8 +local dy = 8 + (7 - dh / 8) * 8 check(#dexRects == 1 and dexRects[1].x == dx and dexRects[1].y == dy and dexRects[1].w == dex.sprite:getWidth() and dexRects[1].h == dex.sprite:getHeight(), diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 6e2ad123..24b6a5fe 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -3559,6 +3559,7 @@ runSuites({ "tests/gen2_hof_continue_test.lua", "tests/gen2_pokecenter_stairs_test.lua", "tests/gen2_canlose_test.lua", + "tests/gen2_wild_cooldown_bug1229_test.lua", "tests/gen2_pc_screens_test.lua", "tests/gen2_badge_boosts_test.lua", "tests/gen2_held_items_test.lua",