From 0f581e2f6989fd584242ff744fbaeffe589ae819 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 30 Jul 2026 07:41:41 -0400 Subject: [PATCH] so many bugs i cannot even breathe --- data/scripts/celadon_eevee.lua | 62 +++ data/scripts/story.lua | 90 ++++- data/scripts/story3.lua | 215 +++++------ data/scripts/story5.lua | 23 +- main.lua | 17 + src/battle/BattleState.lua | 166 ++++++-- src/battle/EffectRegistry.lua | 8 + src/battle/MoveEffects.lua | 6 + src/core/ChipAudio.lua | 37 +- src/core/Music.lua | 4 + src/core/Sound.lua | 4 + src/import/RomImporter.lua | 2 +- src/render/PaletteFX.lua | 62 ++- src/render/Renderer.lua | 22 +- src/render/SpriteRenderer.lua | 37 +- src/render/TileRenderer.lua | 17 +- src/save_convert/GenSave.lua | 58 +++ src/script/Commands.lua | 16 +- src/ui/BagMenu.lua | 8 +- src/ui/OakSpeech.lua | 5 +- src/ui/PartyMenu.lua | 39 +- src/ui/TitleState.lua | 23 +- src/update/Check.lua | 10 + src/world/FieldDefaults.lua | 17 + src/world/OverworldController.lua | 310 ++++++++------- src/world/Player.lua | 30 +- .../drivers/battle_item_turn_bug379_test.lua | 295 +++++++++++++++ tests/drivers/battle_shake_bug354_test.lua | 273 ++++++++++++++ tests/drivers/bide_anim_bug375_test.lua | 205 ++++++++++ .../celadon_roof_readables_bug391_test.lua | 169 +++++++++ tests/drivers/elevator_panel_bug395_test.lua | 139 +++++++ tests/drivers/elevator_test.lua | 47 --- tests/drivers/exit_mat_bug378_test.lua | 123 ++++++ tests/drivers/fanfare_hold_bug398_test.lua | 158 ++++++++ .../drivers/field_move_layer_bug385_test.lua | 187 +++++++++ tests/drivers/fishing_pose_bug384_test.lua | 219 +++++++++++ tests/drivers/fishing_rod_bug321_test.lua | 3 +- tests/drivers/hideout_gate_bug372_test.lua | 118 ++++++ tests/drivers/multihit_bug394_test.lua | 184 +++++++++ .../drivers/oak_nidorino_flip_bug397_test.lua | 149 ++++++++ tests/drivers/parcel_jingle_bug374_test.lua | 102 +++++ tests/drivers/quit_process_bug339_test.lua | 91 +++++ .../drivers/reds_house_stairs_bug230_test.lua | 5 +- .../rock_tunnel_dark_bake_bug383_test.lua | 357 ++++++++++++++++++ .../drivers/rock_tunnel_dark_bug322_test.lua | 37 +- tests/drivers/route.lua | 56 +-- tests/drivers/silph_rockets_bug392_test.lua | 232 ++++++++++++ tests/drivers/silph_tm36_bug393_test.lua | 158 ++++++++ .../drivers/ss_anne_departure_bug360_test.lua | 180 +++++++++ tests/drivers/title_seam_bug373_test.lua | 108 ++++++ tests/engine/fanfare_music_hold.lua | 176 +++++++++ tests/engine/give_item_jingle.lua | 170 +++++++++ tests/engine/multihit_hp_drain.lua | 96 +++++ tests/engine/quit_thread_shutdown.lua | 209 ++++++++++ tests/engine/save_convert_extra_flags.lua | 199 ++++++++++ tests/engine/title_zone_seams.lua | 222 +++++++++++ tests/mod_audio_tests.lua | 2 +- tests/mod_ui_tests.lua | 7 +- tests/mod_world_tests.lua | 15 +- tests/parity_C.lua | 61 +-- tests/parity_I_M.lua | 15 +- tests/parity_applying_attack_anim.lua | 219 +++++++++++ tests/parity_battle_item_turn.lua | 259 +++++++++++++ tests/parity_bide_anim.lua | 180 +++++++++ tests/parity_celadon_roof_readables.lua | 135 +++++++ tests/parity_elevator_panel.lua | 207 ++++++++++ tests/parity_field_move_layering.lua | 184 +++++++++ tests/parity_hideout_gate.lua | 182 +++++++++ tests/parity_silph_rockets.lua | 217 +++++++++++ tests/parity_silph_tm36_pre.lua | 110 ++++++ tests/parity_ss_anne_departure.lua | 275 ++++++++++++++ tests/parity_ss_anne_guard.lua | 23 +- tests/parity_warp_after_warp_step.lua | 48 ++- tests/parity_warp_standing_flag.lua | 142 +++++++ tools/make_rom_manifest.py | 5 +- tools/rom_manifest.json | 5 + tools/rom_manifest_blue.json | 5 + tools/rom_manifest_yellow.json | 1 + 78 files changed, 7729 insertions(+), 523 deletions(-) create mode 100644 tests/drivers/battle_item_turn_bug379_test.lua create mode 100644 tests/drivers/battle_shake_bug354_test.lua create mode 100644 tests/drivers/bide_anim_bug375_test.lua create mode 100644 tests/drivers/celadon_roof_readables_bug391_test.lua create mode 100644 tests/drivers/elevator_panel_bug395_test.lua delete mode 100644 tests/drivers/elevator_test.lua create mode 100644 tests/drivers/exit_mat_bug378_test.lua create mode 100644 tests/drivers/fanfare_hold_bug398_test.lua create mode 100644 tests/drivers/field_move_layer_bug385_test.lua create mode 100644 tests/drivers/fishing_pose_bug384_test.lua create mode 100644 tests/drivers/hideout_gate_bug372_test.lua create mode 100644 tests/drivers/multihit_bug394_test.lua create mode 100644 tests/drivers/oak_nidorino_flip_bug397_test.lua create mode 100644 tests/drivers/parcel_jingle_bug374_test.lua create mode 100644 tests/drivers/quit_process_bug339_test.lua create mode 100644 tests/drivers/rock_tunnel_dark_bake_bug383_test.lua create mode 100644 tests/drivers/silph_rockets_bug392_test.lua create mode 100644 tests/drivers/silph_tm36_bug393_test.lua create mode 100644 tests/drivers/ss_anne_departure_bug360_test.lua create mode 100644 tests/drivers/title_seam_bug373_test.lua create mode 100644 tests/engine/fanfare_music_hold.lua create mode 100644 tests/engine/give_item_jingle.lua create mode 100644 tests/engine/multihit_hp_drain.lua create mode 100644 tests/engine/quit_thread_shutdown.lua create mode 100644 tests/engine/save_convert_extra_flags.lua create mode 100644 tests/engine/title_zone_seams.lua create mode 100644 tests/parity_applying_attack_anim.lua create mode 100644 tests/parity_battle_item_turn.lua create mode 100644 tests/parity_bide_anim.lua create mode 100644 tests/parity_celadon_roof_readables.lua create mode 100644 tests/parity_elevator_panel.lua create mode 100644 tests/parity_field_move_layering.lua create mode 100644 tests/parity_hideout_gate.lua create mode 100644 tests/parity_silph_rockets.lua create mode 100644 tests/parity_silph_tm36_pre.lua create mode 100644 tests/parity_ss_anne_departure.lua create mode 100644 tests/parity_warp_standing_flag.lua diff --git a/data/scripts/celadon_eevee.lua b/data/scripts/celadon_eevee.lua index ea9cfb97..f7563d7c 100644 --- a/data/scripts/celadon_eevee.lua +++ b/data/scripts/celadon_eevee.lua @@ -11,8 +11,70 @@ -- equivalent, kept for save compatibility: rows 1-4 also self-heal -- older saves (flag set before the port hid the ball) by hiding the -- leftover ball on the next interaction. +-- +-- The room's two readables (data/events/hidden_events.asm, +-- hidden_events_for CELADON_MANSION_ROOF_HOUSE): +-- hidden_text_predef 3, 0 / 4, 0 PrintBlackboardLinkCableText, LinkCableHelp +-- hidden_text_predef 3, 4 PrintNotebookText, TMNotebook +-- tools/extract/field.py only parses `hidden_event` rows, so no +-- hidden_text_predef row reaches data/generated/field.lua and both tiles +-- were dead A presses (#391). Same hook shape as the bedroom SNES in +-- data/scripts/flavor/reds_house_2f.lua (#135). hidden_text_predef puts +-- the predef id in the facing byte, so neither tile gates on facing. + +local Menu = require("src.ui.Menu") +local TextBox = require("src.render.TextBox") + +-- TMNotebookText (data/text/text_2.asm) has no leading underscore, so the +-- extractor never collects it and the pamphlet's text is inlined. +local TM_NOTEBOOK_TEXT = "It's a pamphlet\non TMs.\f...\f" + .. "There are 50 TMs\nin all.\f" + .. "There are also 5\nHMs that can be\vused repeatedly.\f" + .. "SILPH CO." + +-- LinkCableHelp (engine/events/hidden_events/school_blackboard.asm): +-- HowToLinkText's four headings in the 15x10 box at the top left; picking +-- a heading prints its LinkCableInfoText and returns to the menu, B or +-- STOP READING closes. +local LINK_HEADINGS = { "HOW TO LINK", "COLOSSEUM", "TRADE CENTER" } + +local function linkCableHelp(game) + local text = game.data.text or {} + local items, showMenu, askHeading + function showMenu() + game.stack:push(Menu.new(game, items, + { tx = 0, ty = 0, tw = 15, th = 10, rowStep = 1 })) + end + function askHeading() + game.stack:push(TextBox.new(game, + text._LinkCableHelpText2 or "Which heading do\nyou want to read?", + showMenu)) + end + items = {} + for i, label in ipairs(LINK_HEADINGS) do + items[i] = { label = label, onSelect = function() + game.stack:push(TextBox.new(game, + text["_LinkCableInfoText" .. i] or label, askHeading)) + end } + end + items[#items + 1] = { label = "STOP READING" } + game.stack:push(TextBox.new(game, + text._LinkCableHelpText1 or "TRAINER TIPS\fUsing a Game Link\nCable", + askHeading)) +end return { + onInteract = function(game, ow, fx, fy) + if fy == 0 and (fx == 3 or fx == 4) then + linkCableHelp(game) + return true + end + if fx == 3 and fy == 4 then + game.stack:push(TextBox.new(game, TM_NOTEBOOK_TEXT)) + return true + end + return false + end, talk = { TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL = { { "check_flag", "EVENT_GOT_EEVEE" }, -- 1 diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 0fa2c3db..356a9907 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -666,6 +666,48 @@ M.WARDENS_HOUSE = { -- generic OPP_GIOVANNI#2 battle) -- ------------------------------------------------------------------- +-- SilphCo11FTeamRocketLeavesScript (scripts/SilphCo11F.asm) hides every +-- ROCKET and rocket-aligned SCIENTIST toggle on 2F-11F the moment Giovanni +-- falls; the item balls, the 2F/10F rescued workers and the 7F rival keep +-- theirs. Names and order are data/maps/toggleable_objects.asm's +-- TOGGLE_SILPH_CO__n entries. Saffron City's half of the same +-- script lives in M.SAFFRON_CITY (story4.lua) (#392). +local SILPH_ROCKET_OBJECTS = { + { "SILPH_CO_2F", { "SILPHCO2F_SCIENTIST1", "SILPHCO2F_SCIENTIST2", + "SILPHCO2F_ROCKET1", "SILPHCO2F_ROCKET2" } }, + { "SILPH_CO_3F", { "SILPHCO3F_ROCKET", "SILPHCO3F_SCIENTIST" } }, + { "SILPH_CO_4F", { "SILPHCO4F_ROCKET1", "SILPHCO4F_SCIENTIST", + "SILPHCO4F_ROCKET2" } }, + { "SILPH_CO_5F", { "SILPHCO5F_ROCKET1", "SILPHCO5F_SCIENTIST", + "SILPHCO5F_ROCKER", "SILPHCO5F_ROCKET2" } }, + { "SILPH_CO_6F", { "SILPHCO6F_ROCKET1", "SILPHCO6F_SCIENTIST", + "SILPHCO6F_ROCKET2" } }, + { "SILPH_CO_7F", { "SILPHCO7F_ROCKET1", "SILPHCO7F_SCIENTIST", + "SILPHCO7F_ROCKET2", "SILPHCO7F_ROCKET3" } }, + { "SILPH_CO_8F", { "SILPHCO8F_ROCKET1", "SILPHCO8F_SCIENTIST", + "SILPHCO8F_ROCKET2" } }, + { "SILPH_CO_9F", { "SILPHCO9F_ROCKET1", "SILPHCO9F_SCIENTIST", + "SILPHCO9F_ROCKET2" } }, + { "SILPH_CO_10F", { "SILPHCO10F_ROCKET", "SILPHCO10F_SCIENTIST" } }, + { "SILPH_CO_11F", { "SILPHCO11F_GIOVANNI", "SILPHCO11F_ROCKET1", + "SILPHCO11F_ROCKET2" } }, +} + +-- hide_object writes save.objectToggles, so the single pass at the win +-- covers floors the player is not standing on; onlyMap is the per-floor +-- repair path below. +local function silphRocketsLeave(game, ow, onlyMap) + local Commands = require("src.script.Commands") + local ctx = { game = game, save = game.save, overworld = ow } + for _, floor in ipairs(SILPH_ROCKET_OBJECTS) do + if not onlyMap or onlyMap == floor[1] then + for _, name in ipairs(floor[2]) do + Commands.hide_object(ctx, floor[1], name) + end + end + end +end + M.SILPH_CO_11F = { -- Giovanni's battle is a COORDINATE TRIGGER, not a talk. -- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks @@ -692,13 +734,11 @@ M.SILPH_CO_11F = { ow:scriptMove(gio, "down", 3, function() gio:facePlayer(ow.player) ow:engageTrainer(gio, function() - -- SilphCo11FTeamRocketLeavesScript: Giovanni leaves the floor + -- SilphCo11FTeamRocketLeavesScript: every Silph rocket leaves -- after the loss (the street rockets are handled by -- M.SAFFRON_CITY.onEnter in story4.lua). if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then - local Commands = require("src.script.Commands") - local ctx = { game = game, save = game.save, overworld = ow } - Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI") + silphRocketsLeave(game, ow) end end) end) @@ -706,28 +746,46 @@ M.SILPH_CO_11F = { end, onEnter = function(game, ow) if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then - local Commands = require("src.script.Commands") - local ctx = { game = game, save = game.save, overworld = ow } - Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI") + silphRocketsLeave(game, ow) end end, talk = { + -- SilphCo11FSilphPresidentText branches on EVENT_GOT_MASTER_BALL only: + -- the teleport pads reach him without passing Giovanni's trigger, and + -- afterwards he describes the ball forever. The old rows fell off the + -- end of the script on both branches, so a second talk was silence + -- (#392). TEXT_SILPHCO11F_SILPH_PRESIDENT = { { "face_player" }, -- 1 - { "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, -- 2 - { "jump_if_false", 10 }, -- 3 - { "check_flag", "EVENT_GOT_MASTER_BALL" }, -- 4 - { "jump_if_true", 10 }, -- 5 - { "show_text", "_SilphCo11FSilphPresidentText" }, -- 6 + { "check_flag", "EVENT_GOT_MASTER_BALL" }, -- 2 + { "jump_if_true", 9 }, -- 3 + { "show_text", "_SilphCo11FSilphPresidentText" }, -- 4 -- give-then-print like scripts/SilphCo11F.asm - { "give_item", "MASTER_BALL", 1, false }, -- 7 - { "show_text", "_SilphCo11FSilphPresidentReceivedMasterBallText" }, -- 8 - { "set_flag", "EVENT_GOT_MASTER_BALL" }, -- 9 - { "jump", 11 }, -- 10 + { "give_item", "MASTER_BALL", 1, false }, -- 5 + { "show_text", "_SilphCo11FSilphPresidentReceivedMasterBallText" }, -- 6 + { "set_flag", "EVENT_GOT_MASTER_BALL" }, -- 7 + { "jump", "end" }, -- 8 + { "show_text", + "_SilphCo11FSilphPresidentMasterBallDescriptionText" }, -- 9 }, }, } +-- Floors 2F-10F carry no other onEnter, so this repairs saves that beat +-- Giovanni before the hide list existed (same shape as +-- M.POKEMON_TOWER_7F.onEnter); 11F hides its own inside the table above. +for _, floor in ipairs(SILPH_ROCKET_OBJECTS) do + local mapId = floor[1] + if mapId ~= "SILPH_CO_11F" then + M[mapId] = M[mapId] or {} + M[mapId].onEnter = function(game, ow) + if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then + silphRocketsLeave(game, ow, mapId) + end + end + end +end + -- ------------------------------------------------------------------- -- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm): -- a boulder resting on a switch removes a barrier block; the 3F hole diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index d868761a..0281eda4 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -182,17 +182,16 @@ M.POKEMON_TOWER_6F = { -- preFrames: the shake's lead-in delays -- ShakeElevator's own Delay3s -- come to 9 frames; Silph/Rocket's ...ShakeScript prefixes another -- Delay3 (12) while CeladonMartElevatorShakeScript farjps straight in +-- The panel is a bg_event, not a map-entry script: data/maps/objects/ +-- CeladonMartElevator.asm has `bg_event 3, 0, TEXT_CELADONMARTELEVATOR` +-- and CeladonMartElevatorText is what runs DisplayElevatorFloorMenu, so +-- the menu waits for the player to face the panel and press A (#395). +-- Map entry only stores the car's exit warps (CeladonMartElevator +-- StoreWarpEntriesScript), which is elevatorSeedExit below. -- After the ride the original does NOT jump-cut to the floor: choosing a --- floor in engine/events/elevator.asm DisplayElevatorFloorMenu rewrites --- the elevator car's own warp entries (wWarpEntries, via .UpdateWarp) to --- the chosen floor's exit warp, then the player walks out of the car onto --- that warp themselves (scripts/SilphCoElevator.asm etc.). Reproduce --- that here: rewrite the car's exit warps, then drive a short scripted --- walk-out onto an exit tile and take the (now rewritten) warp, reusing --- ow:scriptMove / ow:takeWarp (the Oak-escort primitives). -local WALK_DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } -local WALK_OPP = { up = "down", down = "up", left = "right", right = "left" } -local WALK_ORDER = { "up", "down", "left", "right" } +-- floor rewrites the elevator car's own warp entries (wWarpEntries, via +-- .UpdateWarp) to the chosen floor's exit warp and returns control, and +-- the player walks out of the car onto that warp themselves. -- .UpdateWarp: point EVERY car exit warp at the same floor's elevator -- door (warp id, map id). Shared generated map data, but the car's @@ -205,46 +204,6 @@ local function elevatorSetExit(ow, floor) end end -local function elevatorWalkOut(ow, floor) - local m, p = ow.map, ow.player - elevatorSetExit(ow, floor) - -- leave by the exit tile under the player (they warped in onto one), - -- else the nearest - local door - for _, w in ipairs(m.def.warps) do - if w.x == p.cellX and w.y == p.cellY then door = w break end - end - if not door then - local best - for _, w in ipairs(m.def.warps) do - local d = math.abs(w.x - p.cellX) + math.abs(w.y - p.cellY) - if not best or d < best then best, door = d, w end - end - end - -- the car interior sits on the door's walkable side; "out" is the - -- doorway direction (the map edge for Silph/Celadon, the top doorway - -- for the Rocket car). Fixed direction order keeps the step - -- deterministic when a door tile has several walkable neighbours - -- (Silph/Celadon step up into the car, the Rocket car steps down). - local into - for _, dir in ipairs(WALK_ORDER) do - local v = WALK_DIRVEC[dir] - local nx, ny = door.x + v[1], door.y + v[2] - if m:inBounds(nx, ny) and m:isWalkableCell(nx, ny) then into = dir break end - end - into = into or "up" - local out = WALK_OPP[into] - local function leave() - ow:takeWarp(door) -- door SFX + warp to the rewritten floor target - end - -- step one tile into the car, then walk back through the doorway onto - -- the exit tile and take the warp: a visible walk-out on valid tiles - -- for either door orientation, instead of a jump cut - ow:scriptMove(p, into, 1, function() - ow:scriptMove(p, out, 1, leave) - end) -end - local function elevatorFloors(elevatorMapId, game) local floors = {} for mapId, def in pairs(game.data.maps) do @@ -272,9 +231,9 @@ local function elevatorFloors(elevatorMapId, game) end local function elevatorSeedExit(ow, floors, fromMapId) - -- Seed a walk-out destination before the menu (or key-gate text): - -- entry floor when known, else the first listed floor (1F). Choosing - -- a floor still rewrites via elevatorWalkOut; B-cancel / no-key leave + -- Seed a walk-out destination on entry (before the panel is ever + -- read): entry floor when known, else the first listed floor (1F). + -- Choosing a floor rewrites it again; B-cancel / no-key leave -- keeps this seed so walking out of the car cannot hit a missing ROM -- placeholder (#123) or the car's static default floor (#90: Rocket -- Hideout defaults to B1F even when entered from B2F/B4F). @@ -288,68 +247,68 @@ local function elevatorSeedExit(ow, floors, fromMapId) return exitFloor end -local function elevator(elevatorMapId, keyGate, preFrames) +local function elevator(elevatorMapId, panelText, keyGate, preFrames) + -- the panel bg_event: DisplayElevatorFloorMenu runs from this text + -- script, never from map entry (#395) + local function panel(game, ow, npc, done) + done = done or function() end + local floors = elevatorFloors(elevatorMapId, game) + -- Rocket Hideout: without LIFT_KEY the panel only prints the need- + -- a-key line and shows no floor menu + -- (scripts/RocketHideoutElevator.asm). + if keyGate and not game.save.inventory[keyGate.item] then + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, + game.data.text[keyGate.text] or "It appears to\nneed a key.", done)) + return + end + local items = {} + for _, f in ipairs(floors) do + table.insert(items, { label = f.token, value = f }) + end + local ListMenu = require("src.ui.ListMenu") + game.stack:push(ListMenu.new(game, "WHICH FLOOR?", items, { + onChoose = function(item, list) + list:close() + -- the whole ShakeElevator ride runs in place -- music stop, 100 + -- collision-thud scroll bounces, the PA chime -- and only then + -- does .UpdateWarp's rewrite land, with the player still stood + -- at the panel: they walk out to the car door themselves + local ElevatorShake = require("src.world.ElevatorShake") + game.stack:push(ElevatorShake.new(game, ow, { + preFrames = preFrames, + onDone = function() + elevatorSetExit(ow, item.value) + done() + end, + })) + end, + onCancel = function() + -- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing + -- happens, the player just stays in the car (exit warps were + -- already seeded on entry) + done() + end, + })) + end return { -- fromMapId: the floor the player just left (setMap passes it), so a -- B-cancel can still walk out onto a real map. Silph's ROM car warps -- default to UNUSED_MAP_ED, which is not in Data.maps -- Warp.resolve -- asserted and hard-crashed (#123). onEnter = function(game, ow, fromMapId) - local floors = elevatorFloors(elevatorMapId, game) - elevatorSeedExit(ow, floors, fromMapId) - -- Rocket Hideout: without LIFT_KEY the panel only prints the need- - -- a-key line (scripts/RocketHideoutElevator.asm). Exit warps are - -- still seeded above so walking out returns to the entry floor - -- instead of the car's ROM default (B1F) -- #90 / #105. - if keyGate and not game.save.inventory[keyGate.item] then - local TextBox = require("src.render.TextBox") - game.stack:push(TextBox.new(game, - game.data.text[keyGate.text] or "It appears to\nneed a key.")) - return - end - local items = {} - for _, f in ipairs(floors) do - table.insert(items, { label = f.token, value = f }) - end - local ListMenu = require("src.ui.ListMenu") - game.stack:push(ListMenu.new(game, "WHICH FLOOR?", items, { - onChoose = function(item, list) - list:close() - -- the map-entry Transition (startWarpTo) calls onEnter from - -- its OWN midpoint callback, so this menu was pushed on top - -- of that still-active Transition; only the top state - -- updates, so it froze mid-fade instead of finishing. Left - -- alone it would resurface after the ride and play a stray - -- fade at the wrong time -- pop it now, before it can happen. - local Transition = require("src.render.Transition") - if getmetatable(game.stack:top()) == Transition then - game.stack:pop() - end - -- the whole ShakeElevator ride runs in place -- music stop, - -- 100 collision-thud scroll bounces, the PA chime -- and only - -- then does the player walk out of the car onto the chosen - -- floor (elevatorWalkOut rewrites the car's exit warps first) - local ElevatorShake = require("src.world.ElevatorShake") - game.stack:push(ElevatorShake.new(game, ow, { - preFrames = preFrames, - onDone = function() - elevatorWalkOut(ow, item.value) - end, - })) - end, - onCancel = function() - -- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing - -- happens, the player just stays in the car (exit warps were - -- already seeded to the entry floor above) - end, - })) + elevatorSeedExit(ow, elevatorFloors(elevatorMapId, game), fromMapId) end, + talk = { [panelText] = panel }, } end -M.SILPH_CO_ELEVATOR = elevator("SILPH_CO_ELEVATOR") -M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR", nil, 9) +M.SILPH_CO_ELEVATOR = elevator("SILPH_CO_ELEVATOR", + "TEXT_SILPHCOELEVATOR_ELEVATOR") +M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR", + "TEXT_CELADONMARTELEVATOR", nil, 9) M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR", + "TEXT_ROCKETHIDEOUTELEVATOR", { item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" }) -- ------------------------------------------------------------------- @@ -695,6 +654,16 @@ local DOCK_SHIP_BLOCKS = { { bx = 7, by = 2, water = 13 }, { bx = 8, by = 2, water = 13 }, } +-- her four hull columns bow-to-stern (upper-half / lower-half block ids) +-- and the open-water ids of the rows she sits in +local DOCK_SHIP_COLUMNS = { + { bx = 5, top = 4, bottom = 8 }, + { bx = 6, top = 5, bottom = 9 }, + { bx = 7, top = 6, bottom = 10 }, + { bx = 8, top = 7, bottom = 11 }, +} +local DOCK_WATER_TOP, DOCK_WATER_BOTTOM = 1, 13 + M.VERMILION_DOCK = { onEnter = function(game, ow) local Flags = require("src.script.Flags") @@ -729,17 +698,39 @@ 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) local rows = {} - rows[#rows + 1] = { "wait", 100 } - rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" } - for _, b in ipairs(DOCK_SHIP_BLOCKS) do - rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water } + 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", 30 } + rows[#rows + 1] = { "wait", 120 } + rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" } + -- .shift_columns_up slides her tile columns west behind a mid-frame + -- rSCX split; with no split scroll here she sails one block per beat + -- and the water closes in astern (#360) + for step = 1, 8 do + for _, col in ipairs(DOCK_SHIP_COLUMNS) do + setBlock(col.bx - step, 1, col.top) + setBlock(col.bx - step, 2, col.bottom) + end + setBlock(9 - step, 1, DOCK_WATER_TOP) + setBlock(9 - step, 2, DOCK_WATER_BOTTOM) + rows[#rows + 1] = { "wait", 20 } + end + -- the second horn as she clears the dock, then EraseSSAnne's 120 + -- frames before the walk out + rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" } + rows[#rows + 1] = { "wait", 120 } rows[#rows + 1] = { "move_player", "up", 2 } - -- keep Music_Surfing across the city warp (pokered stays on it - -- through the exit-ship walk) - rows[#rows + 1] = { "play_music", "Music_Surfing", { keep = true } } + -- no keepMusic on this warp: Music_Surfing belongs to the dock's + -- cutscene, and VERMILION_CITY's own theme has to take over as the + -- player crosses in (EnterMap's PlayDefaultMusic) rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" } rows[#rows + 1] = { "move_player", "up", 2 } ow:queueScript(rows) diff --git a/data/scripts/story5.lua b/data/scripts/story5.lua index 84aea24f..87d68b70 100644 --- a/data/scripts/story5.lua +++ b/data/scripts/story5.lua @@ -135,6 +135,9 @@ M.SILPH_CO_2F = { talk = { TEXT_SILPHCO2F_SILPH_WORKER_F = gift({ flag = "EVENT_GOT_TM36", item = "TM_SELFDESTRUCT", + -- the label carries no leading underscore: pokered keeps this one in + -- the script bank, not the far-text bank (#393) + pre = "SilphCo2FSilphWorkerFPleaseTakeThisText", received = "_SilphCo2FSilphWorkerFReceivedTM36Text", explain = "_SilphCo2FSilphWorkerFTM36ExplanationText", noRoom = "_SilphCo2FSilphWorkerFTM36NoRoomText", @@ -836,6 +839,19 @@ M.SILPH_CO_7F = { end, } +-- SSAnne2FRivalAfterBattleScript's exit walk is keyed on the player's X, +-- not on where the rival stopped: from (37,8) the rival stands below him on +-- (36,8) and takes .RivalDownFourMovement straight down out of the room; +-- from (36,8) he stands above him on (36,7) and takes +-- .RivalWalkAroundPlayerMovement, which steps RIGHT and then falls through +-- into the same four DOWNs, so five downs in all (#360). +local function ssAnne2FRivalExitDirs(onLeft) + if onLeft then + return { "right", "down", "down", "down", "down", "down" } + end + return { "down", "down", "down", "down" } +end + -- S.S. Anne 2F rival ambush (scripts/SSAnne2F.asm; coords 36/37,8) M.SS_ANNE_2F = { onStep = function(game, ow, x, y) @@ -848,11 +864,12 @@ M.SS_ANNE_2F = { { "face_object", 2, onLeft and "down" or "right" }, -- 3 { "show_text", "_SSAnne2FRivalText" }, -- 4 { "rival_battle", "OPP_RIVAL2", 1 }, -- 5 - { "jump_if_false", 10 }, -- 6 + { "jump_if_false", 11 }, -- 6 { "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7 { "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8 - { "move_npc_to", 2, 36, 4 }, -- 9 - { "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 10 + { "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9 + { "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 10 + { "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 11 }, onLeft and "up" or "left") end, } diff --git a/main.lua b/main.lua index f88a7381..c91130f8 100644 --- a/main.lua +++ b/main.lua @@ -424,6 +424,16 @@ function love.quit() pcall(function() require("src.core.DiscordPresence").shutdown() end) + -- LOVE waits for every live love.thread before the process exits, and both + -- background workers idle in a loop that only a "quit" command breaks, so + -- without this the process outlived the window and the next launch re-entered + -- the dead one instead of starting fresh (#339) + if package.loaded["src.core.ChipAudio"] then + pcall(package.loaded["src.core.ChipAudio"].shutdown) + end + if package.loaded["src.update.Check"] then + pcall(package.loaded["src.update.Check"].shutdown) + end end function love.filedropped(file) @@ -461,6 +471,13 @@ function love.run() for name, a, b, c, d, e, f in love.event.poll() do if name == "quit" then if not love.quit or not love.quit() then + -- Android keeps the process and its task alive after LOVE's own + -- teardown, so the relaunched task re-enters an activity whose + -- native main already returned; end the process outright once the + -- love.quit hook has run (#339) + if love.system and love.system.getOS() == "Android" then + os.exit(a or 0) + end return a or 0 end end diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 131ee8cf..e06ee37d 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -740,10 +740,17 @@ function BattleState:buildScreen(id, ...) end -- insert a wait for the HP bars to finish draining (UpdateHPBar): --- the queue holds until every battler's displayed HP catches up -function BattleState:drainNext() +-- the queue holds until every battler's displayed HP catches up. +-- `stopAt` pins how far that battler's bar may drain on this row. A +-- multi-hit move takes every strike off the model while the turn is still +-- being queued, so an unpinned row would drain straight to the +-- post-last-hit HP and the later strikes would animate nothing (#394); +-- ApplyDamageToEnemyPokemon runs UpdateHPBar2 once per strike inside the +-- wNumAttacksLeft loop (engine/battle/core.asm:4727). +function BattleState:drainNext(battler, stopAt) self.nextInsert = (self.nextInsert or 0) + 1 - table.insert(self.queue, self.nextInsert, { drain = true }) + table.insert(self.queue, self.nextInsert, + { drain = true, battler = battler, stopAt = stopAt }) end -- One frame of the HP-bar drain (engine/gfx/hp_bar.asm UpdateHPBar): @@ -752,14 +759,22 @@ end function BattleState:stepHPDrain() local busy = false for _, b in ipairs({ self.player, self.enemy }) do - if b and b.shownHP and b.shownHP ~= b.mon.hp then - local step = math.max(1, b.mon.stats.hp) / 96 - if b.shownHP > b.mon.hp then - b.shownHP = math.max(b.mon.hp, b.shownHP - step) - else - b.shownHP = math.min(b.mon.hp, b.shownHP + step) + if b and b.shownHP then + -- drainFloor is the stop the running row carries (see drainNext) + local goal = b.mon.hp + if b.drainFloor and b.drainFloor > goal + and b.shownHP >= b.drainFloor then + goal = b.drainFloor + end + if b.shownHP ~= goal then + local step = math.max(1, b.mon.stats.hp) / 96 + if b.shownHP > goal then + b.shownHP = math.max(goal, b.shownHP - step) + else + b.shownHP = math.min(goal, b.shownHP + step) + end + busy = busy or b.shownHP ~= goal end - busy = busy or b.shownHP ~= b.mon.hp end end return busy @@ -837,6 +852,8 @@ function BattleState:updateQueue() if self.draining then if self:stepHPDrain() then return true end self.draining = nil + if self.player then self.player.drainFloor = nil end + if self.enemy then self.enemy.drainFloor = nil end end -- a move animation holds the queue until it finishes; its screen -- effects (SE_*) and per-row sounds route into the fx layer as they @@ -875,6 +892,7 @@ function BattleState:updateQueue() end if item.drain then self.draining = true + if item.battler then item.battler.drainFloor = item.stopAt end return true end if item.wait then @@ -1380,6 +1398,7 @@ function BattleState:update(dt) for _, b in ipairs({ self.player, self.enemy }) do if b then if b.shownHP then b.shownHP = b.mon.hp end + b.drainFloor = nil b.shownStatus = b.mon.status end end @@ -2154,6 +2173,31 @@ local function startPicKind(pf, kind) pf.hidden = nil end +-- PredefShakeScreenHorizontally (engine/gfx/screen_effects.asm): the window +-- jumps right by b for 5 frames then home for 4, b counting down to 1. +-- b = 8 for SE_SHAKE_SCREEN and the heavy applying-attack shake, b = 2 for +-- the light one. +local function fastShakeProg(b) + local prog = {} + for i = b, 1, -1 do + prog[#prog + 1] = { dx = i, frames = 5 } + prog[#prog + 1] = { dx = 0, frames = 4 } + end + return prog +end + +-- AnimationShakeScreenHorizontallySlow (engine/battle/animations.asm:526): +-- rWX creeps 1px right every 2 frames b times, then back down to 0, c times +-- over. Silent -- this is the non-damaging move's feedback. +local function slowShakeProg(b, c) + local prog = {} + for _ = 1, c do + for i = 1, b do prog[#prog + 1] = { dx = i, frames = 2 } end + for i = b - 1, 0, -1 do prog[#prog + 1] = { dx = i, frames = 2 } end + end + return prog +end + -- Route one AnimPlayer event into the fx layer. Frame counts and -- amplitudes are the routines' own (engine/battle/animations.asm; -- shakes: engine/gfx/screen_effects.asm). @@ -2195,14 +2239,7 @@ function BattleState:applyAnimEffect(ev) -- ---------------------------------------------- screen shakes elseif e == "SE_SHAKE_SCREEN" then - -- PredefShakeScreenHorizontally b=8: the window jumps right by b - -- for 5 frames then home for 4, b counting down 8..1 - local prog = {} - for b = 8, 1, -1 do - prog[#prog + 1] = { dx = b, frames = 5 } - prog[#prog + 1] = { dx = 0, frames = 4 } - end - fx.shakeProg = prog + fx.shakeProg = fastShakeProg(8) elseif e == "SE_ROCK_SLIDE_SHAKE" then -- DoRockSlideSpecialEffects: 1px horizontal then vertical rumble fx.shakeProg = { { dx = 1, frames = 5 }, { dx = 0, frames = 4 }, @@ -2299,34 +2336,77 @@ function BattleState:applyAnimEffect(ev) -- battler.substituteHP is set (MoveEffects raises it with the move) end --- The target's post-animation hit feedback (PlayApplyingAttackAnimation, --- engine/battle/animations.asm:475): the player's damaging moves blink --- the ENEMY pic; the enemy's damaging moves shake the screen vertically --- (ShakeScreenVertically -> PredefShakeScreenVertically b=8: the window --- drops by b for 3 frames then home for 3, b counting down) -- the --- player's pic never blinks. Damage sound with either. A hold keeps +-- The post-animation applying-attack feedback (PlayApplyingAttackAnimation +-- -> AnimationTypePointerTable, engine/battle/animations.asm:475-524). +-- hit.animType is wAnimationType, 1..6: +-- 1 enemy damaging, no added effect ShakeScreenVertically (b=8) +-- 2 enemy damaging, added effect fast horizontal shake, b=8 +-- 3 enemy non-damaging slow horizontal shake, b=6, c=2 +-- 4 player damaging, no added effect BlinkEnemyMonSprite +-- 5 player damaging, added effect fast horizontal shake, b=2 +-- 6 player non-damaging slow horizontal shake, b=3, c=2 +-- Types 3 and 6 are silent; the rest open with PlayApplyingAttackSound, +-- which is the damage sound hit.sfx already carries. Only 1 and 4 were +-- implemented, so every move with an added effect blinked (or shook +-- vertically) instead of shaking sideways and every status move showed +-- nothing at all -- Bubblebeam, Confusion, Hypnosis (#354). A hold keeps -- the queue still until the effect finishes. function BattleState:applyHitFx(hit) - if hit.blink then - self.fx = self.fx or {} - if hit.blink.isPlayer then - local prog = {} - for b = 8, 1, -1 do - prog[#prog + 1] = { dy = b, frames = 3 } - prog[#prog + 1] = { dy = 0, frames = 3 } - end - self.fx.shakeProg = prog - self.waitFrames = 48 -- the predef blocks until the shake settles - else - self.fx.blink = { target = hit.blink, frames = 20 } - self.waitFrames = 20 - end - end + self.fx = self.fx or {} + -- rows queued before animType existed carry only the blink target + local t = hit.animType + if not t and hit.blink then t = hit.blink.isPlayer and 1 or 4 end if hit.sfx then require("src.core.Sound").play(self.data, hit.sfx) end + if not t or not self:animationsOn() then return end + if t == 1 then + -- PredefShakeScreenVertically b=8: the window drops by b for 3 frames + -- then home for 3, b counting down + local prog = {} + for b = 8, 1, -1 do + prog[#prog + 1] = { dy = b, frames = 3 } + prog[#prog + 1] = { dy = 0, frames = 3 } + end + self.fx.shakeProg = prog + self.waitFrames = 48 -- the predef blocks until the shake settles + elseif t == 2 then + self.fx.shakeProg = fastShakeProg(8) + self.waitFrames = 72 + elseif t == 3 then + self.fx.shakeProg = slowShakeProg(6, 2) + self.waitFrames = 48 + elseif t == 4 then + if hit.blink then + self.fx.blink = { target = hit.blink, frames = 20 } + self.waitFrames = 20 + end + elseif t == 5 then + self.fx.shakeProg = fastShakeProg(2) + self.waitFrames = 18 + elseif t == 6 then + self.fx.shakeProg = slowShakeProg(3, 2) + self.waitFrames = 24 + end end +-- Primary status effects whose pokered handler ends in +-- PlayCurrentMoveAnimation2 (engine/battle/effects.asm:1448), which sets +-- wAnimationType 6 on the player's turn and 3 on the enemy's: sleep, +-- poison, confuse, disable and the primary stat-down effects. Every other +-- primary effect goes through PlayCurrentMoveAnimation and leaves the type +-- at 0 (no applying animation): paralysis (FreezeBurnParalyzeEffect), +-- leech seed, the stat-UP effects, Splash. Side-effect stat drops are +-- skipped too -- UpdateLoweredStatDone bails out for them because the +-- damaging move's own type 2/5 shake already played. +local SLOW_SHAKE_EFFECTS = { + SLEEP_EFFECT = true, POISON_EFFECT = true, CONFUSION_EFFECT = true, + DISABLE_EFFECT = true, + ATTACK_DOWN1_EFFECT = true, DEFENSE_DOWN1_EFFECT = true, + DEFENSE_DOWN2_EFFECT = true, SPEED_DOWN1_EFFECT = true, + ACCURACY_DOWN1_EFFECT = true, +} + -- AnimateSendingOutMon (core.asm:6801-6838): the mon grows out of the -- ball -- a 3-frame ball beat, 4 frames of the pic at 3/7 scale (a 3x3 -- block of its 7x7 tiles), 5 frames at 5/7 (5x5), then full size. @@ -2910,6 +2990,8 @@ function BattleState:performMove(user, target, moveInst, isCalled) -- (AlreadyAsleep / NothingHappened / ButItFailed print with no anim) if primaryEffectFailed(msgs) then self:cancelMoveAnim() + elseif SLOW_SHAKE_EFFECTS[move.effect] and self.moveAnimRow then + self.moveAnimRow.hit = { animType = user.isPlayer and 6 or 3 } end for _, m in ipairs(msgs) do self:sayNext(m) @@ -2963,6 +3045,10 @@ function BattleState:continueBide(user, target) self:sayNext(Strings("But, it failed!")) return end + -- .UnleashEnergy (core.asm:3501-3529) re-points wPlayerMoveNum at BIDE + -- and rejoins HandleIfPlayerMoveMissed, so BIDE's own animation plays + -- here, after UnleashedEnergyText and before the damage (#375) + self:animNext("BIDE", user.isPlayer) self:applyDamage(target, dmg) if target.mon.hp <= 0 then self:onFaint(target) end end @@ -2987,7 +3073,7 @@ function BattleState:applyDamage(target, dmg) end local dealt = math.min(dmg, target.mon.hp) target.mon.hp = target.mon.hp - dealt - if dealt > 0 then self:drainNext() end -- animate the bar down + if dealt > 0 then self:drainNext(target, target.mon.hp) end -- animate the bar down if target.bideTurns then target.bideDamage = (target.bideDamage or 0) + dealt end diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index a259ca5a..24a78db4 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -186,7 +186,15 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- hitRow carries the blink instead. local hitSfx = info.typeMult > 10 and "Super_Effective" or info.typeMult < 10 and "Not_Very_Effective" or "Damage" + -- 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) + local added = move.effect ~= nil and move.effect ~= "NO_ADDITIONAL_EFFECT" 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 } local totalDealt = 0 diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 085ef431..f6cf5699 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -609,10 +609,16 @@ MoveEffects.full = { }, BIDE_EFFECT = { + -- BideEffect (effects.asm:764-789) is a ResidualEffects2 entry: the + -- storing turn plays XSTATITEM_ANIM (XSTATITEM_DUPLICATE_ANIM on the + -- enemy side) and never BIDE's own animation, which belongs to the + -- release turn in .UnleashEnergy (#375) perform = function(ctx) local user = ctx.user user.bideTurns = ctx.rng(2, 3) user.bideDamage = 0 + ctx.battle:cancelMoveAnim() + ctx.anim(user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM") ctx.say(Strings("%s\nis storing energy!", displayName(user))) end, }, diff --git a/src/core/ChipAudio.lua b/src/core/ChipAudio.lua index e2f7b2ab..2a2effbe 100644 --- a/src/core/ChipAudio.lua +++ b/src/core/ChipAudio.lua @@ -55,6 +55,14 @@ local currentMusic local pendingBuf -- a current-gen buffer popped from the worker but not yet -- queued because the Source was momentarily full +-- Music holds playback while a fanfare owns the music channels (#398). +-- Pausing the Source is not enough on its own: this module is what starts a +-- chip song (immediately on the sync path, on the first worker buffer on the +-- threaded one), so a song that begins during a jingle would come up +-- underneath it. Music.duckForFanfare sets the hold, Music releases it when +-- the jingle ends. +local musicHeld = false + -- --------------------------------------------------------------------------- -- worker management -- --------------------------------------------------------------------------- @@ -148,7 +156,7 @@ local function playMusicSync(data, header, allowLoops) currentMusic = { source = source, engine = engine, threaded = false, started = true, finished = false } fillSync(MUSIC_FILL_INITIAL) - source:play() + if not musicHeld then source:play() end return source end @@ -222,7 +230,7 @@ local function updateThreaded() end end end - if not m.started then + if not m.started and not musicHeld then if (MUSIC_BUFFER_COUNT - m.source:getFreeBufferCount()) > 0 then pcall(function() m.source:play() end) m.started = true @@ -245,7 +253,7 @@ end -- pause/resume behavior. function ChipAudio.ensureMusicPlaying() local m = currentMusic - if not m or m.finished then return end + if not m or m.finished or musicHeld then return end if m.threaded then if not m.started then return end local ok, playing = pcall(function() return m.source:isPlaying() end) @@ -263,6 +271,18 @@ function ChipAudio.ensureMusicPlaying() end end +-- Silence the song for the length of a fanfare and start whatever was held +-- back once it ends. Held state outlives a song change: Music.play may swap +-- songs while the jingle is still sounding. +function ChipAudio.holdMusic(held) + held = not not held + if held == musicHeld then return end + musicHeld = held + if held then return end + ChipAudio.update() + ChipAudio.ensureMusicPlaying() +end + -- Threaded playMusic returns an empty QueueableSource and only calls -- Source:play once the first worker buffer lands (~1 frame later). Until -- then Source:isPlaying is false -- callers that treat that as "song over" @@ -303,6 +323,17 @@ function ChipAudio.invalidate() if workerReady and cmdCh then cmdCh:push({ cmd = "invalidate" }) end end +-- End the worker thread. LOVE waits for every live love.thread before the +-- process exits and the worker's command loop only returns on "quit", so +-- skipping this leaves the process running after the window is gone (#339). +function ChipAudio.shutdown() + ChipAudio.stopMusic() + if workerReady and cmdCh then cmdCh:push({ cmd = "quit" }) end + if worker then pcall(function() worker:wait() end) end + worker, cmdCh, outCh = nil, nil, nil + workerReady = false +end + -- Runtime mix for one hardware channel (1..4). Takes effect on the next -- synthesized buffer (live music) and on any SFX/cry rendered after the call. function ChipAudio.setChannelVolume(hw, scale) diff --git a/src/core/Music.lua b/src/core/Music.lua index 74b4b1ff..e218658b 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -85,6 +85,7 @@ local function fanfareActive() local ok, playing = pcall(src.isPlaying, src) if ok and playing then return true end state.fanfare = nil + require("src.core.ChipAudio").holdMusic(false) return false end @@ -94,6 +95,9 @@ end function Music.duckForFanfare(src) if not src then return end state.fanfare = src + -- ChipAudio is what starts a chip song, so the pause below cannot hold one + -- that has not started yet (nor one Music.play swaps in mid-jingle) (#398) + require("src.core.ChipAudio").holdMusic(true) if state.source then local ok, playing = pcall(state.source.isPlaying, state.source) if ok and playing then diff --git a/src/core/Sound.lua b/src/core/Sound.lua index 5ef8b754..9fdf51f3 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -111,6 +111,9 @@ local function played(kind, name, species) Runtime.emit("sound.played", { kind = kind, name = name, species = species }) end +-- returns the started source (nil headless, or when the def failed to load) +-- so callers that block on a fanfare like the original's +-- PlaySoundWaitForCurrent -> WaitForSoundToFinish can poll it function Sound.play(data, name) local sfx = data.audio and data.audio.sfx local def = sfx and sfx[name] @@ -120,6 +123,7 @@ function Sound.play(data, name) require("src.core.Music").duckForFanfare(src) end played("sfx", name) + return src end -- Play a move's sound with its MoveSoundTable pitch/tempo modifiers diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 82609f51..32b83697 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -6,7 +6,7 @@ local RomImporter = {} RomImporter.__index = RomImporter -- Cache generation tag; bump to force every imported version to re-extract. -local CACHE_FORMAT = "rom-cache-v7:" +local CACHE_FORMAT = "rom-cache-v8:" -- The completion marker is written under each version's cache prefix -- (rom-cache.complete for Red, blue/rom-cache.complete for Blue). local MARKER_PATH = "rom-cache.complete" diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 4af5ca02..1cc6b368 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -35,6 +35,48 @@ PaletteFX.MODE_LABELS = { } PaletteFX.mode = "gbc" +-- ------- dark-cave state (wMapPalOffset) +-- +-- Unlike the per-frame shadeMap further down, this outlives a frame: ADVANCED +-- resolves real colour per tile and BAKES it (tileset atlas, sprite sheets), +-- so FadePal2's shift has to reach those bakes and their cache keys rather +-- than a shader (#383). OverworldState owns it: armed before the map's atlas +-- is built, cleared by FLASH. +local darkWorld = false + +-- returns true when the flag actually changed, so the caller can rebuild +function PaletteFX.setDarkWorld(on) + on = on and true or false + if darkWorld == on then return false end + darkWorld = on + return true +end + +function PaletteFX.darkWorld() return darkWorld end + +-- cache-key suffix for anything baked under the dark shift +function PaletteFX.darkKey() return darkWorld and "#dark" or "" end + +-- FadePal2 sets rOBP0 = `dc 3,3,3,2` as well as rBGP, so EVERY OBJ colour a +-- sprite can carry lands on shade 3: the player, trainers and item balls are +-- black silhouettes until FLASH (#383). Applied to whatever 4-colour OBP the +-- active mode resolved, with a distinct cache group so the lit and dark bakes +-- of one sheet never collide in SpriteRenderer's obpCache. +function PaletteFX.darkObp(colors, group) + if not (colors and darkWorld) then return colors, group end + return PaletteFX.permute(colors, PaletteFX.DARK_BGP), tostring(group) .. "dark" +end + +-- the same shift folded into a baked 8-group world palette array (ADVANCED) +local function darkGroups(groups) + if not (groups and darkWorld) then return groups end + local out = {} + for i = 1, #groups do + out[i] = PaletteFX.permute(groups[i], PaletteFX.DARK_BGP) + end + return out +end + -- Classic DMG pea-soup greens (#9BBC0F / #8BAC0F / #306230 / #0F380F) PaletteFX.CLASSIC = { { 155, 188, 15 }, { 139, 172, 15 }, { 48, 98, 48 }, { 15, 56, 15 }, @@ -93,9 +135,13 @@ end -- Red bake with a Blue one and one version would show the other's colors. -- Yellow: same Red OBJ green as above until Yellow-specific tables land. function PaletteFX.ogObj() - if GameVersion.isBlue() then return PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue" end - if GameVersion.isYellow() then return PaletteFX.GBC_OBJ, "gbcobj" end - return PaletteFX.GBC_OBJ, "gbcobj" + if GameVersion.isBlue() then + return PaletteFX.darkObp(PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue") + end + if GameVersion.isYellow() then + return PaletteFX.darkObp(PaletteFX.GBC_OBJ, "gbcobj") + end + return PaletteFX.darkObp(PaletteFX.GBC_OBJ, "gbcobj") end -- The DMG object ramp every mode except OG RED bakes onto overworld sprites, @@ -114,7 +160,7 @@ PaletteFX.OBP0_SHADES = { } function PaletteFX.dmgObj() - return PaletteFX.OBP0_SHADES, "obp0" + return PaletteFX.darkObp(PaletteFX.OBP0_SHADES, "obp0") end local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 } @@ -503,7 +549,7 @@ function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY) local w = pack and pack.world local base = w and w.groupColors[tileset] if not base then return nil end - if not w.roofGroup[tileset] then return base end + if not w.roofGroup[tileset] then return darkGroups(base) end local roofMapId = mapId if mapId == ROUTE_6_SAFFRON.mapId and playerCellY and playerCellY < ROUTE_6_SAFFRON.cellYBelow then @@ -511,7 +557,7 @@ function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY) end local roofMap = data and data.maps and data.maps[roofMapId] local roof = roofMap and w.roofByMapIndex[roofMap.index] - if not roof then return base end + if not roof then return darkGroups(base) end local out = {} for i = 1, 8 do out[i] = base[i] end -- LoadTownPalette only overwrites W2_BgPaletteData + $32, i.e. colors 1 @@ -521,7 +567,7 @@ function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY) -- material's 2 middle shades are town-specific local base4 = base[ROOF_GROUP + 1] out[ROOF_GROUP + 1] = { base4[1], roof[1], roof[2], base4[4] } - return out + return darkGroups(out) end -- an overworld sprite's resolved 4-color OBJ palette (ColorOverworldSprite), @@ -552,7 +598,7 @@ function PaletteFX.spriteObp(spriteDef, seed) for i = 1, #seed do h = (h * 31 + seed:byte(i)) % 4294967296 end group = h % 4 end - return w.spritePalettes[group], group + return PaletteFX.darkObp(w.spritePalettes[group], group) end -- GetHealthBarColor (home/palettes.asm) on the standard 48px bar diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 099f4b56..0fdd861e 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -437,12 +437,26 @@ function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target) return true end --- clamp a scissor rect to the viewport box -local function scissorClamped(x, y, w, h, ox, oy, vpw, vph) +-- Clamp a scissor rect to the viewport box, then round it outward to whole +-- framebuffer pixels. love.graphics.setScissor truncates x, y, w and h to +-- pixels independently, so a rect with fractional unit edges (Android's +-- non-integer DPI puts fitScale/dpi in Sx/Sy) loses up to a pixel per side +-- and two adjacent SGB zones stop sharing an edge: the letterbox clear shows +-- through as a horizontal seam at every zone boundary (#373). Rounding +-- outward makes neighbours overlap by at most one row instead -- the overlap +-- redraws the same canvas pixels one palette later, and past the canvas edge +-- there is nothing to draw. The half pixel keeps LOVE's truncation on the +-- snapped edge rather than one short of it. +local function scissorClamped(x, y, w, h, ox, oy, vpw, vph, dpiX, dpiY) local x2, y2 = math.min(x + w, ox + vpw), math.min(y + h, oy + vph) x, y = math.max(x, ox), math.max(y, oy) if x2 <= x or y2 <= y then return false end - love.graphics.setScissor(x, y, x2 - x, y2 - y) + dpiX, dpiY = dpiX or 1, dpiY or 1 + local px1, py1 = math.floor(x * dpiX), math.floor(y * dpiY) + local px2, py2 = math.ceil(x2 * dpiX), math.ceil(y2 * dpiY) + love.graphics.setScissor((px1 + 0.5) / dpiX, (py1 + 0.5) / dpiY, + (px2 - px1 + 0.5) / dpiX, + (py2 - py1 + 0.5) / dpiY) return true end @@ -572,7 +586,7 @@ function Renderer:endFrame(zones, worldZones) if not plain then PaletteFX.sendColors(shader, z.colors) end if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy, z.w * zoneSx, z.h * zoneSy, - boxX, boxY, boxW, boxH) then + boxX, boxY, boxW, boxH, dpiX, dpiY) then love.graphics.draw(canvas, bx, by, 0, sx, sy) end end diff --git a/src/render/SpriteRenderer.lua b/src/render/SpriteRenderer.lua index 12b1f910..3f39f1d9 100644 --- a/src/render/SpriteRenderer.lua +++ b/src/render/SpriteRenderer.lua @@ -135,7 +135,10 @@ local function blitFrame(image, quad, x, y, flip, redraw) end end -function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip) +-- topHalf blits only the upper 8 rows of the frame: FishingAnim overwrites the +-- bottom tile row of the standing frames with the fishing pose art, which the +-- caller then draws itself through :drawTile (Player:draw, #384) +function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, topHalf) local x = math.floor(px - camX) local y = math.floor(py - camY) - 4 local image = self.image @@ -190,7 +193,39 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip) flip = true end local quad = self.frames[frame] or self.frames[0] + if topHalf then + self.halfFrames = self.halfFrames or {} + if not self.halfFrames[frame] then + local iw, ih = self.image:getDimensions() + self.halfFrames[frame] = love.graphics.newQuad(0, frame * 16, 16, 8, iw, ih) + end + quad = self.halfFrames[frame] + end blitFrame(image, quad, x, y, flip, redraw) end +-- Blit a loose 16-wide fx tile at screen (x, y) wearing THIS sprite's OBJ +-- palette, mirroring the mode branches in :draw above. The fishing pose row +-- overwrites the sheet's own tiles in VRAM in the original, so it has to be +-- recolored and OG-RED-redrawn exactly like the sheet rather than blitted as +-- raw DMG shades (#384). +function SpriteRenderer:drawTile(path, x, y, flip) + local image, redraw = getImage(path), false + if self.def.trueColor then + PaletteFX.markTrueColor(x, y, 16, 8) + elseif PaletteFX.usesGbcPack() then + local colors, group = PaletteFX.spriteObp(self.def, self.seed) + if colors then image = getObpImage(path, colors, group) end + elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then + image, redraw = getObpImage(path, PaletteFX.ogObj()), true + else + image = getObpImage(path, PaletteFX.dmgObj()) + end + local iw, ih = image:getDimensions() + self.tileQuads = self.tileQuads or {} + self.tileQuads[path] = self.tileQuads[path] + or love.graphics.newQuad(0, 0, iw, ih, iw, ih) + blitFrame(image, self.tileQuads[path], x, y, flip, redraw) +end + return SpriteRenderer diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index 2970a68a..f616d252 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -402,8 +402,15 @@ end -- the route's own default roof (Vermilion's) throughout. local gbcAtlasCache = {} +-- Cache suffix for a map's RED++ bake. A dark cave folds FadePal2 into the +-- palette worldGroupColors hands the bake (#383), so the lit and dark bakes of +-- one map are different images and must not share a key. +local function gbcKeyFor(mapId) + return "#gbc:" .. mapId .. PaletteFX.darkKey() +end + local function getGbcAtlas(imagePath, tilesetId, mapId, perRow, data) - local key = imagePath .. "#gbc:" .. mapId + local key = imagePath .. gbcKeyFor(mapId) if gbcAtlasCache[key] ~= nil then return gbcAtlasCache[key] or nil end local img = false if love.image and love.image.newImageData then @@ -473,7 +480,7 @@ function TileRenderer.new(map, data) self.gbcAtlas = true -- also recolors the animated water/flower entries below, so they -- match the atlas's static tiles instead of showing raw grayscale - gbcCtx = { tilesetId = map.tileset.id, mapId = map.id, key = "#gbc:" .. map.id, + gbcCtx = { tilesetId = map.tileset.id, mapId = map.id, key = gbcKeyFor(map.id), groupColors = PaletteFX.worldGroupColors(data, map.tileset.id, map.id, nil) } -- ...and feeds the color-0-keyed single tiles the feet overdraw needs -- (see getKeyedTile): same source image and palette groups, so keep the @@ -481,6 +488,7 @@ function TileRenderer.new(map, data) gbcCtx.imagePath = map.tileset.image gbcCtx.perRow = map.tileset.tilesPerRow self.gbcCtx = gbcCtx + self.gbcAtlasKey = map.tileset.image .. gbcCtx.key self.gbcKeyed = {} end end @@ -582,7 +590,7 @@ local function ensureWaterBorderFill(self) local groupColors = PaletteFX.worldGroupColors( self.data, map.tileset.id, map.id, nil) colors = group and groupColors and groupColors[group + 1] or nil - gbcKey = "#gbc:" .. map.id + gbcKey = gbcKeyFor(map.id) end local textures = getShiftVariants(map.tileset.image, perRow, WATER_TILE, colors, gbcKey) @@ -913,8 +921,9 @@ function TileRenderer:release() self.gbcCtx = nil end if self.gbcAtlas and self.image then - local key = self.map.tileset.image .. "#gbc:" .. self.map.id + local key = self.gbcAtlasKey or (self.map.tileset.image .. gbcKeyFor(self.map.id)) if gbcAtlasCache[key] == self.image then gbcAtlasCache[key] = nil end + self.gbcAtlasKey = nil safeRelease(self.image) self.image = nil self.gbcAtlas = nil diff --git a/src/save_convert/GenSave.lua b/src/save_convert/GenSave.lua index 5f4a7ea2..06d170fc 100644 --- a/src/save_convert/GenSave.lua +++ b/src/save_convert/GenSave.lua @@ -84,6 +84,15 @@ O.coins = O.mainData + 685 -- 2B BCD -- (wWalkBikeSurfState) + 10 = 359, so 685 + 359 = 1044 as well. O.townVisited = O.mainData + 1044 -- 2B (flag_array NUM_CITY_MAPS) O.eventFlags = O.mainData + 1104 -- 320B (flag_array NUM_EVENTS = 2560 bits) +-- Progress bits vanilla keeps OUTSIDE wEventFlags that this port still spells +-- as save.flags entries (#396). Offsets walk forward from wTownVisitedFlag +-- over the same ram/wram.asm declaration run the 60-byte gap above sums: +-- +29 wStatusFlags1, +35 wStatusFlags4, +41 wElite4Flags, +-- +44 wCompletedInGameTradeFlags. +O.statusFlags1 = O.townVisited + 29 -- 1B +O.statusFlags4 = O.townVisited + 35 -- 1B +O.elite4Flags = O.townVisited + 41 -- 1B +O.tradeFlags = O.townVisited + 44 -- 2B (flag_array NUM_NPC_TRADES) -- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the -- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into -- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified @@ -314,6 +323,34 @@ local BADGE_BY_BIT = { local BADGE_BY_BIT_SET = {} for _, name in pairs(BADGE_BY_BIT) do BADGE_BY_BIT_SET[name] = true end +-- save.flags names whose vanilla home is NOT wEventFlags (#396: exporting a +-- save and importing it back made the Saffron gate guards thirsty again, +-- because BIT_GAVE_SAFFRON_GUARDS_DRINK is a wStatusFlags1 bit and nothing +-- carried it). Bit numbers are constants/ram_constants.asm; the trade bits +-- are wWhichTrade, which engine/events/in_game_trades.asm uses to index +-- wCompletedInGameTradeFlags, i.e. the data/events/trades.asm row order the +-- port's `trade` command takes 1-based. +local EXTRA_FLAG_BITS = { + EVENT_GOT_OLD_ROD = { O.statusFlags1, 3 }, + EVENT_GOT_GOOD_ROD = { O.statusFlags1, 4 }, + EVENT_GOT_SUPER_ROD = { O.statusFlags1, 5 }, + EVENT_GAVE_GUARDS_DRINK = { O.statusFlags1, 6 }, + EVENT_GOT_LAPRAS = { O.statusFlags4, 0 }, + EVENT_STARTED_ELITE_4 = { O.elite4Flags, 1 }, + EVENT_TRADED_NIDORINO_FOR_NIDORINA = { O.tradeFlags, 0 }, + EVENT_TRADED_ABRA_FOR_MR_MIME = { O.tradeFlags, 1 }, + EVENT_TRADED_PONYTA_FOR_SEEL = { O.tradeFlags, 3 }, + EVENT_TRADED_SPEAROW_FOR_FARFETCHD = { O.tradeFlags, 4 }, + EVENT_TRADED_SLOWBRO_FOR_LICKITUNG = { O.tradeFlags, 5 }, + EVENT_TRADED_POLIWHIRL_FOR_JYNX = { O.tradeFlags, 6 }, + EVENT_TRADED_RAICHU_FOR_ELECTRODE = { O.tradeFlags, 7 }, + EVENT_TRADED_VENONAT_FOR_TANGELA = { O.tradeFlags, 8 }, + EVENT_TRADED_NIDORAN_M_FOR_NIDORAN_F = { O.tradeFlags, 9 }, +} + +-- port-local name -> the wEventFlags name it means (#396) +local FLAG_ALIAS = { EVENT_RECEIVED_BIKE_VOUCHER = "EVENT_GOT_BIKE_VOUCHER" } + -- STATUS_* bits (constants/battle_constants.asm): 0-2 sleep-turns-left, -- 3 PSN, 4 BRN, 5 FRZ, 6 PAR local STATUS_BIT = { PSN = 3, BRN = 4, FRZ = 5, PAR = 6 } @@ -671,6 +708,14 @@ function GenSave.decode(bytes, data, opts) end end + -- the same progress under names that are not wEventFlags bits (#396) + for name, spec in pairs(EXTRA_FLAG_BITS) do + if bitGet(bytes, spec[1], spec[2]) then save.flags[name] = true end + end + for portName, vanillaName in pairs(FLAG_ALIAS) do + if save.flags[vanillaName] then save.flags[portName] = true end + end + -- FLY destinations. wTownVisitedFlag's bit index IS the town's map index: -- engine/items/town_map.asm BuildFlyLocationsList loads the 16-bit value -- into de and rotates it right one bit per iteration with b counting up @@ -794,6 +839,19 @@ function GenSave.encode(save, data, template) local bitIdx = events.byName[name] if bitIdx then bitSet(buf, O.eventFlags, bitIdx, true) end end + for portName, vanillaName in pairs(FLAG_ALIAS) do + local bitIdx = events.byName[vanillaName] + if bitIdx and save.flags[portName] then bitSet(buf, O.eventFlags, bitIdx, true) end + end + end + + -- Non-wEventFlags progress, written both ways: this port's save is the only + -- authority for these names, so a flag it does not hold must clear the + -- template's bit rather than survive in the export (#396). + if save.flags then + for name, spec in pairs(EXTRA_FLAG_BITS) do + bitSet(buf, spec[1], spec[2], save.flags[name] and true or false) + end end -- FLY destinations back into wTownVisitedFlag (see the decode note), so a diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 488c927b..0f95614c 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -225,11 +225,23 @@ function Commands.give_item(ctx, itemId, count, gotText) ctx.game.stringBuffer = def and def.name or itemId -- the jingle rides the box -- Sound.play routes fanfares through -- Music.duckForFanfare, like PlaySoundWaitForCurrent - require("src.core.Sound").play(ctx.game.data, - (def and def.keyItem) and "Get_Key_Item" or "Get_Item1") + local Sound = require("src.core.Sound") + local jingle = (def and def.keyItem) and "Get_Key_Item" or "Get_Item1" if gotText ~= false then + -- the gift texts carry the jingle as a trailing text command + -- (sound_get_item_1 / sound_get_key_item -> home/text.asm + -- TextCommand_SOUND), so it only fires once the last page has typed + -- out, blocks on WaitForSoundToFinish, and AfterDisplayingTextID's + -- button wait runs after it (#374) + ctx.textOpts = ctx.textOpts or {} + ctx.textOpts.auto = { + sound = function() return Sound.play(ctx.game.data, jingle) end, + wait = true, + } Commands.show_text(ctx, gotText or Strings("{PLAYER} got\n%s!", ctx.game.stringBuffer)) + else + Sound.play(ctx.game.data, jingle) end end diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 95cb7b38..90c53517 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -305,9 +305,11 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) -- HP medicine: fill the bar in the still-open picker first, then print -- and close, the order item_effects.asm .doneHealing runs in -- (SFX_HEAL_HP -> UpdateHPBar2 -> RedrawPartyMenu prints the message). - -- picker is nil for every other item and for in-battle use, which keeps - -- the pop-then-print path below. #252 - if picker and extra and extra.healedFrom and target then + -- Only a keepOpen picker is still on the stack to animate: every other + -- item, and every in-battle use, popped it in PartyMenu before onSwitch, + -- and takes the pop-then-print path below -- which is the path that + -- spends the battle turn. #252, #379 + if picker and picker.keepOpen and extra and extra.healedFrom and target then picker:animateTo(target, extra.healedFrom, function() showMessages(game, payload, closePicker) end) diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index 0dc79c88..a69b590f 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -486,7 +486,10 @@ end function OakSpeech:advance() self.step = self.step + 1 - self.picFlip = false + -- picFlip belongs to the pic, not to the step: OakSpeechText2 prints 2A + -- and 2B over one flipped NIDORINO with no redraw between them + -- (oak_speech.asm:80-83), so a pic-less step must not un-mirror what is + -- still on screen; only applyPic and the demo step may change it (#397) local steps = self.steps if not steps then -- enter() builds steps; keep a path for callers that advance early diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 422208b0..feff3630 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -351,18 +351,23 @@ function PartyMenu:update(dt) end }) return elseif action == "flash" then -- FLASH lights dark tunnels - -- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText, then - -- GBPalWhiteOutWithDelay3 + jp .goBackToMap + -- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText runs + -- with the party menu still on screen, and only then + -- GBPalWhiteOutWithDelay3 + jp .goBackToMap. So the message reads + -- over the menu, and the cave is lit when the blink hands the + -- screen back, never under the text (#385). local ow = self.game.overworld local TextBox = require("src.render.TextBox") local Transition = require("src.render.Transition") - self.game.stack:pop() - ow.dark = false self.game.save.flashLit = true self.game.stack:push(TextBox.new(self.game, self.game.data.text._FlashLightsAreaText or Strings("A blinding FLASH\nlights the area!"), function() - self.game.stack:push(Transition.whiteFlash(self.game)) + self:close() + -- setDark, not a bare field write: ADVANCED carries the darkness + -- in a baked atlas, so lighting the cave rebuilds it (#383) + self.game.stack:push(Transition.whiteFlash(self.game, nil, + function() ow:setDark(false) end)) end)) return elseif action == "surf" then @@ -377,9 +382,11 @@ function PartyMenu:update(dt) local reason = ow:useSurfFieldMove() local Transition = require("src.render.Transition") if reason == "ok" then - self.game.stack:pop() -- close the party menu (jp .goBackToMap) + -- UseItem prints _SurfingGotOnText with the party menu still up; + -- GBPalWhiteOutWithDelay3 + jp .goBackToMap only follow it, so + -- trySurf closes this menu when its text does (#385) local fx, fy = ow.player:facingCell() - ow:trySurf(fx, fy) + ow:trySurf(fx, fy, function() self:close() end) return end if reason == "dismount" then @@ -410,9 +417,10 @@ function PartyMenu:update(dt) -- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but -- never zeroes wActionResultOrTookBattleTurn, so unlike the -- other refusals the menu still closes afterwards - -- (GBPalWhiteOutWithDelay3 + .goBackToMap) - self.game.stack:pop() + -- (GBPalWhiteOutWithDelay3 + .goBackToMap), and the text prints + -- over the still-open menu like every other .loop refusal (#385) self.game.stack:push(TextBox.new(self.game, txt, function() + self:close() self.game.stack:push(Transition.whiteFlash(self.game)) end)) return @@ -454,18 +462,19 @@ function PartyMenu:update(dt) local Transition = require("src.render.Transition") local def = self.game.data.pokemon[mon.species] local name = mon.nickname or def.name - self.game.stack:pop() -- close the party menu (jp .goBackToMap) ow.strengthActive = true local t1 = (self.game.data.text._UsedStrengthText or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name) local t2 = (self.game.data.text._CanMoveBouldersText or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name) - -- like surf (#320): the blink belongs UNDER the texts, not as a - -- flashbang on the empty map after them; the stack only updates - -- the top state, so the flash holds until the texts close - self.game.stack:push(Transition.whiteFlash(self.game)) + -- like surf (#320, #385): both texts print with the party menu + -- still on screen, and the blink IS the menu closing afterwards, + -- not a flashbang on the empty map self.game.stack:push(TextBox.new(self.game, t1, function() - self.game.stack:push(TextBox.new(self.game, t2)) + self.game.stack:push(TextBox.new(self.game, t2, function() + self:close() + self.game.stack:push(Transition.whiteFlash(self.game)) + end)) end, { auto = { sound = function() return require("src.core.Sound").playCry(self.game.data, mon.species) end } })) diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index 3c393818..9d33c005 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -22,13 +22,17 @@ TitleState.isOpaque = true -- LOGO2 blue / LOGO1 red (issue #133). A trailing trueColor zone leaves the -- overlay's DMG black unshaded while the logo and title mon keep title pals. -- --- ROM SuperPal whites are often {255,239,255}. Under RED++, LOGO2/MEWMON --- come from the GBC pack (pure white) while Blue's LOGO1 stays on the ROM --- pack (#128), so the version-ribbon row reads as a pink band. Force that --- slot to pure white; ink colors (Blue/Red "Version" text) stay intact. -local function withPureWhite(pal) +-- Every title SuperPal shares color 0 on hardware (sgb_palettes.asm: LOGO1, +-- LOGO2 and MEWMON all start RGB 31,29,31), so the ribbon band's white has to +-- be the neighbouring zones' white. Under RED++, LOGO2/MEWMON come from the +-- GBC pack (pure white) while Blue's LOGO1 stays on the ROM pack (#128) and +-- the row reads as a pink band; taking LOGO2's white fixes that without +-- forcing pure white in SGB, where a brighter band drew two faint lines +-- across the title (#373). Ink colors (Blue/Red "Version" text) stay intact. +local function withWhiteOf(pal, ref) if not pal then return nil end - return { { 255, 255, 255 }, pal[2], pal[3], pal[4] } + if not (ref and ref[1]) then return pal end + return { ref[1], pal[2], pal[3], pal[4] } end function TitleState:sgbPalettes(game) @@ -46,9 +50,10 @@ function TitleState:sgbPalettes(game) P.zone(logoPal, 9, 8, 10, 8), } else + local logoPal = P.pal(game.data, "LOGO2") z = { - P.zone(P.pal(game.data, "LOGO2"), 0, 0, 19, 7), - P.zone(withPureWhite(P.pal(game.data, "LOGO1")), 0, 8, 19, 9), + P.zone(logoPal, 0, 0, 19, 7), + P.zone(withWhiteOf(P.pal(game.data, "LOGO1"), logoPal), 0, 8, 19, 9), P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17), } end @@ -427,7 +432,7 @@ end -- the version ribbon at (7,8), Red's title art as OAM at px (82,80), -- the title mon in the 7x7 box at tile (5,10), copyright on row 17. -- Yellow (title_yellow.asm): logo (2,1), speech bubble (6,4), Pikachu --- (4,8) 12x9 — no version ribbon, no cycling mon, no Red OAM. +-- (4,8) 12x9 -- no version ribbon, no cycling mon, no Red OAM. function TitleState:draw() love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) diff --git a/src/update/Check.lua b/src/update/Check.lua index a5e82653..b06b1f61 100644 --- a/src/update/Check.lua +++ b/src/update/Check.lua @@ -174,4 +174,14 @@ function Check.download() cmdCh:push({ cmd = "download" }) end +-- End the worker thread. Its command loop sits in Channel:demand(), which +-- never returns on its own, and LOVE waits for every live love.thread before +-- the process exits (#339). +function Check.shutdown() + if cmdCh then cmdCh:push({ cmd = "quit" }) end + if worker then pcall(function() worker:wait() end) end + worker, cmdCh, stateCh = nil, nil, nil + workerReady = false +end + return Check diff --git a/src/world/FieldDefaults.lua b/src/world/FieldDefaults.lua index 65565aff..f62efdfc 100644 --- a/src/world/FieldDefaults.lua +++ b/src/world/FieldDefaults.lua @@ -128,6 +128,23 @@ FieldDefaults.FIELD = { -- the one-shot flag the gate's pass text is gated on; a gate a mod adds -- gets "PASSED_" instead of this pre-v2 spelling badgeGates = { ROUTE_22_GATE = { passedFlag = "PASSED_ROUTE22_GATE" } }, + -- the Rocket Hideout lift gates the floor callbacks stamp shut + -- (scripts/RocketHideoutB1F.asm / RocketHideoutB4F.asm + -- ...DoorCallbackScript); seeds caches imported before the manifest + -- carried them, which left both gates standing open (#372) + cardKeyDoors = { + closedDoors = { + ROCKET_HIDEOUT_B1F = { + { block = 0x54, bx = 12, by = 8, open = 0x0e, + event = "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4" }, + }, + ROCKET_HIDEOUT_B4F = { + { block = 0x2d, bx = 12, by = 5, open = 0x0e, + events = { "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0", + "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1" } }, + }, + }, + }, -- VermilionGymSetDoorTile opens the motorized door once both locks are hit hiddenExtras = { -- PrintTrashText bins (#188); seeds stale caches missing the key diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index fcacea3b..e2719c3e 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -65,6 +65,16 @@ local ROD_OAM = { right = { dx = 16, dy = 4, tile = 1, flip = true }, -- dbsprite 11, 10, 0, 0, $fe, XFLIP } +-- field.darkMaps (home/overworld.asm's dark-map check): the floors that run +-- with wMapPalOffset = 6 until FLASH +local function isDarkMap(mapId) + local darkDef = Game.data.field.darkMaps + for _, m in ipairs(darkDef and darkDef.maps or {}) do + if m == mapId then return true end + end + return false +end + -- object_event spawn filter (toggleable_objects, items taken, beaten -- static encounters), shared by the current map's real NPCs and the -- visual-only ghosts on connected neighbor maps @@ -188,8 +198,50 @@ function OverworldState:enter(mapId, x, y, facing) -- survives save/load: a loaded game may start inside a building whose -- exit mat is a LAST_MAP warp self.lastOutdoor = Game.save.lastOutdoor - self.justWarped = false self:setMap(mapId, x, y, facing, { via = "boot" }) + -- boot/load: derive the flag from the tile the save left us standing on, + -- like MapEntryAfterBattle's IsPlayerStandingOnWarp, so a game saved on a + -- door mat can still walk straight back out (issue #378) + self:refreshStandingOnWarp() +end + +-- Silph Co card key doors + Rocket Hideout elevator gates: the .blk +-- layouts ship with the doorways open; each floor's map script stamps +-- the closed door block on load until its unlock event is set +-- (scripts/SilphCo2F.asm SilphCo2FGateCallbackScript et al., closed +-- blocks $54/$5f/$20; scripts/RocketHideoutB1F.asm + +-- RocketHideoutB4F.asm ...DoorCallbackScript, closed blocks $54/$2d over +-- the lift doorway). A door opens on its single `event`, or on `events` +-- when every listed flag must be set (Rocket Hideout B4F's lift gate +-- needs both guard trainers beaten -- CheckBothEventsSet). The callbacks +-- run whenever BIT_CUR_MAP_LOADED_1 is set, which is map load AND the end +-- of a battle on that map (home/trainers.asm EndTrainerBattle), so the +-- gate opens with SFX_GO_INSIDE the moment the last guard falls (#372). +function OverworldState:stampClosedDoors() + local closedDoors = FieldDefaults.fieldValue(Game.data, "cardKeyDoors", + "closedDoors") + local floorDoors = self.map and closedDoors and closedDoors[self.map.id] + if not floorDoors then return end + local stamped, unlocked = false, false + for _, door in ipairs(floorDoors) do + local open + if door.events then + open = true + for _, ev in ipairs(door.events) do + if not Game.save.flags[ev] then open = false break end + end + else + open = Game.save.flags[door.event] + end + local want = open and door.open or door.block + if self.map:blockAt(door.bx, door.by) ~= want then + self.map:setBlock(door.bx, door.by, want) + stamped = true + if open then unlocked = true end + end + end + if stamped then self.map.renderer:rebuild() end + if unlocked then require("src.core.Sound").play(Game.data, "Go_Inside") end end function OverworldState:setMap(mapId, x, y, facing, opts) @@ -222,6 +274,13 @@ function OverworldState:setMap(mapId, x, y, facing, opts) self.tileAnimOverride.tileset.animation = self.tileAnimOverride.animation self.tileAnimOverride = nil end + -- ADVANCED bakes colour into the tileset atlas, so the dark-cave shift has + -- to be armed before that atlas is built for this map (#383); self.dark is + -- settled below, once the map record is in hand. + if PaletteFX.setDarkWorld(isDarkMap(mapId) and not Game.save.flashLit) + and PaletteFX.usesGbcPack() then + MapLoader.invalidateAll() + end self.map = MapLoader.load(Game.data, mapId) -- STRENGTH deactivates on every real map load (home/overworld.asm -- EnterMap -> ResetUsingStrengthOutOfBattleBit clears BIT_STRENGTH_ACTIVE @@ -241,38 +300,7 @@ function OverworldState:setMap(mapId, x, y, facing, opts) self.map.renderer:rebuild() self.cutBlocks[mapId] = nil end - -- Silph Co card key doors + Rocket Hideout elevator gates: the .blk - -- layouts ship with the doorways open; each floor's map script stamps - -- the closed door block on load until its unlock event is set - -- (scripts/SilphCo2F.asm SilphCo2FGateCallbackScript et al., closed - -- blocks $54/$5f/$20; scripts/RocketHideoutB1F.asm + - -- RocketHideoutB4F.asm ...DoorCallbackScript, closed blocks $54/$2d over - -- the lift doorway). A door opens on its single `event`, or on `events` - -- when every listed flag must be set (Rocket Hideout B4F's lift gate - -- needs both guard trainers beaten -- CheckBothEventsSet). - local closedDoors = FieldDefaults.fieldValue(Game.data, "cardKeyDoors", - "closedDoors") - local floorDoors = closedDoors and closedDoors[mapId] - if floorDoors then - local stamped = false - for _, door in ipairs(floorDoors) do - local open - if door.events then - open = true - for _, ev in ipairs(door.events) do - if not Game.save.flags[ev] then open = false break end - end - else - open = Game.save.flags[door.event] - end - local want = open and door.open or door.block - if self.map:blockAt(door.bx, door.by) ~= want then - self.map:setBlock(door.bx, door.by, want) - stamped = true - end - end - if stamped then self.map.renderer:rebuild() end - end + self:stampClosedDoors() -- forced dismount only where riding is disallowed (IsBikeRidingAllowed, -- home/overworld.asm: bike_riding_tilesets.asm tilesets plus the -- ROUTE_23/INDIGO_PLATEAU map exceptions) @@ -294,18 +322,11 @@ function OverworldState:setMap(mapId, x, y, facing, opts) -- Rock Tunnel darkness (wMapPalOffset, home/overworld.asm): dark -- until FLASH is used; the light persists between the tunnel floors -- and resets once outside - local darkDef = Game.data.field.darkMaps - self.dark = false - if darkDef then - local isDark = false - for _, m in ipairs(darkDef.maps) do - if m == mapId then isDark = true break end - end - if isDark then - self.dark = not Game.save.flashLit - else - Game.save.flashLit = nil - end + if isDarkMap(mapId) then + self:setDark(not Game.save.flashLit) + else + Game.save.flashLit = nil + self:setDark(false) end if Game.data.field.flyWarps[mapId] then Game.save.visited = Game.save.visited or {} @@ -576,17 +597,19 @@ function OverworldState:sgbWorldZones() return zones end --- Whether the dark-map shade shift (PaletteFX.DARK_BGP, armed in drawWorld) --- can actually reach this frame's world pass. It cannot in RED++: that mode --- bakes real per-tile colour into the tileset atlas and sgbWorldZones returns --- an EMPTY zone list above, so the world blits with no shade-remap shader at --- all and there is no palette left to permute. Only then does fxDark --- composite the darkness by hand (#322). -function OverworldState:darkNeedsOverlay() - if not self.dark then return false end - local renderer = self.map and self.map.renderer - return PaletteFX.usesGbcPack() and renderer ~= nil - and renderer.gbcAtlas ~= nil +-- wMapPalOffset, the one piece of state both halves of the darkness read: +-- drawWorld arms PaletteFX.DARK_BGP off self.dark for the shade-remapped +-- modes, and PaletteFX.setDarkWorld feeds the bakes ADVANCED does instead of +-- shading (tileset atlas, sprite sheets) plus their cache keys. A bake cannot +-- be re-shaded in place, so a change there rebuilds every resident map -- +-- every dark floor, not just this one, since FLASH lights them all (#383). +function OverworldState:setDark(on) + on = on and true or false + self.dark = on + if PaletteFX.setDarkWorld(on) and PaletteFX.usesGbcPack() and self.map then + MapLoader.invalidateAll() + self:reloadMap(self.map.id, "dark") + end end function OverworldState:npcByIndex(index) @@ -795,6 +818,15 @@ function OverworldState:update(dt) if ca.onDone then ca.onDone() end end end + -- fishing pose tail: the rod is already gone, the pose holds for the + -- frames the original spends unwinding the item menu (#384) + if self.fishPose then + self.fishPose = self.fishPose - 1 + if self.fishPose <= 0 then + self.fishPose = nil + self.player.fishing = nil + end + end -- Yellow's companion hopping up onto the Poke Center counter owns the -- world for its arc, the same way the heal machine below does (#417) if self.pikaHop then @@ -961,19 +993,33 @@ function OverworldState:dirHeld() or input:isDown("left") or input:isDown("right") end --- The warp cell the player WARPED IN on is inert until they physically step --- off it: standing on it, or bonking a wall/edge from it, must not re-fire a --- warp (CheckWarpsNoCollision's arrival-disable; see setMap where --- warpEntryCell/justWarped are set and onStepComplete where they clear). --- Both stand-still warp triggers -- the map-edge exit (checkEdgeExit) and the --- blocked-step collision warp (handleInput) -- must consult this, or a corner --- staircase whose warp tile sits on the map edge (Red's-house (7,1)) bounces --- floors every input frame (issue #230). -function OverworldState:onWarpArrivalCell() - if self.justWarped then return true end - local entry = self.warpEntryCell - return entry ~= nil and self.player.cellX == entry.x - and self.player.cellY == entry.y +-- BIT_STANDING_ON_WARP (wMovementFlags): the warp under the player's feet may +-- only fire from a collision -- the blocked-step warp (handleInput) and the +-- map-edge exit (checkEdgeExit) -- while this flag is set. pokered clears it +-- on every completed step, sets it again when that step lands on a warp +-- square, then clears it once more when the square is a warp-activating tile +-- that is not also a door tile (CheckWarpsNoCollisionLoop -> +-- IsPlayerStandingOnDoorTileOrWarpTile, engine/overworld/player_state.asm); +-- onStepComplete maintains it. ClearVariablesOnEnterMap does not clear +-- wMovementFlags, so the flag rides through the warp itself: a house door +-- tile ($1B) leaves it set, so you land on the interior mat still able to +-- walk back out on that same tile (issue #378), while a staircase tile +-- ($1A/$1C) clears it and cannot bounce you between floors (issue #230). +function OverworldState:canCollisionWarp() + return self.standingOnWarp == true +end + +-- Re-derive the flag from the tile under the player, the way a completed step +-- does (and the way MapEntryAfterBattle's IsPlayerStandingOnWarp does after a +-- battle): a door tile keeps it, a stair/ladder warp tile clears it. +function OverworldState:refreshStandingOnWarp() + local p = self.player + self.standingOnWarp = false + if self.map:warpAtCell(p.cellX, p.cellY) + and not (self.map:isWarpTileCell(p.cellX, p.cellY) + and not self.map:isDoorTileCell(p.cellX, p.cellY)) then + self.standingOnWarp = true + end end function OverworldState:handleInput() @@ -1017,9 +1063,9 @@ function OverworldState:handleInput() local result, why = self.player:tryMove(dir, self.map, self.entities) -- a collision while standing on a warp square fires the warp when the -- extra check passes (CheckWarpsCollision: route-gate doorways, dock - -- entrances, ...) -- but never on the inert cell we just warped in on - -- (issue #230), which the completed-step path guards the same way. - if result == "blocked" and not self:onWarpArrivalCell() then + -- entrances, ...), and only while BIT_STANDING_ON_WARP is set (issue + -- #230), which the map-edge path guards the same way. + if result == "blocked" and self:canCollisionWarp() then local w = Warp.onCollision(self.map, Game.data.field.warpCarpets, self.player.cellX, self.player.cellY, dir) if w then @@ -1175,11 +1221,11 @@ function OverworldState:checkEdgeExit(dir) local w = Warp.onEdge(self.map, p.cellX, p.cellY, dir) if w then - -- ...but not while still standing on the warp cell we just arrived on - -- (issue #230): fall through so pushing into the edge bonks (SFX + - -- walk-in-place) instead of instantly re-warping. A real step onto an - -- exit-carpet edge cleared warpEntryCell first, so those still fire. - if self:onWarpArrivalCell() then return false end + -- ...but only with BIT_STANDING_ON_WARP set: a staircase tile clears it, + -- so pushing into the edge beside one bonks (SFX + walk-in-place) instead + -- of bouncing floors (issue #230), while the door mat you warped in on + -- keeps it and exits on that same tile (issue #378). + if not self:canCollisionWarp() then return false end self:takeWarp(w.def) return true end @@ -1405,6 +1451,7 @@ function OverworldState:goFishing(rod) -- the bobber waits a beat before the verdict (the original's -- FishingInit dot animation); the rod pose draws in the meantime self.fishing = { facing = self.player.facing } + self.player.fishing = true Game.stack:push(TextBox.new(Game, ". . .", function() -- FishingAnim (engine/overworld/player_animations.asm) holds -- BIT_LEDGE_OR_FISHING -- the rod OAM and the fishing pose -- through @@ -1412,12 +1459,20 @@ function OverworldState:goFishing(rod) -- must NOT vanish with the dots box (#321). if not enc then Game.stack:push(TextBox.new(Game, Strings("Not even a nibble!"), function() + -- the rod OAM goes out with the verdict box (res BIT_LEDGE_OR_FISHING + -- straight after PrintText) but the player keeps the patched tiles + -- until the overworld reloads them a few frames later + -- (RestoreScreenTilesAndReloadTilePatterns, home/palettes.asm -> + -- ReloadMapSpriteTilePatterns, home/reload_sprites.asm) -- #384 self.fishing = nil + self.fishPose = 10 end)) return end Game.stack:push(TextBox.new(Game, Strings("Oh!\nIt's a bite!"), function() + -- the bite goes straight into battle, which reloads the sprite tiles self.fishing = nil + self.player.fishing = nil local BattleState = require("src.battle.BattleState") local battle = BattleState.newWild(Game, enc.species, enc.level, { hooked = true }) if Game.save.safari and Map.inRegion(self.map.def, "SAFARI", "SAFARI_ZONE") then @@ -2098,25 +2153,26 @@ end -- Gen 1 has no confirmation prompt: using SURF gets straight on -- (_SurfingGotOnText, item_effects.asm .surf). Called from the party -- menu's SURF action (via useSurfFieldMove) once the facing tile has been --- confirmed to be water -- there is no overworld A-press hook. -function OverworldState:trySurf(fx, fy) +-- confirmed to be water -- there is no overworld A-press hook. onClose is +-- that menu's own close, called when the got-on text ends (see below). +function OverworldState:trySurf(fx, fy, onClose) local mon = self:partyKnows("SURF") if not mon then return end local name = mon.nickname or Game.data.pokemon[mon.species].name local p = self.player local text = (Game.data.text._SurfingGotOnText or Strings("{PLAYER} got on\n{RAM:wNameBuffer}!")) :gsub("{RAM:wNameBuffer}", name) - -- GBPalWhiteOutWithDelay3 runs while the got-on text is still up - -- (start_sub_menus.asm .surf), so the blink reads as a text flash - -- instead of a flashbang on the empty map (#320). The flash sits - -- under the textbox on the stack: only the top state updates, so it - -- holds its frames until the text closes. surfing (and the sprite - -- swap) only applies when the step happens -- no paddling on land. - Game.stack:push(require("src.render.Transition").whiteFlash(Game)) + -- UseItem prints the got-on text with the party menu still on screen and + -- GBPalWhiteOutWithDelay3 + .goBackToMap only run after it + -- (start_sub_menus.asm .surf), so the text reads over the menu and the + -- blink is the menu closing, not a flashbang on the empty map (#320, + -- #385). The mount rides the blink, so nothing paddles on land. Game.stack:push(TextBox.new(Game, text, function() + if onClose then onClose() end p.surfing = true require("src.core.Music").setSurfing(Game.data, true) - self:stepForwardOrCrossEdge(p.facing) + Game.stack:push(require("src.render.Transition").whiteFlash(Game, nil, + function() self:stepForwardOrCrossEdge(p.facing) end)) end)) end @@ -3050,18 +3106,16 @@ function OverworldState:onStepComplete() entry = nil end -- The arrival disable is POSITIONAL: warpEntryCell above is the whole - -- test. justWarped only records that an arrival happened (it still - -- backs onWarpArrivalCell's bonk guard for issue #230), so consuming a - -- completed step with it swallowed the warp under the player's feet, - -- which is why a second ladder one cell from the first did nothing - -- (Seafoam B3F has warp tiles on (25,3) and (25,4)) -- issue #265. - -- pokered has no such counter: every completed step runs - -- CheckWarpsNoCollision (home/overworld.asm), and BIT_STANDING_ON_WARP, - -- the flag the bonk path needs, is only set by - -- CheckWarpsNoCollisionLoop itself or by IsPlayerStandingOnWarp from - -- MapEntryAfterBattle, never on a plain warp arrival -- which is - -- exactly what warpEntryCell reproduces. - self.justWarped = false + -- test. Consuming a completed step with a one-shot "just warped" counter + -- instead swallowed the warp under the player's feet, which is why a + -- second ladder one cell from the first did nothing (Seafoam B3F has warp + -- tiles on (25,3) and (25,4)) -- issue #265. pokered has no such counter: + -- every completed step runs CheckWarpsNoCollision (home/overworld.asm). + -- That same step is where BIT_STANDING_ON_WARP is maintained: cleared + -- before the check (home/overworld.asm:324), set again while standing on a + -- warp square, then cleared once more when the square is a warp-activating + -- tile that is not a door tile (IsPlayerStandingOnDoorTileOrWarpTile). + self:refreshStandingOnWarp() if entry then -- still standing on the warp we arrived through; do not re-trigger it else @@ -3499,6 +3553,10 @@ function OverworldState:afterBattle(result, battle) { save = Game.save, healTarget = self:healPoint() }) self:warpToHealPoint(evolutions) else + -- EndTrainerBattle sets BIT_CUR_MAP_LOADED_1 (home/trainers.asm), which + -- re-runs the floor's door callback: beating the last Rocket Hideout guard + -- opens the lift gate without leaving the map (#372) + self:stampClosedDoors() -- throwing the last SAFARI BALL ends the game if Game.save.safari and Game.save.safari.balls <= 0 then self:safariGameOver(Strings("PA: You're out of\nSAFARI BALLs!")) @@ -3632,12 +3690,12 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) self.arriveWarp = nil Game.stack:push(Transition.new(Game, function() self:setMap(mapId, x, y, facing or "down", opts) - self.justWarped = true - -- The warp we land ON stays inert until we physically step off it, so a - -- warp whose destination cell is itself a warp cannot bounce us straight - -- back (elevator cars, stacked stair/door mats). This generalizes the - -- one-step justWarped guard, which only skipped the very next frame's - -- check and so let a mon walked back onto the pad re-trigger it. + -- The warp we land ON stays inert for the completed-step check until we + -- physically step off it, so a warp whose destination cell is itself a + -- warp cannot bounce us straight back (elevator cars, stacked stair/door + -- mats). BIT_STANDING_ON_WARP is deliberately NOT touched here: + -- ClearVariablesOnEnterMap leaves wMovementFlags alone, so the flag the + -- departing tile set rides through the warp (issue #378). self.warpEntryCell = { x = x, y = y } -- Fly/Teleport/Dig/Escape-Rope landings poof the player back in -- (player_animations.asm EnterMapAnim). Blackouts and ordinary @@ -3663,8 +3721,8 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) -- PlayerStepOutFromDoor (engine/overworld/auto_movement.asm): any -- warp that lands on a door tile auto-steps south once, indoor or -- outdoor. Auto-walk leaves the mat, so the arrival disable - -- (warpEntryCell / justWarped) is unnecessary -- and would let you - -- stand on the door without re-entering if you hold back into it. + -- (warpEntryCell) is unnecessary -- and would let you stand on the + -- door without re-entering if you hold back into it. -- The walk-out is a simulated d-pad press (wSimulatedJoypadStates), -- not a forced move, so it obeys collision: on a landing with a -- solid cell south of the door (the mansion stair landings back @@ -3673,7 +3731,6 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) if self.map:isDoorTileCell(self.player.cellX, self.player.cellY) then if Collision.canMove(self.map, self.entities, self.player, "down") then self.warpEntryCell = nil - self.justWarped = false self:scriptMove(self.player, "down", 1) else self.player.facing = "down" @@ -4116,23 +4173,6 @@ function OverworldState:drawWorld() end end - -- Rock Tunnel darkness. The original never cuts a window of light around - -- the player: it shifts the BG palette for the WHOLE screen (wMapPalOffset - -- = 6 -> home/fade.asm LoadGBPal -> FadePal2 `dc 3,3,3,2`) and FLASH shifts - -- it back (#322). PaletteFX.DARK_BGP does that for every shade-remapped - -- mode, armed at the top of drawWorld. RED++ is the one mode with no - -- palette left to shift -- TileRenderer bakes true colour into the tileset - -- atlas and sgbWorldZones hands the blit an EMPTY zone list, so no shader - -- runs over the world at all -- so there the darkness is composited instead: - -- a flat veil over the whole world view (surveying still cannot peek past - -- it) at the 85/255 brightness FadePal2 leaves DMG white on. - local function fxDark() - if not self:darkNeedsOverlay() then return end - love.graphics.setColor(0, 0, 0, 1 - 85 / 255) - love.graphics.rectangle("fill", 0, 0, vw, vh) - love.graphics.setColor(1, 1, 1, 1) - end - -- the FLY bird sweeping off with the player local function fxBird() if not self.flyAnim then return end @@ -4210,7 +4250,7 @@ function OverworldState:drawWorld() return PaletteFX.pal(Game.data, self:paletteNameFor(map or self.map)) end, fx = { heal = fxHeal, dust = fxDust, cutTree = fxCutTree, - emote = fxEmote, dark = fxDark, bird = fxBird, rod = fxRod }, + emote = fxEmote, bird = fxBird, rod = fxRod }, } -- Draw every active field FX into the finished scene. `project(wx, wy)` -- maps a world point to canvas pixels (nil when it is behind the @@ -4261,17 +4301,6 @@ function OverworldState:drawWorld() if self.fishing then at(fxRod, self.player.px + 8, self.player.py + 16) end - -- Rock Tunnel darkness is a screen-space veil, not a ground object: - -- draw it flat over the finished scene like the tilt path. It fills - -- the view in world-pixel units, so it only needs the scale, and it is - -- only needed at all in the mode whose palette cannot carry the - -- darkening itself (see fxDark). - if self:darkNeedsOverlay() then - love.graphics.push() - love.graphics.scale(scale, scale) - fxDark() - love.graphics.pop() - end end override = Pipelines.drawWorld(pipelineId, ctx) -- world post-processes (a miniature-diorama blur, a colour grade) fold @@ -4334,7 +4363,6 @@ function OverworldState:drawWorld() fxDust() fxCutTree() fxEmote() - fxDark() fxBird() fxRod() else @@ -4423,12 +4451,6 @@ function OverworldState:drawWorld() self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxRod) end - -- Rock Tunnel darkness is a screen-space veil, not a ground object -- - -- draw it flat into the upright canvas so it darkens the final - -- composited scene uniformly. It no-ops unless this mode needs the - -- composited fallback (see fxDark). - fxDark() - Game.renderer:endUprightPass() end diff --git a/src/world/Player.lua b/src/world/Player.lua index af6aeca8..b331b7f4 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -42,6 +42,20 @@ function Player.new(data, cx, cy, facing) local ok, img = pcall(love.graphics.newImage, fx.shadow.path) self.shadowImg = ok and img or nil end + -- FishingAnim (engine/overworld/player_animations.asm) patches tiles + -- $02/$06/$0a -- the bottom tile row of each standing frame -- with + -- RedFishingTiles before it parks the rod OAM, so the rod stroke meets a + -- pair of hands instead of ending in mid air (#384) + if fx then + local function posePath(name) + local def = fx[name] + return def and def.path or nil + end + local pose = { down = posePath("redFishFront"), up = posePath("redFishBack") } + pose.left = posePath("redFishSide") + pose.right = pose.left -- the side pose mirrors like the sprite (OAM_XFLIP) + if pose.down or pose.up or pose.left then self.fishTiles = pose end + end self.cellX, self.cellY = cx, cy self.px, self.py = cx * 16, cy * 16 self.facing = facing or "down" @@ -231,7 +245,10 @@ function Player:pose() py = py - math.floor((total - self.spinFrames) * 24 / total) end end - local sprite = (self.surfing and self.surfSprite) + -- RodResponse (engine/items/item_effects.asm) zeroes wWalkBikeSurfState + -- across FishingAnim, so casting from the water shows the on-foot sheet + local sprite = (self.fishing and self.sprite) + or (self.surfing and self.surfSprite) or (self.onBike and self.bikeSprite) or self.sprite return sprite, self.px, py, facing, phase, flip, hopping end @@ -263,6 +280,17 @@ function Player:draw(camX, camY) love.graphics.draw(self.shadowImg, sx + 16, sy + 16, 0, -1, -1) end end + -- Fishing pose: the standing frame with its bottom tile row swapped for + -- RedFishingTiles, which is where the hands and the near half of the rod + -- live; the far half is the rod OAM OverworldState draws (FishingRodOAM, + -- engine/overworld/player_animations.asm) -- #384 + local fishTile = self.fishing and self.fishTiles and self.fishTiles[facing] + if fishTile then + sprite:draw(px, py, camX, camY, facing, 0, false, true) + sprite:drawTile(fishTile, math.floor(px - camX), + math.floor(py - camY) - 4 + 8, facing == "right") + return + end sprite:draw(px, py, camX, camY, facing, phase, flip) end diff --git a/tests/drivers/battle_item_turn_bug379_test.lua b/tests/drivers/battle_item_turn_bug379_test.lua new file mode 100644 index 00000000..a74697a4 --- /dev/null +++ b/tests/drivers/battle_item_turn_bug379_test.lua @@ -0,0 +1,295 @@ +-- Driver: a POTION used in battle prints, moves the HUD bar and spends the +-- turn (#379). pokered engine/items/item_effects.asm:1-3 leaves +-- wActionResultOrTookBattleTurn at its success value, and ItemUseMedicine only +-- clears it on .healingItemNoEffect (:1241), so a medicine that lands costs +-- the turn; the party-menu bar fill is the field case (#252). Data half: +-- tests/parity_battle_item_turn.lua. Never under POKEPORT_SPEED: fast-forward +-- desynchronizes SFX_HEAL_HP from the bar. +-- POKEPORT_DRIVER=tests/drivers/battle_item_turn_bug379_test.lua POKEPORT_IDENTITY=bug379 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local Bag = require("src.inventory.Bag") + local BattleState = require("src.battle.BattleState") + local ItemEffects = require("src.inventory.ItemEffects") + local PartyMenu = require("src.ui.PartyMenu") + local TextBox = require("src.render.TextBox") + + local pass, fail = 0, 0 + local function check(label, ok) + if ok then pass = pass + 1 else fail = fail + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + local function top() return game.stack:top() end + local function isPicker(s) + return s ~= nil and (s.screenId == "PartyMenu" or getmetatable(s) == PartyMenu) + end + local function isBox(s) return getmetatable(s) == TextBox end + local function isBag(s) return s ~= nil and s.screenId == "BagMenu" end + local function inStack(pred) + for _, s in ipairs(game.stack.states or {}) do + if pred(s) then return true end + end + return false + end + local function boxText(s) + local out = {} + for _, page in ipairs(s.pages or {}) do + for _, line in ipairs(page) do out[#out + 1] = line end + end + return table.concat(out, " ") + end + + -- ---- preconditions ----------------------------------------------------- + U.log("#379 in-battle medicine: machine checks") + check("POTION takes the HP-medicine path", ItemEffects.healsHP("POTION") == true) + check("ANTIDOTE does not (status cure, always spent the turn)", + ItemEffects.healsHP("ANTIDOTE") ~= true) + for _, id in ipairs({ "POTION", "SUPER_POTION", "ANTIDOTE" }) do + check(id .. " resolves in the item table", game.data.items[id] ~= nil) + end + check("SFX_HEAL_HP is in the generated audio", + game.data.audio and game.data.audio.sfx + and game.data.audio.sfx.Heal_HP ~= nil) + check("BattleState:itemUsed exists (queues the foe's turn)", + type(BattleState.itemUsed) == "function") + + local vol = game.save.options and game.save.options.sfxVol + if vol == 0 then + U.log("sfxVol is 0: the heal chime will be SILENT, raise it in OPTION first") + else + U.log("sfxVol", tostring(vol), "-- expect the heal chime under the bar drain") + end + + -- use() must hand back the pre-heal HP; without it neither bar has a start + do + local scratch = Pokemon.new(game.data, "BULBASAUR", 20) + scratch.hp = 4 + local scratchSave = { party = { scratch }, inventory = {}, flags = {}, + pokedex = { seen = {}, owned = {} } } + local result, msgs, extra = ItemEffects.use(game.data, scratchSave, + "POTION", scratch) + check("POTION on a hurt mon reports consumed", result == "consumed") + check("...and hands back the pre-heal HP", + type(extra) == "table" and extra.healedFrom == 4) + check("...with the restored-HP message", + type(msgs) == "table" and type(msgs[1]) == "string" + and msgs[1]:find("restored", 1, true) ~= nil) + end + + -- ---- fixture ----------------------------------------------------------- + local lead = Pokemon.new(game.data, "CHARIZARD", 50) + local second = Pokemon.new(game.data, "PIKACHU", 30) + lead.hp = 12 + game.save.party = { lead, second } + game.save.player.name = "RED" + for _, id in ipairs({ "POTION", "SUPER_POTION", "ANTIDOTE" }) do + Bag.add(game.save, id, 9) + end + + -- ROUTE_1 is open field with tall grass either side of the path + -- (data/generated/maps.lua ROUTE_1); the cell comes off the loaded map so a + -- map edit degrades to somewhere else in the grass instead of a wall. + U.teleport(game, "ROUTE_1", 5, 5, "down") + U.wait(10) + local ow = game.overworld + local map = ow.map + local gx, gy + if not map:isWalkableCell(5, 5) then + for cy = 0, map.heightCells - 1 do + for cx = 0, map.widthCells - 1 do + if map:isGrassCell(cx, cy) and map:isWalkableCell(cx, cy) then + gx, gy = cx, cy + break + end + end + if gx then break end + end + if gx then + U.teleport(game, "ROUTE_1", gx, gy, "down") + U.wait(10) + ow = game.overworld + end + end + check("player is standing on a walkable ROUTE_1 cell", + ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY)) + + -- ---- navigation -------------------------------------------------------- + local function mashUntil(cond, max) + for _ = 1, max or 120 do + if cond() then return true end + U.tap(game, "a") + U.wait(4) + end + return cond() + end + + -- FIGHT PKMN / ITEM RUN (BattleState.menuIndex): down from FIGHT is ITEM + local function openBagFrom(battle) + for _ = 1, 30 do + if battle.menuIndex == 3 then break end + U.tap(game, battle.menuIndex > 2 and "up" or "down") + U.wait(4) + end + if battle.menuIndex ~= 3 then return nil, "ITEM cell unreachable" end + U.tap(game, "a") + U.wait(20) + local bag = top() + if not isBag(bag) then return nil, "bag never opened" end + return bag + end + + local function cursorTo(menu, want) + for _ = 1, 40 do + if not menu or menu.index == want then return menu and menu.index == want end + U.tap(game, menu.index < want and "down" or "up") + U.wait(3) + end + return menu.index == want + end + + local function chooseItem(bag, id) + local row + for i, r in ipairs(bag.items or {}) do + if r.value == id then row = i break end + end + if not row or not cursorTo(bag, row) then return false end + U.tap(game, "a") -- in battle A uses it straight away, no USE / TOSS + U.wait(16) + return true + end + + local function newFight() + local battle = BattleState.newWild(game, "PIDGEY", 8) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + mashUntil(function() return battle.phase == "menu" end) + return battle + end + + -- ======== scripted run: POTION on the hurt lead ========================== + U.log("#379 scripted run: POTION on a 12 HP CHARIZARD, mid-battle") + local battle = newFight() + check("the wild battle reached its FIGHT menu", battle.phase == "menu") + + local turns, ends = 0, 0 + local realAction, realEnd = battle.executeAction, battle.endOfTurn + battle.executeAction = function(self, ...) + turns = turns + 1 + return realAction(self, ...) + end + battle.endOfTurn = function(self, ...) + ends = ends + 1 + return realEnd(self, ...) + end + + local bag, why = openBagFrom(battle) + check("ITEM opened the bag" .. (why and (" (" .. why .. ")") or ""), bag ~= nil) + if bag then + U.shot(game, DIR .. "/bug379_bag_in_battle.png") + check("POTION chosen from the bag", chooseItem(bag, "POTION")) + local picker = top() + check("the party picker opened to pick a target", isPicker(picker)) + if isPicker(picker) then + check("keepOpen is off in battle (the fill is the field case, #252)", + picker.keepOpen ~= true) + cursorTo(picker, 1) + U.tap(game, "a") + U.wait(10) + + check("the mon was healed", lead.hp > 12) + check("no party-menu bar fill was started in battle", picker.heal == nil) + check("the picker popped itself", not inStack(isPicker)) + check("the bag list closed underneath it", not inStack(isBag)) + + -- THE DEFECT: the animate branch swallowed this message, and with it the + -- itemUsed tail that spends the turn + for _ = 1, 60 do + if isBox(top()) then break end + U.wait(1) + end + local box = top() + check("the restored-HP message printed (#379)", isBox(box)) + if isBox(box) then + U.wait(50) + local said = boxText(box) + U.log("box reads:", said) + check("...and it is the restored-HP line", + said:find("restored", 1, true) ~= nil) + U.shot(game, DIR .. "/bug379_message_in_battle.png") + end + + -- the turn: drain, the foe's move, end-of-turn, back to the menu + for _ = 1, 400 do + if battle.phase == "menu" and #battle.queue == 0 then break end + U.tap(game, "a") + U.wait(4) + end + check("the foe took its turn after the item (#379)", turns >= 1) + check("end-of-turn effects ran (#379)", ends >= 1) + check("and the FIGHT menu came back", battle.phase == "menu") + U.shot(game, DIR .. "/bug379_back_on_menu.png") + end + end + + -- ======== control: a refused POTION is free ============================= + U.log("#379 control: a POTION on a full-HP mon is refused, turn NOT spent") + lead.hp = lead.stats.hp + local freeTurns = 0 + battle.executeAction = function(self, ...) + freeTurns = freeTurns + 1 + return realAction(self, ...) + end + local held = game.save.inventory.POTION or 0 + local bag2 = openBagFrom(battle) + if check("the bag reopened for the refusal", bag2 ~= nil) then + chooseItem(bag2, "POTION") + local picker = top() + if isPicker(picker) then + cursorTo(picker, 1) + U.tap(game, "a") + U.wait(12) + end + for _ = 1, 60 do + if isBox(top()) then break end + U.wait(1) + end + local box = top() + if check("the refusal printed", isBox(box)) then + U.wait(40) + check("...as \"It won't have any effect.\"", + boxText(box):find("effect", 1, true) ~= nil) + U.shot(game, DIR .. "/bug379_refused.png") + end + check("a refused item costs no turn (item_effects.asm:1241)", freeTurns == 0) + check("and the POTION was not consumed", + (game.save.inventory.POTION or 0) == held) + end + + U.log(("machine checks: %d passed, %d failed"):format(pass, fail)) + + -- ---- re-arm and hand the pad over -------------------------------------- + for _ = 1, 200 do + if battle.phase == "menu" and #battle.queue == 0 then break end + U.tap(game, "a") + U.wait(4) + end + lead.hp = 12 + local handoff = openBagFrom(battle) + if handoff then + for i, r in ipairs(handoff.items or {}) do + if r.value == "POTION" then cursorTo(handoff, i) break end + end + end + + U.log("The ITEMS list is open mid-battle with the cursor on POTION. Press A,") + U.log("pick CHARIZARD: the picker and the list both close, \"CHARIZARD's HP was") + U.log("restored!\" prints with the heal chime, the HUD bar climbs, then PIDGEY") + U.log("attacks before FIGHT comes back. #379 was no message and a free heal.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/battle_shake_bug354_test.lua b/tests/drivers/battle_shake_bug354_test.lua new file mode 100644 index 00000000..70c52f98 --- /dev/null +++ b/tests/drivers/battle_shake_bug354_test.lua @@ -0,0 +1,273 @@ +-- Driver: the applying-attack shake pokered plays after a move's own +-- animation (#354). PlayApplyingAttackAnimation dispatches wAnimationType +-- 1..6 through AnimationTypePointerTable (engine/battle/animations.asm:475); +-- only 1 (vertical shake) and 4 (blink the enemy pic) were wired, so an +-- added-effect move like BUBBLEBEAM blinked instead of shaking sideways and a +-- status move showed nothing. Wiring half: tests/parity_applying_attack_anim.lua. +-- Never under POKEPORT_SPEED: fast-forward desynchronizes the damage sound. +-- POKEPORT_DRIVER=tests/drivers/battle_shake_bug354_test.lua POKEPORT_IDENTITY=bug354 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + local pass, fail = 0, 0 + local function check(label, ok) + if ok then pass = pass + 1 else fail = fail + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- pokered core.asm:3159 / effects.asm:1448: a player damaging move with an + -- added effect is type 5, without one type 4, and a primary status effect + -- that ends in PlayCurrentMoveAnimation2 is type 6. `frames` counts only + -- the frames the screen sits off-centre: b=2 out for 5 frames twice, and + -- the slow creep's five off-centre steps of 2 frames, twice over. + local MOVES = { + { slot = 1, id = "BUBBLEBEAM", want = 5, peak = 2, frames = 10 }, + { slot = 4, id = "TACKLE", want = 4 }, + { slot = 3, id = "GROWL", want = 6, peak = 3, frames = 20 }, + { slot = 2, id = "HYPNOSIS", want = 6, peak = 3, frames = 20 }, + } + + U.log("#354 applying-attack shake: machine checks") + for _, m in ipairs(MOVES) do + check(m.id .. " is in the move table", game.data.moves[m.id] ~= nil) + end + local anims = game.data.battle_anims and game.data.battle_anims.moveAnims + check("battle_anims carries BUBBLEBEAM's own animation", + anims ~= nil and anims.BUBBLEBEAM ~= nil) + check("...and HYPNOSIS's", anims ~= nil and anims.HYPNOSIS ~= nil) + check("BattleState:applyHitFx exists", type(BattleState.applyHitFx) == "function") + check("animations are on in OPTIONS (a shake is gated on them)", + game.save.options.animations ~= false) + + local vol = game.save.options and game.save.options.sfxVol + if vol == 0 then + U.log("sfxVol is 0: the damage thud under the shake will be SILENT,") + U.log("raise it in OPTION before judging the sound") + else + U.log("sfxVol", tostring(vol), "-- the damage thud opens types 1, 2, 4 and 5;") + U.log("types 3 and 6 (the slow creeps) are silent on purpose") + end + + -- ---- fixture ----------------------------------------------------------- + local squirtle = Pokemon.new(game.data, "SQUIRTLE", 30) + squirtle.moves = {} + for _, m in ipairs(MOVES) do + squirtle.moves[m.slot] = { id = m.id, pp = game.data.moves[m.id].pp } + end + game.save.party = { squirtle } + game.save.player.name = "RED" + + -- data/generated/maps.lua ROUTE_1: (5, 5) is open walkable ground, and the + -- battle is pushed straight in rather than encountered + U.teleport(game, "ROUTE_1", 5, 5, "down") + U.wait(10) + local ow = game.overworld + if not ow.map:isWalkableCell(5, 5) then + -- a map edit moved the path: take the nearest walkable cell instead + local sx, sy + for r = 1, 6 do + for dy = -r, r do + for dx = -r, r do + if not sx and ow.map:isWalkableCell(5 + dx, 5 + dy) then + sx, sy = 5 + dx, 5 + dy + end + end + end + if sx then break end + end + if sx then + U.log("(5, 5) is blocked, standing on", sx, sy) + U.teleport(game, "ROUTE_1", sx, sy, "down") + U.wait(10) + ow = game.overworld + end + end + check("the overworld is up on ROUTE_1", ow ~= nil) + + local battle = BattleState.newWild(game, "PIDGEY", 20) + battle.onFinish = function() end + ow:pushBattle(battle) + for _ = 1, 400 do + if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end + U.wait(1) + end + check("the battle reached the screen", game.stack:top() == battle) + + -- a foe that survives four turns: every type needs its own turn to show + battle.enemy.mon.stats.hp = 400 + battle.enemy.mon.hp = 400 + battle.enemy.shownHP = 400 + + -- ---- watch the fx layer ------------------------------------------------ + -- one entry per applying-attack row, labelled with the move that queued it + local rows, cur, using = {}, nil, nil + local realPerform, realFx = battle.performMove, battle.applyHitFx + battle.performMove = function(self, user, target, moveInst, ...) + using = { id = moveInst and moveInst.id, isPlayer = user.isPlayer } + return realPerform(self, user, target, moveInst, ...) + end + battle.applyHitFx = function(self, hit) + cur = { move = using and using.id, isPlayer = using and using.isPlayer, + animType = hit.animType, frames = 0, peakX = 0, peakY = 0 } + rows[#rows + 1] = cur + realFx(self, hit) + -- a spent blink table stays on fx with frames = 0, so freshness matters + local bl = self.fx and self.fx.blink + cur.blinked = bl ~= nil and bl.frames > 0 + end + + local function sample() + local fx = battle.fx + if not (cur and fx) then return end + local dx, dy = math.abs(fx.shakeX or 0), math.abs(fx.shakeY or 0) + if dx > 0 or dy > 0 then + cur.frames = cur.frames + 1 + cur.peakX = math.max(cur.peakX, dx) + cur.peakY = math.max(cur.peakY, dy) + end + end + + -- step n frames, sampling every one, pressing A every `mash` frames + local function pump(n, mash, stop) + for i = 1, n do + if mash and i % mash == 0 then + table.insert(game.input.pressQueue, "a") + end + U.wait(1) + game.input.state.a = false + sample() + if stop and stop() then return end + end + end + + local function toMenu() + pump(400, 6, function() return battle.phase == "menu" and #battle.queue == 0 end) + return battle.phase == "menu" + end + + -- FIGHT is menuIndex 1 of the 2x2 grid; A opens moveSelect, where up/down + -- walk the slots. A press that lands on a frame the battle is not reading + -- input is simply lost, so every step retries instead of assuming. + local function useMove(slot) + for _ = 1, 40 do + if battle.phase == "moveSelect" then break end + if battle.phase == "menu" then + if battle.menuIndex ~= 1 then + U.tap(game, battle.menuIndex > 2 and "up" or "left") + else + U.tap(game, "a") + end + end + U.wait(4) + end + if battle.phase ~= "moveSelect" then return false end + for _ = 1, 30 do + if battle.moveIndex == slot then break end + U.tap(game, battle.moveIndex < slot and "down" or "up") + U.wait(3) + end + if battle.moveIndex ~= slot then return false end + for _ = 1, 20 do + if battle.phase ~= "moveSelect" then return true end + U.tap(game, "a") + U.wait(3) + end + return false + end + + local function lastRowFor(id) + for i = #rows, 1, -1 do + if rows[i].move == id and rows[i].isPlayer then return rows[i] end + end + return nil + end + + local SHOTS = { BUBBLEBEAM = "bug354_bubblebeam_shake.png", + HYPNOSIS = "bug354_hypnosis_creep.png", + TACKLE = "bug354_tackle_blink.png" } + + check("the battle reached its FIGHT menu", toMenu()) + + for _, m in ipairs(MOVES) do + local sent = useMove(m.slot) + check("chose " .. m.id .. " from the move menu", sent) + if sent then + -- catch the shake mid-flight for the screenshot: the offset is live for + -- only a couple of dozen frames + local shotAt + -- A every 8 frames: the text box between the announcement and the + -- animation waits on the button like any other + pump(400, 8, function() + local live = battle.fx and (m.peak and (battle.fx.shakeX or 0) ~= 0 + or (not m.peak and battle.fx.blink + and battle.fx.blink.frames > 0)) + if not shotAt and live and SHOTS[m.id] then + shotAt = true + U.shot(game, DIR .. "/" .. SHOTS[m.id]) + end + local r = lastRowFor(m.id) + return r ~= nil and battle.fx.shakeProg == nil + and (r.frames > 0 or r.blinked) + end) + toMenu() + local r = lastRowFor(m.id) + check(m.id .. " queued an applying-attack row", r ~= nil) + if r then + check(("%s is animation type %d (got %s)") + :format(m.id, m.want, tostring(r.animType)), + r.animType == m.want) + U.log((" %s: %d frames off-centre, peak %dpx across / %dpx down, blink %s") + :format(m.id, r.frames, r.peakX, r.peakY, tostring(r.blinked))) + if m.peak then + -- the exact frame count is pinned in the parity suite; sampling from + -- a driver misses a frame whenever the logic step outruns the render + check(("%s moved the screen %dpx sideways"):format(m.id, m.peak), + r.peakX == m.peak) + check(("%s held it off-centre (%d of the routine's %d frames)") + :format(m.id, r.frames, m.frames), r.frames > 0) + check(m.id .. " did not blink the enemy pic instead (#354)", + r.blinked == false) + else + check(m.id .. " still blinks the enemy pic and holds still", + r.blinked == true and r.frames == 0) + end + end + if SHOTS[m.id] and not shotAt and m.peak then + U.log(" no off-centre frame to capture for " .. m.id) + end + end + end + + -- the foe's own turns: a plain damaging move is type 1, one with an added + -- effect type 2, SAND-ATTACK type 3 + local foe = {} + for _, r in ipairs(rows) do + if not r.isPlayer then + foe[#foe + 1] = ("%s type %s (%d frames, %dpx across, %dpx down)") + :format(tostring(r.move), tostring(r.animType), + r.frames, r.peakX, r.peakY) + end + end + U.log("the foe's rows so far: " .. (#foe > 0 and table.concat(foe, "; ") + or "none yet")) + U.log(("machine checks: %d passed, %d failed"):format(pass, fail)) + + -- ---- hand off ---------------------------------------------------------- + U.log("The pad is yours at the FIGHT menu. Slot 1 BUBBLEBEAM: after the") + U.log("bubbles and their white flashes the whole screen -- field, HUD, text") + U.log("box -- snaps 2px right and home four times, with the damage thud.") + U.log("Slot 2 HYPNOSIS and slot 3 GROWL creep it 1px at a time out to 3px") + U.log("and back, twice, in silence. Slot 4 TACKLE is the control: the foe's") + U.log("pic blinks and nothing moves. Let the PIDGEY hit back too -- a plain") + U.log("move drops the screen 8px vertically, SAND-ATTACK creeps it 6px across.") + U.log("Reference: https://youtu.be/4aBT7rjZoIE at 1:19.") + U.log("Screenshots: " .. DIR .. "/bug354_*.png") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/bide_anim_bug375_test.lua b/tests/drivers/bide_anim_bug375_test.lua new file mode 100644 index 00000000..72ea43d9 --- /dev/null +++ b/tests/drivers/bide_anim_bug375_test.lua @@ -0,0 +1,205 @@ +-- Eye/ear check on which animation BIDE plays when (#375). BideEffect ends on +-- `add XSTATITEM_ANIM / jp PlayBattleAnimation2` (engine/battle/effects.asm: +-- 764-789), so the storing turn is the silent X-item spiral; BIDE's own row +-- (sound Battle_18) belongs to .UnleashEnergy (engine/battle/core.asm:3501-3529). +-- POKEPORT_DRIVER=tests/drivers/bide_anim_bug375_test.lua POKEPORT_IDENTITY=bug375 POKEPORT_TOUCH=0 love . +-- No POKEPORT_SPEED: fast-forward scales the logic clock only and desyncs audio. +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local ChipAudio = require("src.core.ChipAudio") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + -- data/generated/maps.lua ROUTE_1: the open path south of the first grass + -- patch, and pokered data/maps/objects/Route1.asm parks its youngsters at + -- (5, 24) and (15, 13), so nobody is standing here or watching. + local MAP = "ROUTE_1" + local STAND = { x = 5, y = 5, facing = "down" } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- what the ear and eye cannot check --------------------------------- + local opts = game.save.options or {} + local sfxVol = opts.sfxVol or 7 + if sfxVol == 0 then + U.log("FAIL sfx volume is 0: the release hit plays no sound at all, so") + U.log(" the half of this that is audible cannot be judged. Set SFX") + U.log(" to 7 in OPTION first.") + end + check(("sfx volume %d"):format(sfxVol), sfxVol > 0) + if opts.animations == false then + U.log("FAIL OPTION has animations off, which skips every queued anim row") + U.log(" (BattleState:animationsOn). Turn ANIMATION on first.") + end + check("battle animations are on", opts.animations ~= false) + + local moveDef = game.data.moves.BIDE + check("BIDE is in the move table", moveDef ~= nil) + local anims = (game.data.battle_anims or {}).moveAnims or {} + check("BIDE has an animation", anims.BIDE ~= nil) + check("XSTATITEM_ANIM has an animation", anims.XSTATITEM_ANIM ~= nil) + check("XSTATITEM_DUPLICATE_ANIM has an animation", + anims.XSTATITEM_DUPLICATE_ANIM ~= nil) + -- the row's sound byte is a move id; playAnimSound resolves it through + -- moves.lua, which is where the actual sfx program name lives + local sfxName = moveDef and moveDef.anim and moveDef.anim.sound + check("BIDE's animation names a sound (" .. tostring(sfxName) .. ")", + sfxName ~= nil) + if sfxName then + local src = ((game.data.audio or {}).sfx or {})[sfxName] + and ChipAudio.newSfx(game.data, sfxName) or nil + local secs = src and src:getDuration() or 0 + check(("%s synthesizes (%.2fs)"):format(sfxName, secs), secs > 0) + end + + -- ---- a BIDE user, parked on Route 1 ------------------------------------ + local lead = Pokemon.new(game.data, "CHARMANDER", 20) + lead.moves = { { id = "BIDE", pp = 10 } } + game.save.party = { lead } + + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(15) + local ow = game.overworld + if not ow.map:isWalkableCell(STAND.x, STAND.y) then + -- a map edit or a mod blocked the cell: take any free neighbour + for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do + local cx, cy = STAND.x + d[1], STAND.y + d[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), cx, cy) + U.teleport(game, MAP, cx, cy, STAND.facing) + U.wait(15) + ow = game.overworld + break + end + end + end + check("standing on " .. MAP, ow.map.id == MAP) + + -- Every anim row this battle queues, in order. Polling the queue catches + -- the announcement-time row performMove inserts directly as well as the + -- ones animNext adds, which is the whole difference the fix makes. + local function watchAnims(battle) + local seen, mark = {}, {} + return seen, function() + for _, row in ipairs(battle.queue) do + if row.anim and not mark[row] then + mark[row] = true + seen[#seen + 1] = row.anim + end + end + end + end + + local function listOf(seen) + return #seen > 0 and table.concat(seen, ", ") or "(none)" + end + + local function has(seen, name) + for _, n in ipairs(seen) do + if n == name then return true end + end + return false + end + + -- Mash A while polling, so a row that comes and goes inside the wait is + -- still recorded. + local function mash(battle, poll, taps, gap) + for _ = 1, taps do + U.tap(battle.game, "a") + for _ = 1, gap do poll() U.wait(1) end + end + end + + -- the wipe and the send-out chain hold the battle in "intro" for a while, + -- and the send-out text needs the A presses to advance + local function waitPhase(battle, phase, tries, poll) + for _ = 1, tries do + if battle.phase == phase then return true end + mash(battle, poll, 1, 6) + end + return battle.phase == phase + end + + -- FIGHT then the only move in the list + local function pickBide(battle, poll) + U.tap(battle.game, "a") + if not waitPhase(battle, "moveSelect", 10, poll) then return false end + U.tap(battle.game, "a") + return true + end + + -- ---- player side ------------------------------------------------------- + local battle = BattleState.newWild(game, "PIDGEY", 8) + battle.onFinish = function() end + ow:pushBattle(battle) + local seen, poll = watchAnims(battle) + check("the battle reached the menu", waitPhase(battle, "menu", 60, poll)) + check("BIDE is the move on the list", pickBide(battle, poll)) + for _ = 1, 20 do poll() U.wait(1) end + U.shot(game, DIR .. "/bug375_store_spiral.png") + for _ = 1, 30 do poll() U.wait(1) end + U.shot(game, DIR .. "/bug375_store_text.png") + + check("the storing turn queued XSTATITEM_ANIM", has(seen, "XSTATITEM_ANIM")) + check("and not BIDE's hit animation: " .. listOf(seen), not has(seen, "BIDE")) + check("BIDE locked in for " .. tostring(battle.player.bideTurns) .. " turns", + battle.player.bideTurns ~= nil) + + -- ride out the locked turns: the storing text needs A presses and the menu + -- never comes back until BIDE unleashes + local released = false + for _ = 1, 120 do + U.tap(game, "a") + for _ = 1, 6 do poll() U.wait(1) end + if has(seen, "BIDE") then + released = true + break + end + if game.stack:top() ~= battle or battle.player.mon.hp <= 0 then break end + end + U.shot(game, DIR .. "/bug375_release.png") + check("the release queued BIDE's own animation: " .. listOf(seen), released) + for _ = 1, 60 do poll() U.wait(1) end + + -- ---- enemy side -------------------------------------------------------- + -- PlayBattleAnimation2 adds hWhoseTurn, so the foe's storing turn is + -- XSTATITEM_DUPLICATE_ANIM (AttackAnimationPointers[174]), never the + -- player row. + for _ = 1, 8 do + if game.stack:top() == ow then break end + game.stack:pop() + end + lead.hp = lead.stats.hp + local foeBattle = BattleState.newWild(game, "RATTATA", 8) + foeBattle.onFinish = function() end + foeBattle.enemy.mon.moves = { { id = "BIDE", pp = 10 } } + foeBattle.enemy.curMoves = foeBattle.enemy.mon.moves + ow:pushBattle(foeBattle) + local foeSeen, foePoll = watchAnims(foeBattle) + check("the foe's battle reached the menu", + waitPhase(foeBattle, "menu", 60, foePoll)) + pickBide(foeBattle, foePoll) -- ours does not matter here, the foe's does + for _ = 1, 60 do + if has(foeSeen, "XSTATITEM_DUPLICATE_ANIM") then break end + U.tap(game, "a") + for _ = 1, 6 do foePoll() U.wait(1) end + end + U.shot(game, DIR .. "/bug375_enemy_store.png") + check("the foe's storing turn queued XSTATITEM_DUPLICATE_ANIM: " + .. listOf(foeSeen), has(foeSeen, "XSTATITEM_DUPLICATE_ANIM")) + + U.log("The pad is yours in a battle where both sides know only BIDE, so") + U.log("pick FIGHT then BIDE and watch turn one: the screen palette flashes") + U.log("white and balls spiral inward, silently, and then it says storing") + U.log("energy. No flash on the RATTATA and no BIDE thud until the turn it") + U.log("unleashes, where the thud and its animation come after the text and") + U.log("before the enemy HP bar slides down. The foe's spiral looks the same.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/celadon_roof_readables_bug391_test.lua b/tests/drivers/celadon_roof_readables_bug391_test.lua new file mode 100644 index 00000000..047176d7 --- /dev/null +++ b/tests/drivers/celadon_roof_readables_bug391_test.lua @@ -0,0 +1,169 @@ +-- Manual check on the Celadon Mansion roof house blackboard and pamphlet (#391). +-- pokered data/events/hidden_events.asm CELADON_MANSION_ROOF_HOUSE declares both +-- with hidden_text_predef (LinkCableHelp, TMNotebook), which the extractor never +-- parsed, so both A presses were dead. The data half is in +-- tests/parity_celadon_roof_readables.lua. +-- POKEPORT_DRIVER=tests/drivers/celadon_roof_readables_bug391_test.lua POKEPORT_IDENTITY=bug391 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . (no POKEPORT_SPEED: fast-forward desyncs audio) +return function(game) + local U = dofile("tests/drivers/util.lua") + local TextBox = require("src.render.TextBox") + local Menu = require("src.ui.Menu") + -- data/scripts/init.lua, not src/script/MapScripts.lua: OverworldController + -- requires it lazily on the first map load, so the registrations it makes + -- are not in place yet when the driver starts + local MapScripts = require("data.scripts.init") + + local MAP = "CELADON_MANSION_ROOF_HOUSE" + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + -- hidden_events.asm: hidden_text_predef 3, 4 (pamphlet on the table) and + -- 3, 0 / 4, 0 (blackboard in the north wall). Both target cells are solid, + -- so the stand cell is the walkable one below, facing up. + local PAMPHLET = { x = 3, y = 4 } + local BLACKBOARD = { x = 3, y = 0 } + local BLACKBOARD_ALT = { x = 4, y = 0 } + local BALL = { x = 4, y = 3 } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local hooks = MapScripts.get(MAP) + check(MAP .. " registers an onInteract hook", + hooks ~= nil and type(hooks.onInteract) == "function") + check("the Eevee ball talk entry is still registered", + hooks ~= nil and hooks.talk ~= nil + and hooks.talk.TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL ~= nil) + for _, key in ipairs({ "_LinkCableHelpText1", "_LinkCableHelpText2", + "_LinkCableInfoText1", "_LinkCableInfoText2", "_LinkCableInfoText3" }) do + local s = game.data.text[key] + check(key .. " resolves to a string", type(s) == "string" and s ~= "") + end + + local opts = game.save.options or {} + U.log("audio device present:", love.audio ~= nil, + " SFX VOL (0-7):", tostring(opts.sfxVol)) + if not love.audio or opts.sfxVol == 0 then + U.log("WARNING: sfx output is off, so the menu beeps and the wall bonk will", + "not be audible; raise SFX VOL in OPTION first") + end + + -- reach the pamphlet: warps land at (2,7)/(3,7), so start below the table + U.teleport(game, MAP, PAMPHLET.x, PAMPHLET.y + 2, "up") + U.wait(10) + local ow = game.overworld + check("map " .. MAP .. " loaded", ow ~= nil and ow.map ~= nil) + check("the pamphlet cell is solid, as the table it sits on", + not ow.map:isWalkableCell(PAMPHLET.x, PAMPHLET.y)) + check("the blackboard cell is solid, as the wall it hangs on", + not ow.map:isWalkableCell(BLACKBOARD.x, BLACKBOARD.y)) + U.hold(game, "up", 20) + U.wait(10) + + -- a map edit or a mod could move the table: any walkable neighbour will do, + -- {dx, dy, facing} is the offset from the readable plus the look back at it + local SIDES = { + { 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" }, + } + -- alt is the blackboard's second cell: hidden_text_predef declares 3,0 and + -- 4,0 with the same handler, so either one facing up is the right spot + local function faceCell(cell, alt) + local p = game.overworld.player + local fx, fy = p:facingCell() + if fx == cell.x and fy == cell.y then return true end + if alt and fx == alt.x and fy == alt.y then return true end + for _, s in ipairs(SIDES) do + local cx, cy = cell.x + s[1], cell.y + s[2] + if game.overworld.map:isWalkableCell(cx, cy) + and not game.overworld:npcAtCell(cx, cy) then + U.log(("(%d,%d) was not faced from where the walk ended, standing on") + :format(cell.x, cell.y), cx, cy, "facing", s[3]) + U.teleport(game, MAP, cx, cy, s[3]) + U.wait(10) + return true + end + end + return false + end + + local function boxText(box) + local out = {} + for _, page in ipairs(box.pages or {}) do + for _, line in ipairs(page) do out[#out + 1] = line end + end + return table.concat(out, " / ") + end + + check("standing where the pamphlet is faced", faceCell(PAMPHLET)) + U.tap(game, "a") + U.wait(30) + local top = game.stack:top() + local isBox = getmetatable(top) == TextBox + check("A on the pamphlet opened a text box", isBox) + if isBox then + U.log("box reads:", boxText(top)) + check("it is the TM pamphlet, not the generic bookshelf line", + boxText(top):find("pamphlet", 1, true) ~= nil) + U.shot(game, SHOT_DIR .. "/bug391_pamphlet.png") + end + -- clear the box: five pages of TMNotebookText, then back to the overworld + for _ = 1, 40 do + if game.stack:top() == game.overworld then break end + U.tap(game, "a") + U.wait(8) + end + check("the pamphlet closed and gave the overworld back", + game.stack:top() == game.overworld) + + -- reach the blackboard: the table blocks column 3 between the two, so restart + -- from the row below the wall rather than walking the long way around it + U.teleport(game, MAP, BLACKBOARD.x, BLACKBOARD.y + 2, "up") + U.hold(game, "up", 20) -- one step onto row 1, then a bonk on the north wall + U.wait(10) + U.log("walked to", game.overworld.player.cellX, game.overworld.player.cellY, + "facing", game.overworld.player.facing) + check("standing where the blackboard is faced", + faceCell(BLACKBOARD, BLACKBOARD_ALT)) + U.tap(game, "a") + U.wait(30) + top = game.stack:top() + isBox = getmetatable(top) == TextBox + check("A on the blackboard opened a text box", isBox) + if isBox then + U.log("box reads:", boxText(top)) + check("it is _LinkCableHelpText1", + boxText(top):find("TRAINER TIPS", 1, true) ~= nil) + end + -- through the intro and the prompt into the heading menu + local menu + for _ = 1, 40 do + local t = game.stack:top() + if getmetatable(t) == Menu then menu = t break end + U.tap(game, "a") + U.wait(8) + end + check("the prompt opened the heading menu", menu ~= nil) + if menu then + local labels = {} + for i, item in ipairs(menu.items or {}) do labels[i] = item.label end + U.log("menu rows:", table.concat(labels, " / ")) + check("four rows in HowToLinkText order", + table.concat(labels, "/") + == "HOW TO LINK/COLOSSEUM/TRADE CENTER/STOP READING") + U.shot(game, SHOT_DIR .. "/bug391_blackboard_menu.png") + U.log("captured", SHOT_DIR .. "/bug391_blackboard_menu.png") + end + + U.log("The heading menu on screen is the moment to eyeball: a 15x10 box in") + U.log("the top-left corner, four rows, nothing clipped. Pick a heading and") + U.log("its blurb prints, then the prompt and this menu should come back.") + U.log("B or STOP READING drops you back outside and you can walk again.") + U.log(("Then stand at (%d,%d) facing down and press A: the ball on the table") + :format(BALL.x, BALL.y - 1)) + U.log("still hands over EEVEE, since the same script file changed.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/elevator_panel_bug395_test.lua b/tests/drivers/elevator_panel_bug395_test.lua new file mode 100644 index 00000000..459b3cb2 --- /dev/null +++ b/tests/drivers/elevator_panel_bug395_test.lua @@ -0,0 +1,139 @@ +-- Manual check that the elevator floor menu waits for the panel (#395). +-- pokered data/maps/objects/CeladonMartElevator.asm has it as `bg_event 3, 0, +-- TEXT_CELADONMARTELEVATOR`, and only that text reaches +-- DisplayElevatorFloorMenu (engine/events/elevator.asm), which rewrites the +-- car's warps and returns without moving the player. No POKEPORT_SPEED here: +-- fast-forward desynchronizes the ride's audio. +-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/elevator_panel_bug395_test.lua POKEPORT_IDENTITY=bug395 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local ListMenu = require("src.ui.ListMenu") + local mapScripts = require("data.scripts.init") + + -- data/generated/maps.lua CELADON_MART_ELEVATOR: sign (3,0) is the panel, + -- warp (1,3) is the arrival tile, row 0 is wall, so the panel is read from + -- (3,1) facing up. 2F is the floor picked below. + local MAP = "CELADON_MART_ELEVATOR" + local TEXT = "TEXT_CELADONMARTELEVATOR" + local ARRIVE = { x = 1, y = 3 } + local PANEL = { x = 3, y = 0 } + local STAND = { x = 3, y = 1, facing = "up" } + local PICK = "CELADON_MART_2F" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local def = game.data.maps[MAP] + local sign + for _, s in ipairs(def.signs or {}) do + if s.text == TEXT then sign = s end + end + check(TEXT .. " is a sign in the map data", sign ~= nil) + if sign then + check(("the panel sits at (%d,%d)"):format(PANEL.x, PANEL.y), + sign.x == PANEL.x and sign.y == PANEL.y) + end + check("the panel text runs a script", type(mapScripts.talkScript(MAP, TEXT)) == "function") + + local vol = game.save.options and game.save.options.sfxVol + if (vol or 0) == 0 then + U.log("sfxVol is 0: the ride will be silent, raise it in OPTION first") + else + U.log("sfxVol", tostring(vol), "-- 100 collision thuds then the PA chime") + end + + U.teleport(game, MAP, ARRIVE.x, ARRIVE.y, "up") + U.wait(10) + local ow = game.overworld + check("no menu opened on arrival", getmetatable(game.stack:top()) ~= ListMenu) + U.shot(game, DIR .. "/elev395_0_entered.png") + + local function facingThePanel() + local fx, fy = ow.player:facingCell() + return ow.map:signAtCell(fx, fy) ~= nil + end + + -- walk over to the panel, one cell per hold + U.hold(game, "right", 24) + U.hold(game, "right", 24) + U.hold(game, "up", 24) + U.hold(game, "up", 24) + U.wait(10) + U.log("standing at", ow.player.cellX, ow.player.cellY, ow.player.facing) + + if not facingThePanel() and sign then + -- a map edit moved the panel: stand on any walkable neighbour of it. + -- {dx, dy, facing} is the offset from the panel plus the way back at it. + local sides = { + { 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" }, + } + for _, s in ipairs(sides) do + local cx, cy = sign.x + s[1], sign.y + s[2] + if ow.map:isWalkableCell(cx, cy) then + U.log("panel moved; standing on", cx, cy, "facing", s[3]) + U.teleport(game, MAP, cx, cy, s[3]) + U.wait(10) + ow = game.overworld + break + end + end + end + check("the player is facing the panel", facingThePanel()) + + U.tap(game, "a") + U.wait(20) + local menu = getmetatable(game.stack:top()) == ListMenu + check("A on the panel opens the floor list", menu) + if menu then + local labels = {} + for _, item in ipairs(game.stack:top().items or {}) do + labels[#labels + 1] = item.label + end + U.log("floors listed:", table.concat(labels, " ")) + end + U.shot(game, DIR .. "/elev395_1_menu.png") + + U.tap(game, "down") -- 1F -> 2F + U.wait(2) + U.tap(game, "a") + -- 9 zero frames (Celadon farjps into ShakeElevator with no extra Delay3), + -- then -1,-1,+1,+1 in 2-frame steps + local trace = {} + for _ = 1, 24 do + trace[#trace + 1] = tostring(ow.bgShakeY or 0) + U.wait(1) + end + U.log("bgShakeY after the pick:", table.concat(trace, ",")) + U.shot(game, DIR .. "/elev395_2_shake_a.png") + U.shot(game, DIR .. "/elev395_3_shake_b.png") -- the other phase of the bounce + + local ElevatorShake = require("src.world.ElevatorShake") + for _ = 1, 1200 do + U.wait(1) + if getmetatable(game.stack:top()) ~= ElevatorShake then break end + end + U.wait(20) + check("the ride left the player in the car", ow.map.id == MAP) + check("and standing where they read the panel", + ow.player.cellX == STAND.x and ow.player.cellY == STAND.y) + local dests = {} + for _, w in ipairs(ow.map.def.warps) do dests[#dests + 1] = tostring(w.destMap) end + U.log("car exit warps now point at:", table.concat(dests, " ")) + check("the exits were rewritten to " .. PICK, dests[1] == PICK) + U.shot(game, DIR .. "/elev395_4_rode.png") + + U.log("The ride is over and the pad is yours: walk down and left onto the") + U.log("door tile at (2,3) and you should come out on CELADON MART 2F. The") + U.log("panel is behind you -- A on it should reopen WHICH FLOOR?, and B out") + U.log("of that list should leave you in the car with 2F still the exit.") + U.log("Worth checking by hand: Silph Co (same panel cell, 11 floors) and the") + U.log("Rocket car, whose panel is at (1,1) so you face LEFT from (2,1) and") + U.log("with no LIFT KEY get \"It appears to need a key.\" and no list.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/elevator_test.lua b/tests/drivers/elevator_test.lua deleted file mode 100644 index 84aee61e..00000000 --- a/tests/drivers/elevator_test.lua +++ /dev/null @@ -1,47 +0,0 @@ --- Driver: elevator ride (ShakeElevator, engine/overworld/elevator.asm). --- Teleports onto the Celadon Mart elevator's exit tile (1,3) -- the real --- arrival cell, where the floor menu opens on entry -- picks 2F, traces --- the bgShakeY scroll offset through the 9-frame lead-in and first shake --- cycles, screenshots both phases of the oscillation, then confirms the --- post-ride walk-out onto the arrival floor (the car's exit warps are --- rewritten and the player walks out, per scripts/CeladonMartElevator.asm, --- instead of a jump-cut warp). -return function(game) - local U = dofile("tests/drivers/util.lua") - local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" - local ListMenu = require("src.ui.ListMenu") - - U.teleport(game, "CELADON_MART_ELEVATOR", 1, 3, "up") - U.wait(5) - local ow = game.overworld - U.log("map:", ow.map.id, - "menu open:", tostring(getmetatable(game.stack:top()) == ListMenu)) - U.shot(game, DIR .. "/elev_0_menu.png") - - U.tap(game, "down") -- cursor 1F -> 2F - U.wait(2) - U.tap(game, "a") -- choose 2F: the ElevatorShake state pushes - -- expect 9 zero frames (Celadon farjps into ShakeElevator, no extra - -- Delay3), then -1,-1,+1,+1,... in 2-frame steps - local trace = {} - for _ = 1, 24 do - trace[#trace + 1] = tostring(ow.bgShakeY or 0) - U.wait(1) - end - U.log("bgShakeY after A:", table.concat(trace, ",")) - U.shot(game, DIR .. "/elev_1_shake_a.png") - U.shot(game, DIR .. "/elev_2_shake_b.png") -- 3 frames later: other phase - -- ride out: rest of the 200 shake frames, the PA chime, then the - -- scripted walk-out; wait until the walk-out has warped onto the floor - for _ = 1, 1200 do - U.wait(1) - if ow.map.id ~= "CELADON_MART_ELEVATOR" and not ow.transitioning - and #ow.scriptMoves == 0 then - break - end - end - U.wait(10) - U.log("final map:", ow.map.id, "pos:", ow.player.cellX, ow.player.cellY, - "bgShakeY:", tostring(ow.bgShakeY or 0)) - U.shot(game, DIR .. "/elev_3_arrived.png") -end diff --git a/tests/drivers/exit_mat_bug378_test.lua b/tests/drivers/exit_mat_bug378_test.lua new file mode 100644 index 00000000..fa521e12 --- /dev/null +++ b/tests/drivers/exit_mat_bug378_test.lua @@ -0,0 +1,123 @@ +-- Manual check that the exit mat you warp in on answers the first press of +-- DOWN (#378). The house door outside is a door tile, so it leaves +-- BIT_STANDING_ON_WARP set and ClearVariablesOnEnterMap never clears it +-- (home/overworld.asm:247, engine/overworld/player_state.asm:190). +-- POKEPORT_DRIVER=tests/drivers/exit_mat_bug378_test.lua POKEPORT_IDENTITY=bug378 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +-- Do not set POKEPORT_SPEED: fast-forward desynchronizes the audio clock. +return function(game) + local U = dofile("tests/drivers/util.lua") + local Warp = require("src.world.Warp") + + local HOUSE = "REDS_HOUSE_1F" + local TOWN = "PALLET_TOWN" + + local fails = 0 + local function expect(cond, ...) + if not cond then fails = fails + 1 end + U.log(cond and "PASS" or "FAIL", ...) + end + + local ow + local function settle(mapId) + for _ = 1, 300 do + ow = game.overworld + if ow and ow.map.id == mapId and not ow.transitioning + and #ow.scriptMoves == 0 and not ow.player.moving then + break + end + U.wait(1) + end + U.wait(4) + ow = game.overworld + end + + -- data/generated/maps.lua warps, extracted from pokered + -- data/maps/objects/PalletTown.asm and RedsHouse1F.asm: the town warp whose + -- destination is the house is the door tile, and the house's LAST_MAP warps + -- are the two mat cells on its bottom row. + local function warpTo(mapId, destMap) + for _, w in ipairs(game.data.maps[mapId].warps or {}) do + if w.destMap == destMap then return w end + end + return nil + end + + local door = warpTo(TOWN, HOUSE) + local mat = warpTo(HOUSE, "LAST_MAP") + expect(door ~= nil, "Pallet Town has a warp into " .. HOUSE) + expect(mat ~= nil, HOUSE .. " has a LAST_MAP exit warp") + if not (door and mat) then error("map data has no door/mat pair to test") end + U.log(("door at (%d, %d), mat at (%d, %d)"):format(door.x, door.y, + mat.x, mat.y)) + + -- Approach the door from the south, which is the only side the walk-in works + -- from; if a map edit blocked that cell, take any walkable neighbour. + U.teleport(game, TOWN, door.x, door.y + 1, "up") + settle(TOWN) + local stand = { x = door.x, y = door.y + 1, facing = "up" } + if not ow.map:isWalkableCell(stand.x, stand.y) then + local sides = { + { 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" }, + } + for _, s in ipairs(sides) do + local cx, cy = door.x + s[1], door.y + s[2] + if ow.map:isWalkableCell(cx, cy) then + U.log(("cell (%d, %d) is blocked, approaching from"):format( + stand.x, stand.y), cx, cy, "facing", s[3]) + stand = { x = cx, y = cy, facing = s[3] } + U.teleport(game, TOWN, cx, cy, s[3]) + settle(TOWN) + break + end + end + end + + -- Walk in for real: teleporting straight onto the mat would never set the + -- flag the door sets, so the bug could not show up. The direction is + -- released the instant the warp lands, because a still-held d-pad walks the + -- player off the mat and stepping back onto it is ExtraWarpCheck's own + -- trigger, which would exit the house before the human sees anything. + for _ = 1, 120 do + if game.overworld and game.overworld.map.id == HOUSE then break end + table.insert(game.input.pressQueue, stand.facing) + game.input.state[stand.facing] = true + coroutine.yield() + end + game.input.state[stand.facing] = false + settle(HOUSE) + expect(ow.map.id == HOUSE, "walked in through the door, map:", ow.map.id) + local p = ow.player + expect(p.cellX == mat.x and p.cellY == mat.y, + ("landed on the mat (%d, %d), got:"):format(mat.x, mat.y), + p.cellX, p.cellY) + expect(ow.map:warpAtCell(p.cellX, p.cellY) ~= nil, + "the cell under the player carries a warp entry") + expect(not ow.map:isWarpTileCell(p.cellX, p.cellY), + ("the mat tile ($%02X) is no warp-activating tile, so nothing cleared " + .. "the flag"):format(ow.map:cellTile(p.cellX, p.cellY))) + expect(ow:canCollisionWarp(), + "BIT_STANDING_ON_WARP survived the warp, so DOWN can exit (#378)") + expect(Warp.onEdge(ow.map, p.cellX, p.cellY, "down") ~= nil, + "and DOWN off the south edge resolves to the exit warp") + expect(ow.warpEntryCell ~= nil, + "the completed-step arrival record is set, as #265 wants it") + + local vol = game.save.options and game.save.options.sfxVol + if vol == 0 then + U.log("sfxVol is 0: a wrong bonk would be SILENT, raise it in OPTION first") + else + U.log("sfxVol", tostring(vol), "-- there should be no collision thud at all") + end + + U.log("You are on the mat inside the house, already facing down. Press DOWN.") + U.log("Right: it fades out on the first press and you are back in Pallet") + U.log("Town on the door, which auto-steps you one cell south. Wrong: a") + U.log("collision thud and Red walks in place until you step off the mat.") + U.log("The stairs at the top right must still bonk, not bounce floors (#230).") + + if fails > 0 then U.log(fails .. " check(s) failed before the handoff") end + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/fanfare_hold_bug398_test.lua b/tests/drivers/fanfare_hold_bug398_test.lua new file mode 100644 index 00000000..e4ea25c9 --- /dev/null +++ b/tests/drivers/fanfare_hold_bug398_test.lua @@ -0,0 +1,158 @@ +-- Ear check: a song cued while a jingle sounds must wait for it (#398). +-- A fanfare's sfx header claims the music channels and .playMusic rewrites +-- only the music-channel state, so a mid-jingle song stays muted (pokered +-- audio/engine_1.asm:39-56, :1343-1357). +-- POKEPORT_DRIVER=tests/drivers/fanfare_hold_bug398_test.lua POKEPORT_IDENTITY=bug398 POKEPORT_TOUCH=0 love . +-- No POKEPORT_SPEED: fast-forward scales the logic clock only and desyncs audio. +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local ChipAudio = require("src.core.ChipAudio") + local Music = require("src.core.Music") + local Sound = require("src.core.Sound") + + -- data/generated/maps.lua ROUTE_1: the northern grass patch spans cells + -- (10-17, 6-8), and pokered data/maps/objects/Route1.asm keeps its + -- youngsters at (5, 24) and (15, 13), well south of it. + local MAP = "ROUTE_1" + local STAND = { x = 12, y = 7, facing = "down" } + local FANFARES = { + "Level_Up", "Caught_Mon", "Get_Item1", "Get_Item2", + "Get_Key_Item", "Pokedex_Rating", "Dex_Page_Added", "Pokeflute", + } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- what the ear cannot check ----------------------------------------- + local opts = game.save.options or {} + local sfxVol = opts.sfxVol or 7 + local musicVol = opts.musicVol or 7 + if sfxVol == 0 then + U.log("FAIL sfx volume is 0: no jingle will sound at all, so nothing") + U.log(" below can be judged. Set SFX to 7 in OPTION first.") + end + if musicVol == 0 then + U.log("FAIL music volume is 0: the song that must stay held is muted") + U.log(" anyway. Set MUSIC to 7 in OPTION first.") + end + check(("sfx volume %d, music volume %d"):format(sfxVol, musicVol), + sfxVol > 0 and musicVol > 0) + + for _, name in ipairs(FANFARES) do + local def = (game.data.audio.sfx or {})[name] + local src = def and ChipAudio.newSfx(game.data, name) or nil + local secs = src and src:getDuration() or 0 + check(("%s synthesizes (%.2fs)"):format(name, secs), secs > 0.3) + end + local fanfares = game.data.audio.fanfares + check("Caught_Mon counts as a fanfare", + fanfares == nil or fanfares.Caught_Mon == true) + -- Music.lua:100 reaches into ChipAudio for the hold: a chip song is started + -- by ChipAudio, not by Music, so pausing Music's own source cannot cover it + check("ChipAudio.holdMusic exists for Music to call", + type(ChipAudio.holdMusic) == "function") + local routeSong = (game.data.audio.mapSongs or {})[MAP] + check(MAP .. " has a map theme to hold (" .. tostring(routeSong) .. ")", + routeSong ~= nil) + + -- ---- park in the grass ------------------------------------------------- + game.save.party = { + Pokemon.new(game.data, "PIDGEY", 4), + Pokemon.new(game.data, "CHARIZARD", 50), + } + game.save.inventory = { POKE_BALL = 20, POTION = 5 } + game.save.bagOrder = { "POKE_BALL", "POTION" } + + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(20) + local ow = game.overworld + if not ow.map:isWalkableCell(STAND.x, STAND.y) then + -- a map edit or a mod blocked the cell: take any free neighbour + for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do + local cx, cy = STAND.x + d[1], STAND.y + d[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), cx, cy) + U.teleport(game, MAP, cx, cy, STAND.facing) + U.wait(20) + ow = game.overworld + break + end + end + end + check("standing on " .. MAP, ow.map.id == MAP) + + -- ---- the hold, measured ------------------------------------------------ + -- Music hands its chip sources to ChipAudio, so the source the restore + -- built is only reachable through playMusic's return value. + local song + local realPlayMusic = ChipAudio.playMusic + ChipAudio.playMusic = function(...) + local src, err = realPlayMusic(...) + song = src or song + return src, err + end + + local jingle = Sound.play(game.data, "Caught_Mon") + song = nil + -- BattleState:finish restores the map theme this way, and restoreMap clears + -- the current label so even the same theme is rebuilt and restarted + Music.restoreMap(game.data) + U.wait(45) -- past the threaded first-buffer window, still inside the jingle + local stillSounding = jingle ~= nil and jingle:isPlaying() + local songUp = song ~= nil and song:isPlaying() + if not stillSounding then + U.log("FAIL the jingle ended before the restore could be judged; rerun") + else + check("the map theme stays silent under the jingle", not songUp) + end + + for _ = 1, 400 do + if not (jingle and jingle:isPlaying()) then break end + U.wait(1) + end + local back = false + for _ = 1, 120 do + if song and song:isPlaying() then back = true break end + U.wait(1) + end + check("the held theme comes back when the jingle ends", back) + ChipAudio.playMusic = realPlayMusic + + -- ---- the real path, so the same beat can be heard in a battle ---------- + local battle = BattleState.newWild(game, "PIDGEY", 3) + battle.rng = function(a, b) return a end -- clean capture, no wobbles + ow:pushBattle(battle) + for _ = 1, 14 do U.tap(game, "a") U.wait(6) end + battle.phase = "messages" + battle.afterQueue = "menu" + battle:throwBall("POKE_BALL") + local ChoiceBox = require("src.ui.ChoiceBox") + for _ = 1, 120 do + if game.stack:top() == ow then break end + if getmetatable(game.stack:top()) == ChoiceBox then + U.tap(game, "down") -- decline the nickname, or naming eats the mash + U.wait(2) + end + U.tap(game, "a") + U.wait(4) + end + if not check("the catch ran through to the overworld", game.stack:top() == ow) then + -- hand the pad over in the grass whatever the mash got stuck on + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + end + U.wait(60) + + U.log("That was a capture mashed straight out of the battle, which is the") + U.log("tightest case: the caught jingle should sound alone start to finish") + U.log("and the route theme should only come in after its last note.") + U.log("You have 20 balls and a level 4 PIDGEY, so walk the grass and catch") + U.log("something, or let it level up, and listen for the same thing.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/field_move_layer_bug385_test.lua b/tests/drivers/field_move_layer_bug385_test.lua new file mode 100644 index 00000000..fc5c3fd6 --- /dev/null +++ b/tests/drivers/field_move_layer_bug385_test.lua @@ -0,0 +1,187 @@ +-- Manual check that STRENGTH / SURF / FLASH print their message over the +-- party menu and only then blink it away (#385). pokered +-- engine/menus/start_sub_menus.asm .flash/.strength/.surf all run PrintText +-- -> GBPalWhiteOutWithDelay3 -> jp .goBackToMap, so the menu is the backdrop +-- of the text and the blink is the menu closing. No POKEPORT_SPEED here: +-- POKEPORT_DRIVER=tests/drivers/field_move_layer_bug385_test.lua POKEPORT_IDENTITY=bug385 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local PartyMenu = require("src.ui.PartyMenu") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local fails = 0 + local function check(ok, msg) + U.log(ok and "PASS" or "FAIL", msg) + if not ok then fails = fails + 1 end + return ok + end + + U.newGame(game) + game.save.options = game.save.options or {} + game.save.options.textSpeed = 5 -- SLOW: the shot lands while it types + local vol = game.save.options.sfxVol + if vol == 0 then + U.log("sfxVol is 0: the STRENGTH cry will be silent, turn SFX up in OPTION") + else + U.log("sfxVol =", tostring(vol), "-- STRENGTH plays the mon's cry over the text") + end + + -- every badge the three field moves are gated on at list time + game.save.inventory.BOULDERBADGE = true + game.save.inventory.SOULBADGE = true + game.save.inventory.RAINBOWBADGE = true + + for _, key in ipairs({ "_UsedStrengthText", "_CanMoveBouldersText", + "_SurfingGotOnText", "_FlashLightsAreaText" }) do + local t = game.data.text[key] + check(type(t) == "string" and t ~= "", key .. " extracted from the ROM") + end + + local function giveMon(species, move) + local mon = Pokemon.new(game.data, species, 30) + mon.moves = { { id = move, pp = 15 } } + game.save.party = { mon } + return mon + end + + -- park on `cell` if it is free, else on any walkable neighbour of the + -- target that looks back at it (a map edit or a mod moves objects) + local function stand(mapId, cell, target) + U.teleport(game, mapId, cell.x, cell.y, cell.facing) + local ow = game.overworld + if ow.map:isWalkableCell(cell.x, cell.y) and not ow:npcAtCell(cell.x, cell.y) then + return ow + end + local sides = { + { 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" }, + } + for _, s in ipairs(sides) do + local cx, cy = target.x + s[1], target.y + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("(%d, %d) is blocked, standing on"):format(cell.x, cell.y), + cx, cy, "facing", s[3]) + U.teleport(game, mapId, cx, cy, s[3]) + return game.overworld + end + end + return ow + end + + -- START -> POKéMON -> the mon -> the field-move row, by looking the rows + -- up rather than counting presses (a mod can insert either list) + local function openFieldMove(action) + U.tap(game, "start") + U.wait(12) + local menu = game.stack:top() + local row + for i, it in ipairs(menu and menu.items or {}) do + if tostring(it.label):upper():find("MON", 1, true) then row = i break end + end + if not check(row ~= nil, "START menu lists POKéMON") then return nil end + for _ = 2, row do U.tap(game, "down"); U.wait(2) end + U.tap(game, "a") + U.wait(12) + local pm = game.stack:top() + if not check(getmetatable(pm) == PartyMenu, "POKéMON opens the party menu") then + return nil + end + U.tap(game, "a") -- A on the mon builds the field-move submenu + U.wait(8) + local sub + for i, it in ipairs(pm.subItems or {}) do + if it.action == action then sub = i break end + end + if not check(sub ~= nil, action:upper() .. " is listed in the submenu") then + return nil + end + for _ = 2, sub do U.tap(game, "down"); U.wait(2) end + U.tap(game, "a") + return pm + end + + -- the state the compositor starts from (StateStack:draw walks up from the + -- highest opaque state): what is actually behind the message + local function backdrop() + return game.stack.states[game.stack:visibleBase()] + end + local function textIsUp() + local top = game.stack:top() + return top ~= nil and top.pages ~= nil + end + local function drainText() + for _ = 1, 400 do + if not textIsUp() then break end + U.tap(game, "a") + U.wait(3) + end + end + + -- ---------------------------------------------------------------- STRENGTH + -- pokered data/maps/objects/SeafoamIslands1F.asm: BOULDER1 at (18, 10), + -- so the floor to its west is the approach cell + giveMon("MACHOP", "STRENGTH") + local ow = stand("SEAFOAM_ISLANDS_1F", { x = 17, y = 10, facing = "right" }, + { x = 18, y = 10 }) + check(ow:npcAtCell(18, 10) ~= nil, "the Seafoam boulder is loaded at (18,10)") + local pmStr = openFieldMove("strength") + U.wait(6) + check(textIsUp(), "STRENGTH puts a message up") + check(backdrop() == pmStr, "the party menu is the backdrop of the STRENGTH text") + U.shot(game, DIR .. "/bug385_strength.png") + drainText() + U.wait(60) + + -- ---------------------------------------------------------------- SURF + -- pokered data/maps/objects/PalletTown.asm has no object on the south + -- shore; (4, 13) is the land cell above the water at (4, 14) + giveMon("SQUIRTLE", "SURF") + ow = stand("PALLET_TOWN", { x = 4, y = 13, facing = "down" }, { x = 4, y = 13 }) + ow.player.surfing = false + check(ow:useSurfFieldMove() == "ok", "(4,13) faces surfable water") + local pmSurf = openFieldMove("surf") + U.wait(6) + check(textIsUp(), "SURF puts the got-on message up") + check(backdrop() == pmSurf, "the party menu is the backdrop of the got-on text") + U.shot(game, DIR .. "/bug385_surf.png") + drainText() + U.wait(60) + U.shot(game, DIR .. "/bug385_surf_after.png") + ow.player.surfing = false + + -- ---------------------------------------------------------------- FLASH + -- pokered data/maps/objects/RockTunnel1F.asm: warp 1 is (15, 3), so + -- (15, 4) is a real walkable cell just inside the entrance + game.save.flashLit = false + giveMon("PIKACHU", "FLASH") + ow = stand("ROCK_TUNNEL_1F", { x = 15, y = 4, facing = "down" }, { x = 15, y = 3 }) + check(ow.dark == true, "ROCK_TUNNEL_1F is dark before FLASH") + local pmFlash = openFieldMove("flash") + U.wait(6) + check(textIsUp(), "FLASH puts a message up") + check(backdrop() == pmFlash, "the party menu is the backdrop of the FLASH text") + check(ow.dark == true, "the tunnel is still dark while the FLASH text is up") + U.shot(game, DIR .. "/bug385_flash_text.png") + drainText() + U.wait(40) + check(ow.dark == false, "the tunnel is lit once the blink hands the map back") + U.shot(game, DIR .. "/bug385_flash_after.png") + + U.log(fails == 0 and "all machine checks passed" or (fails .. " machine checks failed")) + U.log("shots in", DIR) + + -- hand the pad back on a dark tunnel with FLASH still unused + game.save.flashLit = false + giveMon("PIKACHU", "FLASH") + stand("ROCK_TUNNEL_1F", { x = 15, y = 4, facing = "down" }, { x = 15, y = 3 }) + + U.log("You are in the dark tunnel with an unused FLASH: START, POKéMON, A, FLASH.") + U.log("The message should read over the six-row party list, not a white screen,") + U.log("and the tunnel should only be lit after the blink closes the menu.") + U.log("Same for SURF and STRENGTH in the shots: party list behind the text,") + U.log("one short blink, then the map.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/fishing_pose_bug384_test.lua b/tests/drivers/fishing_pose_bug384_test.lua new file mode 100644 index 00000000..4b2e3ae0 --- /dev/null +++ b/tests/drivers/fishing_pose_bug384_test.lua @@ -0,0 +1,219 @@ +-- Driver: the fishing POSE -- the standing frame's bottom tile row swapped for +-- RedFishingTiles so the rod ends in hands -- and its two-stage teardown, rod +-- OAM out at once, pose ~10 frames later (#384). FishingAnim / RedFishingTiles +-- / res BIT_LEDGE_OR_FISHING: player_animations.asm:378, :485, :449. +-- No POKEPORT_SPEED: fast-forward outruns the rendered frames and the audio +-- clock, so the ten-frame tail can neither be sampled nor watched. +-- POKEPORT_DRIVER=tests/drivers/fishing_pose_bug384_test.lua POKEPORT_IDENTITY=bug384 SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local Screens = require("src.ui.Screens") + local TextBox = require("src.render.TextBox") + local SpriteRenderer = require("src.render.SpriteRenderer") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- Same pond as tests/drivers/fishing_rod_bug321_test.lua: the Viridian City + -- water block (x=8..13, y=24..27, data/generated/maps.lua VIRIDIAN_CITY) is + -- the one place all four shores sit a few steps apart. shoreFor() re-derives + -- each stand cell from the map so a layout edit cannot park us at a wall. + local MAP = "VIRIDIAN_CITY" + local SPOTS = { + { x = 10, y = 23, facing = "down", note = "north shore" }, + { x = 8, y = 28, facing = "up", note = "south shore" }, + { x = 14, y = 24, facing = "left", note = "east shore" }, + { x = 7, y = 24, facing = "right", note = "west shore" }, + } + local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } + local ROD = "OLD_ROD" + + -- ---- the halves of the drawing, before anything is on screen ------------ + -- A missing sheet, a sheet of the wrong size and a Player that never picks + -- up fishTiles all look the same: a rod ending in mid air, which is the + -- shipped bug. + + local fx = game.data.field.overworldFx + local POSE = { + { key = "redFishFront", facing = "down", asm = "RedFishingTilesFront, tile $02" }, + { key = "redFishBack", facing = "up", asm = "RedFishingTilesBack, tile $06" }, + { key = "redFishSide", facing = "left", asm = "RedFishingTilesSide, tile $0a" }, + } + for _, p in ipairs(POSE) do + local def = fx and fx[p.key] + if check(("field.overworldFx.%s resolves (%s)"):format(p.key, p.asm), + def ~= nil and type(def.path) == "string") then + local ok, img = pcall(love.graphics.newImage, def.path) + if check(("%s loads: %s"):format(p.key, def.path), ok and img ~= nil) then + local w, h = img:getDimensions() + check(("%s is 16x8, one 16-wide tile row"):format(p.key), w == 16 and h == 8) + end + end + end + + check("SpriteRenderer:drawTile exists (the pose row wears the sprite's OBP)", + type(SpriteRenderer.drawTile) == "function") + + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + game.save.inventory = { [ROD] = 1 } + game.save.bagOrder = { ROD } + check("OLD ROD is a real item and the only thing in the bag", + game.data.items[ROD] ~= nil and (game.save.inventory[ROD] or 0) > 0) + local ItemEffects = require("src.inventory.ItemEffects") + check("using it routes to the fishing branch", + ItemEffects.use(game.data, game.save, ROD, nil, nil, nil, game.overworld) == "fish") + + local function useRod() + Screens.push(game, "BagMenu") + U.wait(20) + U.tap(game, "a") -- OLD ROD -> USE/TOSS + U.wait(20) + U.tap(game, "a") -- USE + U.wait(30) + end + + local function topBox() + local top = game.stack:top() + if getmetatable(top) ~= TextBox then return nil end + local shown = {} + for _, page in ipairs(top.pages or {}) do + for _, line in ipairs(page) do shown[#shown + 1] = line end + end + return top, table.concat(shown, " / ") + end + + local function shoreFor(map, spot) + local d = DELTA[spot.facing] + if map:inBounds(spot.x + d[1], spot.y + d[2]) + and map:isWaterCell(spot.x + d[1], spot.y + d[2]) + and map:isWalkableCell(spot.x, spot.y) then + return spot.x, spot.y + end + for y = 0, map.def.height * 2 - 1 do + for x = 0, map.def.width * 2 - 1 do + if map:isWalkableCell(x, y) and not map:isWaterCell(x, y) + and map:inBounds(x + d[1], y + d[2]) + and map:isWaterCell(x + d[1], y + d[2]) then + U.log(("(%d,%d) no longer faces water; using (%d,%d) for %s") + :format(spot.x, spot.y, x, y, spot.facing)) + return x, y + end + end + end + return spot.x, spot.y + end + + -- park on a shore, cast, and stop on the dots box + local function castFrom(spot) + U.teleport(game, MAP, spot.x, spot.y, spot.facing) + U.wait(15) + local sx, sy = shoreFor(game.overworld.map, spot) + if sx ~= spot.x or sy ~= spot.y then + U.teleport(game, MAP, sx, sy, spot.facing) + U.wait(15) + end + local ow = game.overworld + local fcx, fcy = ow.player:facingCell() + check(("facing %s from (%d,%d), the %s: water in front") + :format(spot.facing, sx, sy, spot.note), + ow.map:inBounds(fcx, fcy) and ow.map:isWaterCell(fcx, fcy)) + useRod() + return ow + end + + -- ---- pose up, one facing at a time ------------------------------------- + -- Every facing is left on its verdict box; the next teleport pops it, so no + -- bite ever reaches a battle here. + + for _, spot in ipairs(SPOTS) do + local ow = castFrom(spot) + local dots = topBox() + check(("%s: USE opened the fishing box"):format(spot.facing), dots ~= nil) + check(("%s: the rod OAM is up (overworld.fishing)"):format(spot.facing), + ow.fishing ~= nil and ow.fishing.facing == spot.facing) + check(("%s: the pose is up (player.fishing)"):format(spot.facing), + ow.player.fishing == true) + check(("%s: a pose row exists for this facing"):format(spot.facing), + ow.player.fishTiles ~= nil and ow.player.fishTiles[spot.facing] ~= nil) + if spot.facing == "right" then + check("right reuses the left pose row, x-flipped like the sprite", + ow.player.fishTiles.right == ow.player.fishTiles.left) + end + -- RodResponse (engine/items/item_effects.asm:1878) zeroes wWalkBikeSurfState + -- across FishingAnim, so the sheet under the pose is always the walking one + local sheet = ow.player:pose() + check(("%s: the walking sheet is what draws (not surf/bike)"):format(spot.facing), + sheet == ow.player.sprite) + if not U.shot(game, ("%s/bug384_pose_%s.png"):format(SHOT_DIR, spot.facing)) then + check(("%s: screenshot reached disk"):format(spot.facing), false) + end + end + + -- ---- the retract, sampled frame by frame -------------------------------- + -- The rod OAM dies with the verdict box (res BIT_LEDGE_OR_FISHING right + -- after PrintText, player_animations.asm:449) but the patched player tiles + -- live until ReloadMapSpriteTilePatterns (home/reload_sprites.asm) a few + -- frames later. Only the no-bite verdict shows that gap: a bite goes + -- straight into a battle, which reloads the tiles itself, so drop OLD ROD's + -- `always` hook for the rest of this run. + local rodFishing = (game.data.field.fishing or {})[ROD] + check("field.fishing.OLD_ROD exists", rodFishing ~= nil) + if rodFishing then rodFishing.always = nil end + + local ow = castFrom(SPOTS[4]) + local dots = topBox() + if dots then + for _ = 1, 12 do + U.tap(game, "a") + U.wait(12) + if game.stack:top() ~= dots then break end + end + end + local verdict, verdictText = topBox() + check("the no-bite verdict box opened", verdict ~= nil) + if verdictText then U.log("box reads:", verdictText) end + + local rodGone, poseGone + for i = 1, 180 do + if game.stack:top() == verdict then U.tap(game, "a") else U.wait(1) end + if not rodGone and ow.fishing == nil then rodGone = i end + if not poseGone and ow.player.fishing == nil then poseGone = i end + if poseGone then break end + end + check("the rod OAM came down", rodGone ~= nil) + check("the pose came down too", poseGone ~= nil) + if rodGone and poseGone then + U.log(("rod out on sample frame %d, pose out on %d"):format(rodGone, poseGone)) + check("the pose OUTLIVES the rod (two stages, not one)", poseGone > rodGone) + check("the tail is around ten frames", poseGone - rodGone >= 5) + end + + -- ---- hand off ---------------------------------------------------------- + local live = castFrom(SPOTS[4]) + if live.fishing then + local box = topBox() + if box then + for _ = 1, 12 do + U.tap(game, "a") + U.wait(12) + if game.stack:top() ~= box then break end + end + end + end + U.log("West shore of the Viridian pond, mid-cast, verdict box open. OLD ROD") + U.log("was set to never bite for this cast, so dismissing it stays on the map.") + U.log("The rod should read as one unbroken stroke: hands and near end in the") + U.log("player's lower body, far end in the loose tile, touching on the same") + U.log("scanline, and the lower row the same shade as the torso above it.") + U.log("Dismiss the box: the loose tile goes at once, the hands hold about ten") + U.log("frames, then the sprite snaps back. Both going together is the bug.") + U.log("Shots: " .. SHOT_DIR .. "/bug384_pose_.png") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/fishing_rod_bug321_test.lua b/tests/drivers/fishing_rod_bug321_test.lua index 1df1825c..dc8f2495 100644 --- a/tests/drivers/fishing_rod_bug321_test.lua +++ b/tests/drivers/fishing_rod_bug321_test.lua @@ -181,7 +181,8 @@ return function(game) U.log("Correct: one 8x8 rod stroke touching the player's hands, a mirror") U.log("image left vs right, still up for the whole verdict box (#321).") U.log("Shots: " .. SHOT_DIR .. "/bug321_rod__{dots,verdict}.png") - U.log("Still unported: the fishing pose, the sprite shake, the ! bubble.") + U.log("The pose swaps the sprite's bottom tile row (#384); the sprite shake") + U.log("and the ! bubble on a bite are still unported.") while true do coroutine.yield() diff --git a/tests/drivers/hideout_gate_bug372_test.lua b/tests/drivers/hideout_gate_bug372_test.lua new file mode 100644 index 00000000..fcec919c --- /dev/null +++ b/tests/drivers/hideout_gate_bug372_test.lua @@ -0,0 +1,118 @@ +-- Driver: the Rocket Hideout B4F lift gate stays barred until both guards fall, +-- then opens on the spot (#372). scripts/RocketHideoutB4F.asm ...DoorCallback- +-- Script stamps $2d at lb bc, 5, 12 until CheckBothEventsSet trainer_0/_1, then +-- SFX_GO_INSIDE and $e, and re-runs after a battle (home/trainers.asm +-- EndTrainerBattle). Data half: tests/parity_hideout_gate.lua. Never under +-- POKEPORT_SPEED: fast-forward desynchronizes the door sound from the stamp. +-- POKEPORT_DRIVER=tests/drivers/hideout_gate_bug372_test.lua POKEPORT_IDENTITY=bug372 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local FieldDefaults = require("src.world.FieldDefaults") + + local MAP = "ROCKET_HIDEOUT_B4F" + local MAP_LABEL = "RocketHideoutB4F" + local GUARD_0 = "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0" + local GUARD_1 = "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1" + -- data/generated/maps.lua ROCKET_HIDEOUT_B4F: the gate is block (12,5), + -- i.e. cells (24-25, 10-11), the only gap in that wall row; the guards + -- (data/maps/objects/RocketHideoutB4F.asm) sit at (23,12) and (26,12) and + -- Giovanni at (25,3) with the SILPH SCOPE ball at (25,2) behind the gate. + local DOOR = { bx = 12, by = 5 } + local GATE_CELLS = { { 24, 11 }, { 25, 11 } } + local STAND = { x = 24, y = 13, facing = "up" } + + local pass, fail = 0, 0 + local function check(label, ok) + if ok then pass = pass + 1 else fail = fail + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 40), + Pokemon.new(game.data, "NIDOKING", 38), + } + game.save.player.name = "RED" + -- a save that has been here before would already carry the guard events + game.save.flags[GUARD_0] = nil + game.save.flags[GUARD_1] = nil + game.save.defeatedTrainers = {} + + local doors = FieldDefaults.fieldValue(game.data, "cardKeyDoors", "closedDoors") + local row = doors and doors[MAP] and doors[MAP][1] + check("the B4F gate has a closedDoors row (stale caches had none)", row ~= nil) + if row then + check("it stamps $2d over block (12,5)", + row.block == 0x2d and row.bx == DOOR.bx and row.by == DOOR.by) + check("it opens to $e on both guard events", + row.open == 0x0e and row.events ~= nil and #row.events == 2) + end + check("B1F carries its own row ($54 at (12,8))", + doors and doors.ROCKET_HIDEOUT_B1F ~= nil + and doors.ROCKET_HIDEOUT_B1F[1].block == 0x54) + check("the guards set the events the gate watches", + game.data:trainerHeader(MAP_LABEL, 2).event == GUARD_0 + and game.data:trainerHeader(MAP_LABEL, 3).event == GUARD_1) + check("Go_Inside is in the generated audio", + game.data.audio and game.data.audio.sfx + and game.data.audio.sfx.Go_Inside ~= nil) + + local vol = game.save.options and game.save.options.sfxVol + if vol == 0 then + U.log("sfxVol is 0: the door sound will be SILENT, raise it in OPTION first") + else + U.log("sfxVol", tostring(vol), "-- expect one door sound as the gate opens") + end + + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + local ow = game.overworld + + -- a map edit or a mod moved the doorway: stand on any walkable cell + -- touching the gate instead of the cell below it + if not ow.map:isWalkableCell(STAND.x, STAND.y) then + for _, cell in ipairs(GATE_CELLS) do + for _, off in ipairs({ { 0, 1, "up" }, { 0, -1, "down" }, + { 1, 0, "left" }, { -1, 0, "right" } }) do + local cx, cy = cell[1] + off[1], cell[2] + off[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("(%d,%d) is not walkable, standing on"):format(STAND.x, STAND.y), + cx, cy, "facing", off[3]) + U.teleport(game, MAP, cx, cy, off[3]) + U.wait(10) + ow = game.overworld + break + end + end + end + end + + check("the doorway is stamped shut on arrival", + ow.map:blockAt(DOOR.bx, DOOR.by) == 0x2d) + local blocked = true + for _, cell in ipairs(GATE_CELLS) do + if ow.map:isWalkableCell(cell[1], cell[2]) then blocked = false end + end + check("neither doorway cell can be stepped into", blocked) + + local guards = 0 + for _, n in ipairs(ow.npcs or {}) do + local name = n.def and n.def.name + if name == "ROCKETHIDEOUTB4F_ROCKET1" or name == "ROCKETHIDEOUTB4F_ROCKET2" then + guards = guards + 1 + end + end + check("both guards are on the floor", guards == 2) + U.log(("machine checks: %d pass, %d fail"):format(pass, fail)) + + U.shot(game, (os.getenv("SHOT_DIR") or "/tmp/shots") .. "/bug372_gate_shut.png") + + U.log("You are one cell south of the lift gate: barred, and walking up bumps.") + U.log("Beat one grunt and it is still barred; beat the second and the bars turn") + U.log("to floor as the fade ends, one door sound, then north to Giovanni.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/multihit_bug394_test.lua b/tests/drivers/multihit_bug394_test.lua new file mode 100644 index 00000000..2b0dab41 --- /dev/null +++ b/tests/drivers/multihit_bug394_test.lua @@ -0,0 +1,184 @@ +-- Driver: a multi-hit move must step the enemy HP bar down once per strike +-- (#394), not empty it on hit 1 and freeze for the rest. pokered +-- ApplyDamageToEnemyPokemon (engine/battle/core.asm:4684-4727) subtracts +-- wDamage then runs UpdateHPBar2 inside the wNumAttacksLeft loop. Machine +-- half: tests/engine/multihit_hp_drain.lua. Never under POKEPORT_SPEED: +-- fast-forward desynchronizes the per-strike damage sound from the bar. +-- POKEPORT_DRIVER=tests/drivers/multihit_bug394_test.lua POKEPORT_IDENTITY=bug394 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + local MOVE = "DOUBLESLAP" + local pass, fail = 0, 0 + local function check(label, ok) + if ok then pass = pass + 1 else fail = fail + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local moveDef = game.data.moves[MOVE] + check(MOVE .. " resolves in the move table", moveDef ~= nil) + check("...as a 2-to-5 strike move", + moveDef ~= nil and moveDef.effect == "TWO_TO_FIVE_ATTACKS_EFFECT") + check("SNORLAX resolves (a big HP pool, so each strike is visible)", + game.data.pokemon.SNORLAX ~= nil) + check("the damage sound is in the generated audio", + game.data.audio and game.data.audio.sfx + and game.data.audio.sfx.Damage ~= nil) + + local vol = game.save.options and game.save.options.sfxVol + if vol == 0 then + U.log("sfxVol is 0: the per-strike damage hits will be SILENT,", + "raise it in OPTION first") + else + U.log("sfxVol", tostring(vol), "-- expect one damage hit per strike") + end + + -- :L50 so a SNORLAX counterattack cannot end the run before the handoff + local lead = Pokemon.new(game.data, "BULBASAUR", 50) + lead.moves = { { id = MOVE, pp = 10, maxPP = 10 } } -- one slot, no mis-pick + game.save.party = { lead } + + -- offscreen scratch battle: the queue a multi-hit turn builds, with no + -- animation timing in the way. Each drain row has to name its target and + -- carry the HP left after its own strike; unpinned rows were the bug. + do + local seqIndex = 0 + local scratch = BattleState.newWild(game, "SNORLAX", 30) + scratch.onFinish = function() end + scratch.rng = function(_, hi) -- 5 hits, hit, no crit, max damage roll + seqIndex = seqIndex + 1 + local scripted = ({ 7, 0, 255, 255 })[seqIndex] + return scripted ~= nil and scripted or hi + end + local startHP = scratch.enemy.mon.hp + scratch:performMove(scratch.player, scratch.enemy, { id = MOVE, pp = 10 }) + local stops, anims = {}, 0 + for _, row in ipairs(scratch.queue) do + if row.drain then stops[#stops + 1] = row + elseif row.anim == MOVE then anims = anims + 1 end + end + check(("%d strikes queued %d animations"):format(#stops, anims), + #stops > 1 and anims == #stops) + local stepped, named = true, true + local prev = startHP + local shownStops = {} + for _, row in ipairs(stops) do + named = named and row.battler == scratch.enemy + stepped = stepped and type(row.stopAt) == "number" and row.stopAt < prev + prev = row.stopAt or prev + shownStops[#shownStops + 1] = tostring(row.stopAt) + end + check("every drain row names the enemy", named) + check("and stops on its own strike's HP, one step at a time", stepped) + check("the last stop is the post-turn HP", prev == scratch.enemy.mon.hp) + U.log(("enemy HP %d -> stops: %s"):format(startHP, + table.concat(shownStops, ", "))) + end + + -- ROUTE_1 is open field (data/generated/maps.lua ROUTE_1); the cell is read + -- off the loaded map so a map edit degrades to another walkable cell + -- instead of a wall. + U.teleport(game, "ROUTE_1", 5, 5, "down") + U.wait(10) + local map = game.overworld.map + if not map:isWalkableCell(5, 5) then + local fx, fy + for cy = 0, map.heightCells - 1 do + for cx = 0, map.widthCells - 1 do + if map:isWalkableCell(cx, cy) then fx, fy = cx, cy break end + end + if fx then break end + end + if fx then + U.teleport(game, "ROUTE_1", fx, fy, "down") + U.wait(10) + end + end + local ow = game.overworld + check("player stands on a walkable ROUTE_1 cell", + ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY)) + + local function mashUntil(cond, max) + for _ = 1, max or 120 do + if cond() then return true end + U.tap(game, "a") + U.wait(4) + end + return cond() + end + + local function newFight() + local battle = BattleState.newWild(game, "SNORLAX", 30) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + U.wait(220) -- the send-out intro plays before the menu is reachable + mashUntil(function() return battle.phase == "menu" end) + return battle + end + + -- ---- scripted run: watch the bar across the strikes --------------------- + local battle = newFight() + check("the wild battle reached its FIGHT menu", battle.phase == "menu") + U.shot(game, DIR .. "/bug394_menu.png") + + U.tap(game, "a") -- FIGHT + U.wait(16) + U.tap(game, "a") -- the only move slot + U.wait(8) + + -- sample on the falling edge of battle.draining, a frame at a time: a + -- coarser poll can miss a strike's drain entirely and read the next one's + -- rest twice. Rows that leave the enemy bar where it was (the foe's own + -- turn drains the player bar) and the full-bar reset a finished battle + -- does are skipped. A is tapped only every eighth frame, to walk the + -- queue's text rows without eating a whole drain. + local seen, wasDraining = {}, false + local lastShown = battle.enemy.shownHP or battle.enemy.mon.hp + for frame = 1, 2400 do + local draining = battle.draining ~= nil + if wasDraining and not draining then + local shown = battle.enemy.shownHP or battle.enemy.mon.hp + if shown < lastShown - 0.5 then + lastShown = shown + seen[#seen + 1] = shown + U.shot(game, DIR .. ("/bug394_hit%d.png"):format(#seen)) + U.log(("after strike %d the enemy bar rests on %.0f HP, model HP %d") + :format(#seen, shown, battle.enemy.mon.hp)) + end + end + wasDraining = draining + if not draining and battle.phase == "menu" and #battle.queue == 0 then break end + if not draining and frame % 8 == 0 then U.tap(game, "a") end + U.wait(1) + end + local rests = {} + for i, hp in ipairs(seen) do rests[i] = ("%.0f"):format(hp) end + U.log(("enemy bar rests: %s"):format(table.concat(rests, ", "))) + check(("more than one strike moved the enemy bar (%d)"):format(#seen), #seen > 1) + check("the first strike did not drain the whole turn's damage (#394)", + #seen > 1 and seen[1] > seen[#seen]) + + U.log(("machine checks: %d passed, %d failed"):format(pass, fail)) + + -- ---- re-arm and hand the pad over -------------------------------------- + lead.hp = lead.stats.hp + lead.status = nil + lead.moves[1].pp = lead.moves[1].maxPP + local handoff = newFight() + U.log("A fresh SNORLAX is waiting on FIGHT with DOUBLESLAP in the only slot.") + U.log("Press A twice and watch the enemy bar: it should tick down a bit on") + U.log("every strike, in step with the replayed slap and its damage hit, with") + U.log("the HP number dropping each time. Before #394 the whole bar emptied on") + U.log("slap one and then sat frozen for the rest.") + if handoff.phase ~= "menu" then + U.log("(the menu did not come back on its own: mash A to reach FIGHT)") + end + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/oak_nidorino_flip_bug397_test.lua b/tests/drivers/oak_nidorino_flip_bug397_test.lua new file mode 100644 index 00000000..842d009d --- /dev/null +++ b/tests/drivers/oak_nidorino_flip_bug397_test.lua @@ -0,0 +1,149 @@ +-- Manual check that Oak's NIDORINO keeps its mirrored front sprite across the +-- _OakSpeechText2A -> _OakSpeechText2B page break (#397). OakSpeechText2 is one +-- PrintText over one flipped pic (pokered engine/movie/oak_speech/oak_speech.asm +-- :80-85, :172-177); advance() used to clear picFlip on the pic-less next step. +-- SHOT_DIR=/tmp/bug397 POKEPORT_DRIVER=tests/drivers/oak_nidorino_flip_bug397_test.lua POKEPORT_IDENTITY=bug397 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +-- Do not set POKEPORT_SPEED: fast-forward desynchronizes the cry from the wipe. +return function(game) + local U = dofile("tests/drivers/util.lua") + local TextBox = require("src.render.TextBox") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local function top() return game.stack:top() end + local function isBox(s) return getmetatable(s) == TextBox end + + local function speechState() + for _, s in ipairs(game.stack.states or {}) do + if s.demoPic ~= nil or (s.steps and s.answers) then return s end + end + end + + local function boxText(s) + if not isBox(s) then return "" end + local out = {} + for _, page in ipairs(s.pages or {}) do + for _, line in ipairs(page) do out[#out + 1] = line end + end + return table.concat(out, " / ") + end + + local function stepId(speech) + local cur = speech.steps and speech.steps[speech.step] + return cur and (cur.id or cur.kind) or "?" + end + + -- boot flow: attract movie -> title -> menu. With a save on disk NEW GAME is + -- the second row, so land on it the same way silly_oak_intro_test does. + U.wait(5) + U.tap(game, "start") + U.wait(15) + U.tap(game, "a") + U.wait(8) + local ok, saved = pcall(function() + return require("src.core.SaveData").load() ~= nil + end) + if ok and saved then + U.tap(game, "down") + U.wait(5) + end + U.tap(game, "a") + U.wait(20) + + local speech + for _ = 1, 90 do + speech = speechState() + if speech then break end + U.wait(1) + end + if not check("NEW GAME entered Oak's speech", speech ~= nil) then + U.log("top is", tostring(top()), "-- CONTINUE was picked instead") + while true do coroutine.yield() end + end + + local demoIdx + for i, s in ipairs(speech.steps or {}) do + if (s.kind or "say") == "demo" then demoIdx = i break end + end + check("the step list still has a demo beat", demoIdx ~= nil) + check("the show-off front sprite loaded (" .. + tostring(speech.demoSpecies) .. ")", speech.demoPic ~= nil) + + local vol = game.save and game.save.options and game.save.options.sfxVol + if vol == 0 then + U.log("sfxVol is 0: the NIDORINO cry will be silent, raise it in OPTION") + else + U.log("sfxVol", tostring(vol), "-- the cry plays as the sprite wipes in") + end + + if not demoIdx then + while true do coroutine.yield() end + end + + -- mash only up to the demo beat, then let the wipe finish on its own + for _ = 1, 300 do + if speech.step >= demoIdx then break end + U.tap(game, "a") + U.wait(3) + end + for _ = 1, 180 do + if speech.step == demoIdx and not speech.picReveal and isBox(top()) then + break + end + U.wait(1) + end + + check("_OakSpeechText2A is up on the demo beat", + speech.step == demoIdx and isBox(top())) + local textA = boxText(top()) + U.log("page A reads:", textA) + check("the mirrored pic is on screen (picFlip set, demo pic)", + speech.picFlip == true and speech.pic == speech.demoPic) + U.shot(game, DIR .. "/oak_nido_2a.png") + + -- one A per page of 2A; picFlip must survive every one of them + local held = true + for _ = 1, 8 do + U.tap(game, "a") + U.wait(10) + if speech.picFlip ~= true or speech.pic ~= speech.demoPic then + held = false + break + end + if speech.step > demoIdx then break end + end + check("picFlip and pic unchanged after the page break", held) + check("the speech moved on to the pic-less step (" .. stepId(speech) .. ")", + speech.step > demoIdx) + local textB = boxText(top()) + U.log("page B reads:", textB) + check("a second page is up and it is not page A", + isBox(top()) and textB ~= "" and textB ~= textA) + U.shot(game, DIR .. "/oak_nido_2b.png") + U.log("shots in", DIR) + + -- put the beat back so the transition can be watched live: pop the box, rewind + -- the step counter and re-run the demo beat (wipe, cry, page A) + for _ = 1, 8 do + if top() == speech then break end + game.stack:pop() + end + if top() == speech then + speech.picReveal = nil + speech.step = demoIdx + speech:runStep(speech.steps[demoIdx]) + U.wait(60) + end + + U.log("NIDORINO wipes in from the right facing LEFT, horn toward screen-left.") + U.log("Press A to turn the page: the sprite must not move or mirror, it stays") + U.log("facing left until the screen fades to white for the naming beat.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/parcel_jingle_bug374_test.lua b/tests/drivers/parcel_jingle_bug374_test.lua new file mode 100644 index 00000000..e6302281 --- /dev/null +++ b/tests/drivers/parcel_jingle_bug374_test.lua @@ -0,0 +1,102 @@ +-- Ear check on the Viridian Mart parcel jingle (#374): the key-item fanfare +-- used to fire when the box opened, two pages early. pokered carries it as a +-- text command inside the gift text (scripts/ViridianMart.asm +-- sound_get_key_item, home/text.asm TextCommand_SOUND), so it lands after the +-- last character. Do not set POKEPORT_SPEED: fast-forward scales the logic +-- clock only, so audio and text desynchronize and the check means nothing. +-- POKEPORT_DRIVER=tests/drivers/parcel_jingle_bug374_test.lua POKEPORT_IDENTITY=bug374 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Flags = require("src.script.Flags") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local MAP = "VIRIDIAN_MART" + local QUEST = "_ViridianMartClerkParcelQuestText" + -- pokered data/maps/objects/ViridianMart.asm: warp_event 3, 7 is the door; + -- ViridianMartDefaultScript's .PlayerMovement walks left 1 / up 2 from it to + -- the counter, so the driver only has to stand on the mat. + local STAND = { x = 3, y = 7, facing = "up" } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + Flags.set(game.save, "EVENT_GOT_STARTER") + game.save.flags.EVENT_GOT_OAKS_PARCEL = nil + game.save.flags.EVENT_OAK_GOT_PARCEL = nil + check("starter flag set, both parcel flags clear", + Flags.get(game.save, "EVENT_GOT_STARTER") + and not Flags.get(game.save, "EVENT_GOT_OAKS_PARCEL") + and not Flags.get(game.save, "EVENT_OAK_GOT_PARCEL")) + + -- the whole point of the bug: the gift text is the three-page quest, so the + -- jingle has two pages of typing to be early over + local text = game.data.text[QUEST] + check(QUEST .. " resolves", type(text) == "string" and text ~= "") + local pages = 1 + for _ in tostring(text):gmatch("\f") do pages = pages + 1 end + check("the quest text is three pages", pages == 3) + local last = tostring(text):match("([^\f]*)$") or "" + check("its last page is the got-parcel line", + last:find("PARCEL", 1, true) ~= nil) + + local sfx = game.data.audio and game.data.audio.sfx + check("Get_Key_Item is in the sfx table", sfx ~= nil and sfx.Get_Key_Item ~= nil) + local parcel = game.data.items.OAKS_PARCEL + check("OAKS_PARCEL is a key item (picks Get_Key_Item over Get_Item1)", + parcel ~= nil and parcel.keyItem == true) + + local story = require("data.scripts.story") + local gift + for _, row in ipairs(story.VIRIDIAN_MART.talk.TEXT_VIRIDIANMART_CLERK) do + if row[1] == "give_item" and row[2] == "OAKS_PARCEL" then gift = row end + end + check("the clerk's give_item row passes the quest text", + gift ~= nil and gift[4] == QUEST) + + local vol = game.save.options and game.save.options.sfxVol + if vol == 0 then + U.log("SFX VOLUME IS 0. Nothing will be audible. Raise it in OPTION and rerun.") + else + check("sfx volume is audible (" .. tostring(vol) .. ")", vol ~= 0) + end + + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + local ow = game.overworld + if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then + -- a map edit or a mod moved the mat: any free walkable neighbour of the + -- door still lands inside the shop, and the clerk's script drives the walk + local sides = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } } + for _, s in ipairs(sides) do + local cx, cy = STAND.x + s[1], STAND.y + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("door cell (%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), + cx, cy) + U.teleport(game, MAP, cx, cy, STAND.facing) + ow = game.overworld + U.wait(10) + break + end + end + end + check("standing in " .. MAP, ow ~= nil and ow.map.id == MAP) + U.wait(50) + + U.tap(game, "a") U.wait(40) -- the clerk's "you came from PALLET" page + U.tap(game, "a") U.wait(90) -- simulated walk to the counter, quest page 1 + U.tap(game, "a") U.wait(60) -- page 2 + U.tap(game, "a") U.wait(180) -- last page types out, the jingle belongs here + U.shot(game, DIR .. "/374_parcel.png") + U.log("captured", DIR .. "/374_parcel.png") + + U.log("The fanfare should start only after \"got / OAK's PARCEL!\" has") + U.log("finished typing, the music ducks under it, and A is ignored until it") + U.log("ends; then the arrow blinks and A closes the box. The pad is yours:") + U.log("clear both parcel flags and walk back in the door to hear it again.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/quit_process_bug339_test.lua b/tests/drivers/quit_process_bug339_test.lua new file mode 100644 index 00000000..944a37ea --- /dev/null +++ b/tests/drivers/quit_process_bug339_test.lua @@ -0,0 +1,91 @@ +-- Manual check that EXIT GAME actually ends the process (#339): the two +-- background workers are now told to quit from love.quit and Android exits +-- outright, so the app reopens without swiping it out of Recents. +-- Port lifecycle only, no pokered analogue (the GB has no process). +-- POKEPORT_DRIVER=tests/drivers/quit_process_bug339_test.lua POKEPORT_IDENTITY=bug339 POKEPORT_TOUCH=0 love . +-- Do not set POKEPORT_SPEED: fast-forward desynchronizes the title music. +return function(game) + local U = dofile("tests/drivers/util.lua") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- No map setup: the quit path is the title's EXIT GAME row + -- (src/ui/TitleState.lua:368), reachable straight from boot. The START + -- menu QUIT is not a process quit, it power-cycles to the title + -- (src/core/Game.lua:165), so it cannot verify this. + U.wait(5) + U.tap(game, "start") -- past the copyright splash / attract movie + U.wait(30) + + local title = game.stack:top() + check("the title screen is on top", + title ~= nil and title.screenId == "TitleState") + + -- love.quit reaches both workers through package.loaded, so what matters is + -- that the module this session loaded is the one carrying shutdown + local chip = package.loaded["src.core.ChipAudio"] + check("ChipAudio is loaded in this session", chip ~= nil) + check("ChipAudio.shutdown exists", + chip ~= nil and type(chip.shutdown) == "function") + check("love.thread is available, so the chip worker is the live one", + love.thread ~= nil and love.thread.newThread ~= nil) + + -- src/update/Check's worker is started by the launcher + -- (src/import/RomImporter.lua:594), not by a driver run + local upd = package.loaded["src.update.Check"] + if upd then + check("Check.shutdown exists", type(upd.shutdown) == "function") + else + local ok, mod = pcall(require, "src.update.Check") + check("Check.shutdown exists (module not started this run)", + ok and type(mod.shutdown) == "function") + end + + check("love.event.quit is reachable", + love.event ~= nil and love.event.quit ~= nil) + + local opts = game.save.options + if (opts and opts.sfxVol or 0) == 0 then + U.log("WARNING sfx volume is 0: the menu presses will make no sound.") + end + if (opts and opts.musicVol or 0) == 0 then + U.log("WARNING music volume is 0: with the title theme silent the chip") + U.log("worker may never have started, which is the thread this fix joins.") + end + + -- park the cursor on EXIT GAME and stop there; pressing it is the human's + -- job, since the press ends the process + U.tap(game, "a") + U.wait(20) + local menu = game.stack:top() + local wanted, labels = nil, {} + for i, item in ipairs(menu and menu.items or {}) do + labels[#labels + 1] = tostring(item.label) + if tostring(item.label):find("EXIT", 1, true) then wanted = i end + end + check("the title menu is open", menu ~= nil and menu.items ~= nil) + check("it has an EXIT GAME row", wanted ~= nil) + U.log("menu rows:", table.concat(labels, ", ")) + + if wanted then + for _ = 1, #menu.items do + if menu.index == wanted then break end + U.tap(game, "down") + U.wait(6) + end + check("the cursor is parked on EXIT GAME", menu.index == wanted) + end + + U.log("The cursor sits on EXIT GAME; press A and the window should close.") + U.log("A few seconds later `pgrep -fl love` must print nothing at all: before") + U.log("this fix a headless love process stayed resident burning a core.") + U.log("On Android, reopening from the launcher without swiping the app out of") + U.log("Recents should cold boot normally instead of flashing black.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/reds_house_stairs_bug230_test.lua b/tests/drivers/reds_house_stairs_bug230_test.lua index 72b09af3..22646f44 100644 --- a/tests/drivers/reds_house_stairs_bug230_test.lua +++ b/tests/drivers/reds_house_stairs_bug230_test.lua @@ -45,8 +45,9 @@ return function(game) ow = game.overworld end - -- 1) Arrive on the 2F stairs via a REAL warp so warpEntryCell/justWarped - -- are set. Teleporting straight onto (7,1) bypasses takeWarp and would + -- 1) Arrive on the 2F stairs via a REAL warp so the arrival state is real + -- (warpEntryCell set, BIT_STANDING_ON_WARP cleared by the stair tile). + -- Teleporting straight onto (7,1) bypasses takeWarp and would -- never set the arrival-inert state, so the bug could not reproduce -- -- we must walk up onto the 1F stairs and let the warp carry us. U.teleport(game, "REDS_HOUSE_1F", 7, 3, "up") diff --git a/tests/drivers/rock_tunnel_dark_bake_bug383_test.lua b/tests/drivers/rock_tunnel_dark_bake_bug383_test.lua new file mode 100644 index 00000000..9b3eb03d --- /dev/null +++ b/tests/drivers/rock_tunnel_dark_bake_bug383_test.lua @@ -0,0 +1,357 @@ +-- Driver: in a dark cave FadePal2 writes rOBP0 as well as rBGP, so the player +-- is a black silhouette, and ADVANCED bakes the shift into its atlas instead +-- of veiling the world (#383). home/fade.asm:3-19,66 LoadGBPal indexes +-- FadePal4 - wMapPalOffset; home/overworld.asm:500,535,790 sets the offset. +-- POKEPORT_DRIVER=tests/drivers/rock_tunnel_dark_bake_bug383_test.lua \ +-- POKEPORT_IDENTITY=bug383 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots383 love . +-- No POKEPORT_SPEED: fast-forward desynchronizes audio. +return function(game) + local U = dofile("tests/drivers/util.lua") + local Probe = dofile("tests/drivers/shot_probe.lua") + local PaletteFX = require("src.render.PaletteFX") + local Pokemon = require("src.pokemon.Pokemon") + local Zoom = require("src.render.Zoom") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots383" + + local fails = 0 + local function check(ok, msg) + U.log(ok and "PASS" or "FAIL", msg) + if not ok then fails = fails + 1 end + return ok + end + local function rgb(c) + return c and ("(%d,%d,%d)"):format(c[1], c[2], c[3]) or "nil" + end + local function ramp(p) + if not p then return "nil" end + local s = {} + for i = 1, 4 do s[i] = rgb(p[i]) end + return table.concat(s, " ") + end + local function sameCol(a, b) + return a and b and a[1] == b[1] and a[2] == b[2] and a[3] == b[3] + end + local function samePal(a, b) + if not a or not b then return false end + for i = 1, 4 do + if not sameCol(a[i], b[i]) then return false end + end + return true + end + + game.save.flags.EVENT_GOT_STARTER = true + game.save.inventory.BOULDERBADGE = true + local mon = Pokemon.new(game.data, "PIKACHU", 30) + mon.moves = { { id = "FLASH", pp = 15 } } + game.save.party = { mon } + game.save.options = game.save.options or {} + game.save.options.textSpeed = 1 + local vol = game.save.options.sfxVol + if vol == 0 then + U.log("sfxVol is 0: the FLASH blink and menu beeps are silent, turn SFX up", + "in OPTION before judging anything by ear") + else + U.log("sfxVol =", tostring(vol)) + end + + -- ---- the arithmetic, before anything is on screen ---------------------- + local darkDef = game.data.field.darkMaps + local listed = false + for _, m in ipairs(darkDef and darkDef.maps or {}) do + if m == "ROCK_TUNNEL_1F" then listed = true end + end + check(listed, "ROCK_TUNNEL_1F is in field.darkMaps.maps") + local BGP = PaletteFX.DARK_BGP + check(BGP[0] == 2 and BGP[1] == 3 and BGP[2] == 3 and BGP[3] == 3, + "DARK_BGP is FadePal2's `dc 3,3,3,2`") + + local litObp, litGroup = PaletteFX.dmgObj() + check(litObp == PaletteFX.OBP0_SHADES, + "with nothing armed dmgObj still hands back the lit OBP0 ramp by identity") + check(PaletteFX.setDarkWorld(true) == true, + "setDarkWorld reports the change, so the caller knows to rebake") + check(PaletteFX.setDarkWorld(true) == false, + "and reports nothing on a repeat, so walking a dark floor never rebuilds") + check(PaletteFX.darkKey() == "#dark", + "the bake cache key carries the flag") + local darkObp, darkGroup = PaletteFX.dmgObj() + U.log("OBP0 lit:", ramp(litObp)) + U.log("OBP0 dark:", ramp(darkObp)) + check(samePal(darkObp, { litObp[3], litObp[4], litObp[4], litObp[4] }), + "rOBP0 `dc 3,3,3,2` collapses every OBJ colour a sprite draws to shade 3") + check(darkGroup ~= litGroup, + "and its bake sits in its own cache group, not over the lit one") + local ogDark = PaletteFX.ogObj() + check(samePal(ogDark, { PaletteFX.GBC_OBJ[3], PaletteFX.GBC_OBJ[4], + PaletteFX.GBC_OBJ[4], PaletteFX.GBC_OBJ[4] }), + "OG RED's boot-ROM greens go with it (colours 1/2/3 all black)") + PaletteFX.setDarkWorld(false) + check(PaletteFX.dmgObj() == PaletteFX.OBP0_SHADES and PaletteFX.darkKey() == "", + "clearing the flag hands the lit ramp and the lit cache key back") + check(game.overworld == nil or game.overworld.darkNeedsOverlay == nil, + "the screen-space veil is gone: no darkNeedsOverlay left to ask") + + -- ---- reach the moment --------------------------------------------------- + -- pokered data/maps/objects/RockTunnel1F.asm: the Route 10 entrance is + -- warp_event 15, 3, so two cells south of it is floor that cannot + -- re-trigger the warp, with the ladder and the sign both on screen. + local MAP, STAND = "ROCK_TUNNEL_1F", { x = 15, y = 5, facing = "down" } + + local function enter(flashLit) + game.save.flashLit = flashLit or nil + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(12) + local ow = game.overworld + if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then + local found + for r = 1, 8 do + for dy = -r, r do + for dx = -r, r do + local cx, cy = STAND.x + dx, STAND.y + dy + if not found and ow.map:isWalkableCell(cx, cy) + and not ow.map:warpAtCell(cx, cy) then + found = { x = cx, y = cy } + end + end + end + if found then break end + end + if found then + U.log(("(%d,%d) is not floor any more -- standing on"):format( + STAND.x, STAND.y), found.x, found.y) + STAND.x, STAND.y = found.x, found.y + game.save.flashLit = flashLit or nil + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(12) + ow = game.overworld + end + end + return ow + end + + game.save.options.colors = "gbc" + PaletteFX.setMode("gbc") + local ow = enter(nil) + check(ow ~= nil and ow.map and ow.map.id == MAP, "player is inside " .. MAP) + check(ow ~= nil and ow.dark == true and PaletteFX.darkWorld() == true, + "the floor is dark with no FLASH used, and the bake flag is armed with it") + + -- The cavern floor tiles carry no shade-0 pixels, so `dc 3,3,3,2` leaves them + -- solid shade 3 -- "the floor is completely black" -- while wall, sign and + -- ladder tiles keep shade-0 pixels that land on shade 2 and stay legible. + -- That is the reporter's reference image, and no uniform veil can produce it. + local FLOOR_TILES = { 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x2f, 0x34, + 0x3d, 0x3e, 0x3f } + local ts = ow and ow.map and ow.map.tileset + if ts and love.image and love.image.newImageData then + local okImg, art = pcall(love.image.newImageData, ts.image) + if okImg and art then + local perRow = ts.tilesPerRow or 16 + local lightest, worst = 0, nil + for _, t in ipairs(FLOOR_TILES) do + local bx, by = (t % perRow) * 8, math.floor(t / perRow) * 8 + for y = by, by + 7 do + for x = bx, bx + 7 do + local r = math.floor(select(1, art:getPixel(x, y)) * 255 + 0.5) + if r > lightest then lightest, worst = r, t end + end + end + end + U.log(("cavern floor tiles: lightest pixel is %d (tile 0x%02x)") + :format(lightest, worst or 0)) + check(lightest < 255, + "no cavern floor tile has a shade-0 pixel, so the shift blacks it out") + else + U.log("WARN could not read", tostring(ts.image), "-- floor art unchecked") + end + end + + -- Camera:follow parks the sprite at (vw/2 - 16, vh/2 - 12) in world-canvas + -- pixels and endFrame blits that canvas centred at Zoom.scale(fitScale), so + -- the player's torso is a fixed box in the captured frame. + local function playerRect(shot) + local wc = game.renderer and game.renderer.worldCanvas + if not (shot and wc) then return nil end + local W, H = shot:getDimensions() + local vw, vh = wc:getWidth(), wc:getHeight() + local sp = Zoom.scale(game.renderer:fitScale()) + local ox = math.floor((W - vw * sp) / 2) + local oy = math.floor((H - vh * sp) / 2) + local x0, y0 = vw / 2 - 16, vh / 2 - 12 + return { (ox + (x0 + 4) * sp) / (W - 1), (oy + (y0 + 6) * sp) / (H - 1), + (ox + (x0 + 12) * sp) / (W - 1), (oy + (y0 + 14) * sp) / (H - 1) } + end + + -- Count the colours the player would still be wearing if only rBGP had been + -- ported, inside his own 16x16 -- his cell is floor, so none of them can + -- come from the ground under him. + local function spriteProbe(label, shot, telltale) + local rect = playerRect(shot) + if not (shot and rect) then + U.log("WARN no pixel probe for", label, "-- judge the player by eye") + return + end + local counts, total = Probe.count(shot, telltale, 1, rect) + local parts, leaked = {}, 0 + for name, n in pairs(counts) do + parts[#parts + 1] = ("%s=%d"):format(name, n) + leaked = leaked + n + end + table.sort(parts) + U.log(("probe[%s player %d px] %s"):format(label, total, + table.concat(parts, " "))) + U.log(" tones on him:", Probe.fmt(Probe.top(shot, 3, 1, rect))) + check(leaked == 0, label .. ": the player is black, not lit or half-lit") + end + + local CAVE = PaletteFX.pal(game.data, "CAVE") + U.log("CAVE palette:", ramp(CAVE)) + + -- ---- ADVANCED / RED++, the mode in the report -------------------------- + local pack = PaletteFX.gbcPack() + if not pack then + U.log("WARN data/palettes_gbc is absent; the ADVANCED half cannot be shown") + else + game.save.options.colors = "redpp" + PaletteFX.setMode("redpp") + ow = enter(nil) + U.wait(30) + U.shot(game, DIR .. "/bug383_1_dark_redpp.png") + local tsId = ow.map.tileset.id + local lit = pack.world.groupColors[tsId] + local shifted = PaletteFX.worldGroupColors(game.data, tsId, ow.map.id, nil) + local allShifted = lit ~= nil and shifted ~= nil + for i = 1, 8 do + if not (lit and shifted and samePal(shifted[i], + { lit[i][3], lit[i][4], lit[i][4], lit[i][4] })) then + allShifted = false + end + end + check(allShifted, + "every ADVANCED tile group bakes FadePal2-shifted, not veiled") + local key = ow.map.renderer and ow.map.renderer.gbcAtlasKey + U.log("atlas key:", tostring(key)) + check(type(key) == "string" and key:find("#dark", 1, true) ~= nil, + "and the atlas it baked is keyed apart from the lit one") + + local shot = Probe.grab() + if shot then + local top, total = Probe.top(shot, 4) + U.log(("probe[ADVANCED dark] %d px: %s"):format(total, Probe.fmt(top))) + local floorIsShifted = false + for i = 1, 8 do + for j = 1, 4 do + if shifted and top[1] and sameCol(top[1], shifted[i][j]) then + floorIsShifted = true + end + end + end + check(floorIsShifted and top[1].share > 0.3, + "the screen is mostly one exact shifted colour -- flat black floor, " + .. "no veil arithmetic and no speckle") + local litSeen = 0 + for i = 1, 8 do + local c = Probe.count(shot, { a = lit[i][1], b = lit[i][2] }, 3) + litSeen = litSeen + c.a + c.b + end + check(litSeen == 0, "and none of the lit atlas's own two top colours " + .. "survives anywhere on it") + PaletteFX.setDarkWorld(false) + local litSprite = PaletteFX.spriteObp(ow.player.sprite.def, + ow.player.sprite.seed) + PaletteFX.setDarkWorld(true) + U.log("player OBP lit:", ramp(litSprite)) + spriteProbe("ADVANCED", shot, + { obj1 = litSprite[2], obj2 = litSprite[3] }) + end + end + + -- ---- OG RED: the reporter's reference capture -------------------------- + game.save.options.colors = "ogred" + PaletteFX.setMode("ogred") + ow = enter(nil) + U.wait(30) + U.shot(game, DIR .. "/bug383_2_dark_ogred.png") + local shot = Probe.grab() + if shot then + local top, total = Probe.top(shot, 5) + U.log(("probe[OG RED dark] %d px: %s"):format(total, Probe.fmt(top))) + spriteProbe("OG RED", shot, + { green = PaletteFX.GBC_OBJ[2], darkGreen = PaletteFX.GBC_OBJ[3] }) + end + + -- ---- the shade-remapped modes, each in its own ramp -------------------- + -- Their sprites bake OBP0 and are coloured by the zone shader, so the tone + -- to look for is the one DARK_BGP sends DMG white to: palette entry 3, which + -- the floor under him cannot supply. + for _, m in ipairs({ { "gbc", "SGB", CAVE }, { "og", "plain DMG", + { { 255, 255, 255 }, { 170, 170, 170 }, { 85, 85, 85 }, + { 0, 0, 0 } } }, + { "classic", "CLASSIC", PaletteFX.CLASSIC } }) do + game.save.options.colors = m[1] + PaletteFX.setMode(m[1]) + ow = enter(nil) + U.wait(30) + U.shot(game, DIR .. "/bug383_3_dark_" .. m[1] .. ".png") + local s = Probe.grab() + if s then + U.log(("probe[%s dark] %s"):format(m[2], Probe.fmt(Probe.top(s, 4)))) + spriteProbe(m[2], s, { shade2 = m[3][3] }) + end + end + + -- ---- FLASH lights it, sprites and atlas together ----------------------- + game.save.options.colors = "gbc" + PaletteFX.setMode("gbc") + ow = enter(true) + check(ow.dark == false and PaletteFX.darkWorld() == false, + "after FLASH neither half of the darkness is armed") + U.wait(30) + U.shot(game, DIR .. "/bug383_4_flash_lit_sgb.png") + local litShot = Probe.grab() + if litShot then + local rect = playerRect(litShot) + if rect then + -- the same box that had to be featureless in the dark: CAVE[1] is the + -- sprite's own shade-0 white and the floor around him has none, so this + -- is also the proof the box really covers the player + local c = Probe.count(litShot, { paper = CAVE[1] }, 1, rect) + U.log("lit player box tones:", Probe.fmt(Probe.top(litShot, 3, 1, rect))) + check(c.paper > 0, + "lit again, the player fills that same box with CAVE's white") + end + end + + if pack then + game.save.options.colors = "redpp" + PaletteFX.setMode("redpp") + ow = enter(true) + U.wait(30) + U.shot(game, DIR .. "/bug383_5_flash_lit_redpp.png") + local key = ow.map.renderer and ow.map.renderer.gbcAtlasKey + U.log("atlas key after FLASH:", tostring(key)) + check(type(key) == "string" and key:find("#dark", 1, true) == nil, + "ADVANCED rebaked its atlas lit rather than keeping the dark one") + end + + U.log(fails == 0 and "all #383 machine checks passed" + or (fails .. " #383 machine check(s) FAILED -- read up")) + U.log("shots in", DIR) + + -- ---- hand the pad over, dark, in the reporter's mode ------------------- + if pack then + game.save.options.colors = "redpp" + PaletteFX.setMode("redpp") + end + enter(nil) + + U.log("You are in ROCK TUNNEL 1F in ADVANCED colours, no FLASH used yet.") + U.log("The floor should be flat black with no speckle, the rock outlines,") + U.log("ladder and sign dim but legible, and the player gone: black on black.") + U.log("START, POKeMON, A, FLASH lights it in one frame with no reload hitch,") + U.log("him back in colour; the ladder to 2F is lit too, Route 10 and back is dark.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/rock_tunnel_dark_bug322_test.lua b/tests/drivers/rock_tunnel_dark_bug322_test.lua index a520af29..d4bba07d 100644 --- a/tests/drivers/rock_tunnel_dark_bug322_test.lua +++ b/tests/drivers/rock_tunnel_dark_bug322_test.lua @@ -145,8 +145,8 @@ return function(game) "the map reports itself dark with no FLASH used") check(ow ~= nil and ow:paletteNameFor(ow.map) == "CAVE", "and resolves the CAVE palette (tileset CAVERN)") - check(ow ~= nil and ow:darkNeedsOverlay() == false, - "SGB needs NO composited veil -- its palette carries the darkening") + check(ow ~= nil and ow.darkNeedsOverlay == nil, + "the composited veil path is gone: SGB darkens via its palette") local function probe(label, wanted, rect) local shot = Probe.grab() @@ -222,16 +222,13 @@ return function(game) local corner = Probe.count(shot, { red = { 148, 58, 58 } }, 3, FAR_CORNER) check(corner.red > 0, "and the far corner still carries that red, not a void") - -- Not a FAIL: OG RED replays its OBP-baked sprites on top of the finished - -- zone pass (PaletteFX.markSpriteRedraw) and that replay is unshaded, so - -- the player keeps his green where FadePal2's OBP0 would darken him too. - local obj = PaletteFX.ogObj() - local sprite, spriteTotal = Probe.count(shot, - { green = obj[2], darkGreen = obj[3] }) - local share = (sprite.green + sprite.darkGreen) * 100 - / math.max(1, spriteTotal) - U.log(("known gap: OG RED's player keeps his green in the dark " - .. "(%.2f%% of the frame); hardware darkens OBP0 too"):format(share)) + -- FadePal2 writes rOBP0 as well as rBGP (`dc 3,3,3,2`), so every OBJ + -- colour lands on shade 3 and the player is a black silhouette: none of + -- the boot-ROM green may survive (#383). + local sprite = Probe.count(shot, + { green = PaletteFX.GBC_OBJ[2], darkGreen = PaletteFX.GBC_OBJ[3] }) + check(sprite.green == 0 and sprite.darkGreen == 0, + "OG RED's player is black in the dark, not green") end -- ---- the modes that darken in their own ramps -------------------------- @@ -259,13 +256,21 @@ return function(game) m[2] .. ": the far corner still has two tones (no light window)") end if m[1] == "redpp" then - -- RED++ has no palette left to shift: TileRenderer bakes true colour - -- into the atlas, so the darkness has to be composited by hand. + -- RED++ has no palette left for the frame shader to shift, so the shift + -- goes into the palette its atlas bakes from instead of a veil (#383). local o = game.overworld U.log("RED++ gbcAtlas present:", tostring(o and o.map and o.map.renderer and o.map.renderer.gbcAtlas ~= nil)) - check(o ~= nil and o:darkNeedsOverlay() == true, - "RED++ is the ONE mode that still composites a flat veil") + check(PaletteFX.darkWorld() == true, + "RED++ arms the dark-world flag, so its atlas bakes dark") + local pack = PaletteFX.gbcPack() + local tsId = o and o.map and o.map.tileset and o.map.tileset.id + local lit = pack and tsId and pack.world.groupColors[tsId] + local shifted = tsId and PaletteFX.worldGroupColors(game.data, tsId, + o.map.id, nil) + check(lit ~= nil and shifted ~= nil and samePal(shifted[1], + { lit[1][3], lit[1][4], lit[1][4], lit[1][4] }), + "and every baked group is FadePal2-shifted, not veiled") end end diff --git a/tests/drivers/route.lua b/tests/drivers/route.lua index 71045488..7e1659e8 100644 --- a/tests/drivers/route.lua +++ b/tests/drivers/route.lua @@ -6325,18 +6325,35 @@ function MANUAL.evolveNidorino() return true end -- SILPH_CO_ELEVATOR at 140) gate real geography: the mart's upper floors -- and most of Silph are only reachable through them. -- --- The floor menu opens from the map's own onEnter (data/scripts/story3.lua --- `elevator`), so it is already up by the time we get here -- there is --- nothing to interact with. Rows are the short floor tokens pokered prints --- ("5F", "B2F"), which is exactly the tail of the destination map id, so --- the next segment names the button to press. +-- The floor menu belongs to the car's panel bg_event, not to map entry +-- (#395): walk to the panel and press A. Rows are the short floor tokens +-- pokered prints ("5F", "B2F"), which is exactly the tail of the +-- destination map id, so the next segment names the button to press. -- Ride the elevator to `wantMap` (defaults to the next segment's floor). --- --- The floor menu opens from the elevator's own onEnter, so it is already up --- when we arrive. If it is NOT up -- we bounced in on a stray warp and the --- menu was dismissed, or we are on the default 1F-exit oscillation -- step --- back onto the car's warp to re-open it before giving up. local function rideElevator(where, wantMap) + -- Stand beside the car's panel bg_event and press A to open the floor + -- menu (data/maps/objects/CeladonMartElevator.asm `bg_event 3, 0`). + local function pressPanel() + local m = ow().map + local sign = (m.def.signs or {})[1] + if not sign then return false end + -- offset from the panel cell, then the direction that faces it back + local SIDES = { { 0, 1, "up" }, { 0, -1, "down" }, + { 1, 0, "left" }, { -1, 0, "right" } } + for _, s in ipairs(SIDES) do + local sx, sy = sign.x + s[1], sign.y + s[2] + if m:inBounds(sx, sy) and m:isWalkableCell(sx, sy) then + ops.goto_({ x = sx, y = sy }) + local p = ow().player + if ow().map.id == m.id and p.cellX == sx and p.cellY == sy then + faceDir(s[3]) + press("a") + if waitFor(isList, 60) then return true end + end + end + end + return false + end local want = tostring(wantMap or nextMapWanted or "") local token = want:match("_([^_]+)$") if not token then @@ -6345,14 +6362,7 @@ local function rideElevator(where, wantMap) return false end local from = ow().map.id - if not waitFor(isList, 20) then - -- Re-open the floor menu: the car's exit warp re-enters the elevator, - -- firing onEnter again. This is what breaks the un-ridden bounce. - local car = findWarpTo("ELEVATOR") or findWarpTo("") - if car then walkOntoWarp(car.x, car.y) end - from = ow().map.id - end - if not waitFor(isList, 60) then + if not isList() and not pressPanel() then note("elevator: no floor menu", where) say(("elevator on %s: the WHICH FLOOR? menu never opened"):format(from)) return false @@ -6388,11 +6398,11 @@ local function rideElevator(where, wantMap) if not cursorTo("index", idx) then backOut() return false end press("a") -- ShakeElevator runs the whole ride in place -- music stop, 100 scroll - -- bounces, the PA chime -- and only then walks us out onto the floor. - for _ = 1, 600 do - if ow().map.id ~= from then break end - if idle() then U.wait(4) else mashUntilIdle() end - end + -- bounces, the PA chime -- and then hands control back inside the car: + -- the rewritten exit warp is what we walk out onto (#395). + mashUntilIdle() + local car = findWarpTo(want) or ow().map.def.warps[1] + if car then walkOntoWarp(car.x, car.y) end local landed = ow().map.id if landed == want then say(("rode the elevator to %s"):format(token)) diff --git a/tests/drivers/silph_rockets_bug392_test.lua b/tests/drivers/silph_rockets_bug392_test.lua new file mode 100644 index 00000000..01fa806a --- /dev/null +++ b/tests/drivers/silph_rockets_bug392_test.lua @@ -0,0 +1,232 @@ +-- Manual check that Team Rocket leaves Silph Co and the president keeps talking +-- (#392). pokered scripts/SilphCo11F.asm: the Giovanni win runs +-- SilphCo11FTeamRocketLeavesScript (HideObject over TOGGLE_SILPH_CO_2F_2..11F_3), +-- and SilphCo11FSilphPresidentText prints .MasterBallDescriptionText on every +-- later talk. No POKEPORT_SPEED: fast-forward desynchronizes the jingle. +-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/silph_rockets_bug392_test.lua POKEPORT_IDENTITY=bug392 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local TextBox = require("src.render.TextBox") + local BattleState = require("src.battle.BattleState") + local ScriptRunner = require("src.script.ScriptRunner") + local mapScripts = require("data.scripts.init") + + -- pokered data/maps/objects/SilphCo11F.asm: president (7,5) STAY DOWN, so + -- he is read from (6,5) facing right; GIOVANNI (6,9), ROCKET1 (3,16), + -- ROCKET2 (15,9). SilphCo11FDefaultScript.PlayerCoordsArray is (6,13) and + -- (7,12), each stepped onto from the cell below it (teleport ignores + -- collision, so the stand cell only has to be south of the trigger). + local PRESIDENT = { x = 7, y = 5 } + local READ = { x = 6, y = 5, facing = "right" } + local TRIGGERS = { { stand = { 7, 13 }, cell = { 7, 12 } }, + { stand = { 6, 14 }, cell = { 6, 13 } } } + local ELEVENTH = { "SILPHCO11F_GIOVANNI", "SILPHCO11F_ROCKET1", + "SILPHCO11F_ROCKET2" } + -- one floor per shape: 3F is rocket + scientist + an item ball, 5F adds the + -- rocket-aligned ROCKER, 7F keeps the rival out of the hide list + local FLOORS = { + { "SILPH_CO_3F", { 22, 7 }, + gone = { "SILPHCO3F_ROCKET", "SILPHCO3F_SCIENTIST" }, + stays = { "SILPHCO3F_HYPER_POTION", "SILPHCO3F_SILPH_WORKER_M" } }, + { "SILPH_CO_5F", { 13, 10 }, + gone = { "SILPHCO5F_ROCKET1", "SILPHCO5F_SCIENTIST", + "SILPHCO5F_ROCKER", "SILPHCO5F_ROCKET2" }, + stays = { "SILPHCO5F_CARD_KEY", "SILPHCO5F_SILPH_WORKER_M" } }, + { "SILPH_CO_7F", { 10, 9 }, + gone = { "SILPHCO7F_ROCKET1", "SILPHCO7F_SCIENTIST", + "SILPHCO7F_ROCKET2", "SILPHCO7F_ROCKET3" }, + stays = { "SILPHCO7F_TM_SWORDS_DANCE", "SILPHCO7F_SILPH_WORKER_M3" } }, + } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local function spawned(name) + for _, n in ipairs(game.overworld and game.overworld.npcs or {}) do + if n.def and n.def.name == name then return n end + end + return nil + end + + local rows = mapScripts.get("SILPH_CO_11F").talk + .TEXT_SILPHCO11F_SILPH_PRESIDENT + check("the president has a hand-ported talk script", type(rows) == "table") + if type(rows) == "table" then + local problems = ScriptRunner.validate(rows) + check("his script validates: " .. (problems[1] or "no problems"), + #problems == 0) + end + for _, key in ipairs({ "_SilphCo11FSilphPresidentText", + "_SilphCo11FSilphPresidentReceivedMasterBallText", + "_SilphCo11FSilphPresidentMasterBallDescriptionText" }) do + local body = game.data.text[key] + check(key .. " is extracted", type(body) == "string" and body ~= "") + end + + local vol = game.save.options and game.save.options.sfxVol + if (vol or 0) == 0 then + U.log("sfxVol is 0: the key-item jingle will be silent, raise it in OPTION") + else + U.log("sfxVol", tostring(vol), "-- the ball arrives on the key-item jingle") + end + + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 70), + Pokemon.new(game.data, "SNORLAX", 70), + Pokemon.new(game.data, "LAPRAS", 70), + } + game.save.player.name = "bryan" + game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI = nil + game.save.flags.EVENT_GOT_MASTER_BALL = nil + game.save.objectToggles = {} + + U.teleport(game, "SILPH_CO_11F", READ.x, READ.y, READ.facing) + U.wait(10) + local ow = game.overworld + for _, name in ipairs(ELEVENTH) do + check(name .. " is on the floor before the fight", spawned(name) ~= nil) + end + U.shot(game, DIR .. "/silph392_0_before.png") + + local function facingThePresident() + local o = game.overworld + local fx, fy = o.player:facingCell() + return o:npcAtCell(fx, fy) == spawned("SILPHCO11F_SILPH_PRESIDENT") + end + + if not facingThePresident() then + -- a map edit moved him: stand on any free walkable neighbour. + -- {dx, dy, facing} is the offset from the president plus the way back. + local sides = { + { 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" }, + } + for _, s in ipairs(sides) do + local cx, cy = PRESIDENT.x + s[1], PRESIDENT.y + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log("president moved; standing on", cx, cy, "facing", s[3]) + U.teleport(game, "SILPH_CO_11F", cx, cy, s[3]) + U.wait(10) + ow = game.overworld + break + end + end + end + check("the player is facing the president", facingThePresident()) + + local function boxText(top) + local lines = {} + for _, page in ipairs(top.pages or {}) do + for _, line in ipairs(page) do lines[#lines + 1] = line end + end + return table.concat(lines, " ") + end + + -- one whole talk: A to open, then A through every box until the world is back + local function talk() + local seen = {} + U.tap(game, "a") + U.wait(20) + for _ = 1, 120 do + local top = game.stack:top() + if getmetatable(top) == TextBox then + local txt = boxText(top) + if txt ~= "" and seen[#seen] ~= txt then seen[#seen + 1] = txt end + elseif top == game.overworld then + break + end + U.tap(game, "a") + U.wait(10) + end + return table.concat(seen, " / ") + end + + local first = talk() + U.log("first talk reads:", first) + check("the first talk prints his thank-you", first ~= "") + check("and hands over the MASTER BALL", + (game.save.inventory.MASTER_BALL or 0) > 0) + check("EVENT_GOT_MASTER_BALL is set", game.save.flags.EVENT_GOT_MASTER_BALL == true) + + U.wait(20) + local second = talk() + U.log("second talk reads:", second) + check("the second talk is not silence", second ~= "") + check("it is the MASTER BALL description", + second:find("prototype", 1, true) ~= nil) + check("and no second ball is handed out", + (game.save.inventory.MASTER_BALL or 0) == 1) + U.shot(game, DIR .. "/silph392_1_president.png") + + -- the hide pass, floor by floor: this is what a save that beat Giovanni sees + -- on its next visit, and what the win itself does in one go + game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI = true + for i, floor in ipairs(FLOORS) do + local mapId, at = floor[1], floor[2] + U.teleport(game, mapId, at[1], at[2], "down") + U.wait(10) + for _, name in ipairs(floor.gone) do + check(name .. " left " .. mapId, spawned(name) == nil) + end + for _, name in ipairs(floor.stays) do + check(name .. " is still there", spawned(name) ~= nil) + end + local toggles = game.save.objectToggles[mapId] or {} + check(mapId .. " toggles were written to the save", + toggles[floor.gone[1]] == false) + U.shot(game, ("%s/silph392_2_%s.png"):format(DIR, mapId:lower())) + if i == #FLOORS then + check("the 7F rival keeps his toggle", + toggles.SILPHCO7F_RIVAL ~= false) + end + end + + -- put the fight back on the table: clear the event, the toggles and the + -- battle records so the coordinate trigger re-arms + game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI = nil + game.save.objectToggles = {} + game.save.defeatedTrainers = {} + game.save.flags.EVENT_BEAT_SILPH_CO_11F_TRAINER_0 = nil + game.save.flags.EVENT_BEAT_SILPH_CO_11F_TRAINER_1 = nil + + local fired, cell + for i, t in ipairs(TRIGGERS) do + U.teleport(game, "SILPH_CO_11F", t.stand[1], t.stand[2], "up") + U.wait(10) + ow = game.overworld + if i == 1 then + for _, name in ipairs(ELEVENTH) do + check(name .. " is back on the floor", spawned(name) ~= nil) + end + U.shot(game, DIR .. "/silph392_3_rearmed.png") + end + U.hold(game, "up", 24) + for _ = 1, 300 do + U.wait(1) + if getmetatable(game.stack:top()) == BattleState then break end + end + if getmetatable(game.stack:top()) == BattleState + or (ow.player.cellX == t.cell[1] and ow.player.cellY == t.cell[2]) then + fired, cell = true, t.cell + break + end + U.log(("the step up from (%d,%d) missed the trigger; trying the other pad") + :format(t.stand[1], t.stand[2])) + end + cell = cell or TRIGGERS[1].cell + check(("stepping onto (%d,%d) started GIOVANNI"):format(cell[1], cell[2]), + fired == true) + U.shot(game, DIR .. "/silph392_4_giovanni.png") + + U.log("Win the fight: after the fade GIOVANNI and both ROCKETs, (3,16) and") + U.log("(15,9), should be gone, and every grunt and lab-coat trainer on") + U.log("3F/5F/7F with them. The president at (7,5) has the ball already, so") + U.log("A on him should describe it again, never an empty beat.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/silph_tm36_bug393_test.lua b/tests/drivers/silph_tm36_bug393_test.lua new file mode 100644 index 00000000..5d6aa0da --- /dev/null +++ b/tests/drivers/silph_tm36_bug393_test.lua @@ -0,0 +1,158 @@ +-- Manual check that the Silph Co 2F worker begs before handing over TM36 (#393). +-- pokered scripts/SilphCo2F.asm prints .PleaseTakeThisText (text/SilphCo2F.asm:1, +-- "Eeek!" / "No! Stop! Help!" ... "please take this!") before GiveItem; the port +-- had no such string in the cache, so the talk opened on "got TM36!". Re-import +-- first (CACHE_FORMAT bump). No POKEPORT_SPEED here: fast-forward +-- desynchronizes the item jingle from the box it belongs to. +-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/silph_tm36_bug393_test.lua POKEPORT_IDENTITY=bug393 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local TextBox = require("src.render.TextBox") + local Bag = require("src.inventory.Bag") + local mapScripts = require("data.scripts.init") + + -- pokered data/maps/objects/SilphCo2F.asm: SILPHCO2F_SILPH_WORKER_F stands at + -- (10, 1) facing UP with row 0 walled off, so she is talked to from below. + local MAP = "SILPH_CO_2F" + local TEXT = "TEXT_SILPHCO2F_SILPH_WORKER_F" + local WORKER = "SILPHCO2F_SILPH_WORKER_F" + local PRE = "SilphCo2FSilphWorkerFPleaseTakeThisText" + local STAND = { x = 10, y = 2, facing = "up" } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- the whole bug was a missing cache string, so say which half is wrong when + -- the boxes come out empty + local pre = game.data.text[PRE] + check(PRE .. " is in the text cache", type(pre) == "string" and pre ~= "") + if type(pre) == "string" then + check("it is the scared line", pre:find("Eeek!", 1, true) ~= nil + and pre:find("please take this!", 1, true) ~= nil) + U.log("pre text reads:", (pre:gsub("[\n\011\012]", " / "))) + end + check(MAP .. "/" .. TEXT .. " runs a hand-ported script", + type(mapScripts.talkScript(MAP, TEXT)) == "function") + check("TM_SELFDESTRUCT is a known item", + game.data.items.TM_SELFDESTRUCT ~= nil) + + -- a save that already has the TM takes the explanation-only branch + if game.save.flags.EVENT_GOT_TM36 then + game.save.flags.EVENT_GOT_TM36 = nil + Bag.remove(game.save, "TM_SELFDESTRUCT", 1) + U.log("EVENT_GOT_TM36 was already set; cleared it to replay the gift") + end + + local vol = game.save.options and game.save.options.sfxVol + if (vol or 0) == 0 then + U.log("sfxVol is 0: the item jingle will be silent, raise it in OPTION first") + else + U.log("sfxVol", tostring(vol), "-- one Get_Item1 jingle, after the pleading") + end + + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + + local function workerIn(ow) + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == WORKER then return n end + end + return nil + end + + -- re-reads game.overworld: the fallback below teleports again, which rebuilds + -- the state and its npc list + local function facingTheWorker() + local ow = game.overworld + local her = ow and workerIn(ow) + if not her then return false end + local fx, fy = ow.player:facingCell() + return ow:npcAtCell(fx, fy) == her + end + + local ow = game.overworld + local her = workerIn(ow) + check("the worker is loaded on " .. MAP, her ~= nil) + + if her and not facingTheWorker() then + -- a map edit or a mod moved her: stand on any free walkable neighbour. + -- {dx, dy, facing} is the offset from her cell plus the way back at her. + local sides = { + { 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" }, + } + for _, s in ipairs(sides) do + local cx, cy = her.cellX + s[1], her.cellY + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), + cx, cy, "facing", s[3]) + U.teleport(game, MAP, cx, cy, s[3]) + U.wait(10) + ow = game.overworld + break + end + end + end + check("the player is facing her", facingTheWorker()) + + local function boxText() + local top = game.stack:top() + if getmetatable(top) ~= TextBox then return nil end + local out = {} + for _, page in ipairs(top.pages or {}) do + for _, line in ipairs(page) do out[#out + 1] = line end + end + return table.concat(out, " / ") + end + + local function hasTm() + return (game.save.inventory or {}).TM_SELFDESTRUCT ~= nil + end + + U.tap(game, "a") + U.wait(30) + local first = boxText() + check("A on her opens a text box", first ~= nil) + if first then U.log("box 1 reads:", first) end + check("the first box is the scared line, not the TM", + first ~= nil and first:find("Eeek!", 1, true) ~= nil) + check("and the TM is still hers while it is up", not hasTm()) + U.shot(game, DIR .. "/bug393_1_scared.png") + + -- read the rest of the conversation the way a player does + for i = 2, 8 do + U.tap(game, "a") + U.wait(40) + local t = boxText() + if not t then break end + U.log(("box %d reads:"):format(i), t) + if t:find("TM36", 1, true) then + U.shot(game, DIR .. "/bug393_2_tm36.png") + end + end + check("TM36 ended up in the bag", hasTm()) + check("EVENT_GOT_TM36 is set", game.save.flags.EVENT_GOT_TM36 ~= nil) + U.log("bag holds", tostring(Bag.slots(game.save)), "item kinds now") + + -- hand it back unclaimed so the pleading can be watched and heard live + game.save.flags.EVENT_GOT_TM36 = nil + Bag.remove(game.save, "TM_SELFDESTRUCT", 1) + while getmetatable(game.stack:top()) == TextBox do + U.tap(game, "a") + U.wait(20) + end + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + U.log("the gift was rolled back; the pad is yours, press A on her") + + U.log("She should panic first: \"Eeek!/No! Stop! Help!\", then work out you") + U.log("are not a Rocket and offer the TM, and only then the jingle plays and") + U.log("the box says got TM36!, followed by the SELFDESTRUCT warning.") + U.log("Press A on her once more after that: straight to the warning, no panic.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/ss_anne_departure_bug360_test.lua b/tests/drivers/ss_anne_departure_bug360_test.lua new file mode 100644 index 00000000..efa4aefe --- /dev/null +++ b/tests/drivers/ss_anne_departure_bug360_test.lua @@ -0,0 +1,180 @@ +-- Eye/ear check on the S.S. Anne leaving Vermilion Dock and the rival's +-- goodbye on 2F (#360): she used to blink to water in one frame, the surf +-- music rode into the city, and the rival walked back to his spawn without +-- the CUT-master line. scripts/VermilionDock.asm and scripts/SSAnne2F.asm. +-- Do not set POKEPORT_SPEED: fast-forward scales the logic clock only, so +-- the horns and the music switch desynchronize from what you see. +-- POKEPORT_DRIVER=tests/drivers/ss_anne_departure_bug360_test.lua POKEPORT_IDENTITY=bug360 POKEPORT_DEV=1 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local Music = require("src.core.Music") + local story3 = require("data.scripts.story3") + local story5 = require("data.scripts.story5") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + -- pokered data/maps/objects/VermilionDock.asm: warp_event 14, 2 is the + -- gangway off the ship, which is block (7,1); SSAnne2F.asm's + -- .PlayerCoordinatesArray is 36,8 / 37,8 with the rival spawned on (36,4). + local DOCK, DOCK_CELL = "VERMILION_DOCK", { x = 14, y = 2 } + local ANNE2F, TRIGGER = "SS_ANNE_2F", { x = 37, y = 8 } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local function rowsOfKind(rows, kind) + local out = {} + for _, r in ipairs(rows or {}) do + if r[1] == kind then out[#out + 1] = r end + end + return out + end + + -- capture what a scene queues without letting it touch the live save or + -- start the encounter sting + local function capture(run) + local realPlay = Music.play + Music.play = function() end + local rows + local ow = { + runner = { + isRunning = function() return false end, + run = function(_, r) rows = r end, + }, + player = { facing = "down", cellX = DOCK_CELL.x, cellY = DOCK_CELL.y }, + startDustAnim = function(_, _, _, done) if done then done() end end, + queueScript = function(_, r) rows = r end, + } + run(ow) + Music.play = realPlay + return rows + end + + -- ---------------------------------------------------------- rival exit + local CUT = "_SSAnne2FRivalCutMasterText" + local cut = game.data.text[CUT] + check(CUT .. " resolves", type(cut) == "string" and cut ~= "") + if type(cut) == "string" then + check("it is the CUT-master line", cut:find("CUT", 1, true) ~= nil) + U.log("rival goodbye reads:", (cut:gsub("\n", " / "))) + end + + for _, side in ipairs({ { 37, 4 }, { 36, 6 } }) do + local x, steps = side[1], side[2] + local rows = capture(function(ow) + story5[ANNE2F].onStep({ save = { flags = {} }, data = game.data }, ow, x, 8) + end) + local said = rowsOfKind(rows, "show_text") + check(("x=%d the goodbye is the last thing he says"):format(x), + said[#said] ~= nil and said[#said][2] == CUT) + local walk = rowsOfKind(rows, "walk_npc")[1] + check(("x=%d exit walk is %d steps, ending downward"):format(x, steps), + walk ~= nil and #walk[3] == steps and walk[3][#walk[3]] == "down") + check(("x=%d he does not retreat to the spawn (36,4)"):format(x), (function() + for _, r in ipairs(rowsOfKind(rows, "move_npc_to")) do + if r[3] == 36 and r[4] == 4 then return false end + end + return true + end)()) + end + + -- ------------------------------------------------------------ the ship + local dockRows = capture(function(ow) + story3[DOCK].onEnter({ save = { flags = { EVENT_GOT_HM01 = true } }, + data = game.data }, ow) + end) + local horns = 0 + for _, r in ipairs(rowsOfKind(dockRows, "play_sound")) do + if r[2] == "SS_Anne_Horn" then horns = horns + 1 end + end + check("the departure blows the horn twice", horns == 2) + local slide = 0 + for _, w in ipairs(rowsOfKind(dockRows, "wait")) do + if w[2] == 20 then slide = slide + 1 end + end + check("she sails in eight beats, not one frame", slide == 8) + check("no keepMusic override into the city", (function() + for _, r in ipairs(rowsOfKind(dockRows, "play_music")) do + if r[3] and r[3].keep then return false end + end + return true + end)()) + local sfx = game.data.audio and game.data.audio.sfx + check("SS_Anne_Horn is in the sfx table", sfx ~= nil and sfx.SS_Anne_Horn ~= nil) + local mapSongs = game.data.audio and game.data.audio.mapSongs + check("VERMILION_CITY still owns Music_Vermilion", + mapSongs ~= nil and mapSongs.VERMILION_CITY == "Music_Vermilion") + + local opts = game.save.options or {} + if opts.sfxVol == 0 or opts.musicVol == 0 then + U.log("SFX OR MUSIC VOLUME IS 0. The horns and the music switch are the") + U.log("whole check. Raise them in OPTION and rerun.") + else + check("sfx and music are audible (" .. tostring(opts.sfxVol) .. "/" + .. tostring(opts.musicVol) .. ")", true) + end + + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 50), + Pokemon.new(game.data, "PIKACHU", 30), + Pokemon.new(game.data, "SNORLAX", 77), + } + game.save.flags.EVENT_GOT_POKEDEX = true + game.save.flags.EVENT_GOT_HM01 = true + game.save.flags.EVENT_SS_ANNE_LEFT = nil + game.save.flags.EVENT_BEAT_SS_ANNE_RIVAL = nil + check("HM01 in hand, ship still docked, rival unbeaten", + game.save.flags.EVENT_GOT_HM01 == true + and game.save.flags.EVENT_SS_ANNE_LEFT == nil + and game.save.flags.EVENT_BEAT_SS_ANNE_RIVAL == nil) + + U.log("Watch for: she idles a couple of seconds with smoke off the funnel,") + U.log("one horn, then travels WEST a block at a time with the water closing") + U.log("in behind her, a second horn once she is gone, and the surf loop") + U.log("gives way to the Vermilion theme the moment you cross into town.") + + U.teleport(game, DOCK, DOCK_CELL.x, DOCK_CELL.y, "up") + local ow = game.overworld + if ow and ow.map.id ~= DOCK then + check("standing on " .. DOCK, false) + elseif ow and not ow.map:isWalkableCell(DOCK_CELL.x, DOCK_CELL.y) then + -- a map edit moved the gangway: any walkable neighbour of it still sits + -- on the deck row the departure is keyed on + for _, s in ipairs({ { 0, 1 }, { -1, 0 }, { 0, -1 }, { 1, 0 } }) do + local cx, cy = DOCK_CELL.x + s[1], DOCK_CELL.y + s[2] + if ow.map:isWalkableCell(cx, cy) then + U.log(("gangway cell (%d,%d) is blocked, standing on") + :format(DOCK_CELL.x, DOCK_CELL.y), cx, cy) + U.teleport(game, DOCK, cx, cy, "up") + break + end + end + end + + -- filmstrip across the slide: 120 frames of idling, then a shot every 40 + U.wait(110) + U.shot(game, DIR .. "/360_dock_0.png") + for i = 1, 5 do + U.wait(40) + U.shot(game, DIR .. "/360_dock_" .. i .. ".png") + end + U.log("captured", DIR .. "/360_dock_0.png", "through 5") + U.wait(240) + U.shot(game, DIR .. "/360_dock_gone.png") + + -- park him one step short of the 2F trigger so the pad picks it up + U.wait(120) + U.teleport(game, ANNE2F, TRIGGER.x, TRIGGER.y + 1, "up") + + U.log("The pad is yours. One tap UP walks onto (37,8) and the rival comes") + U.log("down from the captain's door: beat him, and after the goodbye he must") + U.log("walk four cells DOWN out of the room, never back up to (36,4). For") + U.log("the other branch, console: game.save.flags.EVENT_BEAT_SS_ANNE_RIVAL") + U.log("= nil, then step onto (36,8) -- there he goes RIGHT around you first.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/title_seam_bug373_test.lua b/tests/drivers/title_seam_bug373_test.lua new file mode 100644 index 00000000..215f5b22 --- /dev/null +++ b/tests/drivers/title_seam_bug373_test.lua @@ -0,0 +1,108 @@ +-- Manual check for the two faint horizontal lines across the title (#373): +-- black gaps between the SGB zone clips plus a too-bright ribbon band. +-- Zones are pokered data/sgb/sgb_packets.asm BlkPacket_Titlescreen (rows +-- 0-7 / 8-9 / 10-17), whose whites all match (data/sgb/sgb_palettes.asm). +-- POKEPORT_DRIVER=tests/drivers/title_seam_bug373_test.lua POKEPORT_IDENTITY=bug373 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love . +-- Do not set POKEPORT_SPEED: fast-forward desynchronizes the title music. +return function(game) + local U = dofile("tests/drivers/util.lua") + local PaletteFX = require("src.render.PaletteFX") + local Renderer = require("src.render.Renderer") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- past the copyright splash / attract movie (engine/movie/splash.asm) + U.wait(5) + U.tap(game, "start") + U.wait(30) + + local title = game.stack:top() + check("the title screen is on top", + title ~= nil and title.screenId == "TitleState") + if not (title and title.sgbPalettes) then + U.log("No title state to look at; nothing below can run.") + while true do coroutine.yield() end + end + + local opts = game.save.options + local mode = opts and opts.colors or PaletteFX.mode + check("COLORS is SGB (the default this bug shows in)", mode == "gbc") + if (opts and opts.musicVol or 0) == 0 then + U.log("WARNING music volume is 0: the title theme will be silent.") + end + if (opts and opts.sfxVol or 0) == 0 then + U.log("WARNING sfx volume is 0: the START press will make no sound.") + end + + -- the three palette names the zones are built from + for _, name in ipairs({ "LOGO1", "LOGO2", "MEWMON" }) do + local pal = PaletteFX.pal(game.data, name) + check(name .. " resolves to a four-color palette", + type(pal) == "table" and #pal == 4) + end + + local function report(z) + if not z then return end + for i = 1, #z do + local c = z[i].colors + U.log(("zone %d rows %d-%d, white %s"):format( + i, z[i].y, z[i].y + z[i].h - 1, + c and c[1] and table.concat(c[1], ",") or "none")) + end + end + + local z = title:sgbPalettes(game) + check("the title builds three zones", z ~= nil and #z == 3) + if z and #z == 3 then + check("the ribbon band starts at tile row 8 (y=64)", z[2].y == 64) + check("the player/mon zone starts at tile row 10 (y=80)", z[3].y == 80) + check("logo and ribbon share a boundary", z[1].y + z[1].h == z[2].y) + check("ribbon and mon zone share a boundary", z[2].y + z[2].h == z[3].y) + local w1, w2, w3 = z[1].colors[1], z[2].colors[1], z[3].colors[1] + local function sameColor(a, b) + return a and b and a[1] == b[1] and a[2] == b[2] and a[3] == b[3] + end + check("all three zones share color 0", sameColor(w1, w2) and sameColor(w1, w3)) + report(z) + end + + -- The black-gap half only exists where the framebuffer is not an integer + -- multiple of the window, i.e. Android's truncated density (#208). + local ww, wh = love.graphics.getDimensions() + local pw, ph = ww, wh + if love.graphics.getPixelDimensions then + pw, ph = love.graphics.getPixelDimensions() + end + local dpiX, dpiY = pw / ww, ph / wh + U.log(("surface %dx%d units, %dx%d pixels, dpi %.4f/%.4f, fit scale %d") + :format(ww, wh, pw, ph, dpiX, dpiY, Renderer:fitScale())) + if dpiX % 1 == 0 and dpiY % 1 == 0 then + U.log("Integer DPI here, so the black-gap half cannot show on this") + U.log("screen at all; only the Android build reproduces it.") + end + + U.shot(game, SHOT_DIR .. "/bug373_title_gbc.png") + U.log("captured", SHOT_DIR .. "/bug373_title_gbc.png") + + PaletteFX.setMode("redpp") + U.wait(20) + U.shot(game, SHOT_DIR .. "/bug373_title_redpp.png") + U.log("captured", SHOT_DIR .. "/bug373_title_redpp.png") + PaletteFX.setMode(mode) + U.wait(10) + + U.log("The title is on screen and the pad is yours; START replays it.") + U.log("In SGB the whole background is one off-white: sample left-edge") + U.log("background above, inside and below the \"Version\" band and all three") + U.log("read the same, no brighter strip and no dark hairline at rows 64/80.") + U.log("The redpp shot is pure white throughout with the ink still red on") + U.log("Red and blue on Blue; run POKEPORT_VERSION=blue to confirm that half.") + + while true do + coroutine.yield() + end +end diff --git a/tests/engine/fanfare_music_hold.lua b/tests/engine/fanfare_music_hold.lua new file mode 100644 index 00000000..1913ba99 --- /dev/null +++ b/tests/engine/fanfare_music_hold.lua @@ -0,0 +1,176 @@ +-- A song cued while a fanfare is sounding stays silent until the jingle +-- ends (#398), on both chip playback paths. On the Game Boy a fanfare's +-- sfx header claims the music channels and Audio1_PlaySound's .playMusic +-- only rewrites the NUM_MUSIC_CHANS state, so the new song is muted until +-- the jingle finishes (audio/engine_1.asm:39-56 Audio1_ApplyMusicAffects, +-- :1343-1357 .playMusic). ChipAudio is what calls Source:play for a chip +-- song, so Music pausing its own Source cannot cover a song that starts +-- mid-jingle: the hold lives in ChipAudio and Music releases it. +-- ROM-free: ChipAsm blobs, no data/generated/. +-- luajit tests/engine/fanfare_music_hold.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check + +love = require("tests.love_stub") + +-- ------- audio stub: records which sources exist and which are sounding + +local sources = {} + +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(v) self.looping = v end +function Source:setVolume(v) self.volume = v end +function Source:setPitch(v) self.pitch = v end +function Source:setFilter() end +function Source:getDuration() return 1 end +function Source:getFreeBufferCount() return self.free end +function Source:queue() self.free = math.max(0, self.free - 1) end + +local ChipSynth = require("src.core.ChipSynth") +local BUFFERS = ChipSynth.MUSIC_BUFFER_COUNT + +local function track(src) + sources[#sources + 1] = src + return src +end + +love.audio = { + newSource = function(what, mode) + if what ~= "assets/beep.wav" then error("could not open " .. tostring(what), 0) end + return track(setmetatable({ file = what, mode = mode, free = 0 }, Source)) + end, + newQueueableSource = function() + return track(setmetatable({ queueable = true, free = BUFFERS }, Source)) + end, +} + +-- ------- worker stub for the threaded path +-- The real worker synthesizes on another thread; here the test hands the +-- buffers over itself so it controls the frame the first one lands on. + +local channels = {} +local lastGen = 0 + +local Channel = {} +Channel.__index = Channel +function Channel:push(msg) + if type(msg) == "table" and msg.cmd == "play" then lastGen = msg.gen end + self.queue[#self.queue + 1] = msg +end +function Channel:pop() return table.remove(self.queue, 1) end +function Channel:clear() self.queue = {} end + +local function channel(name) + channels[name] = channels[name] or setmetatable({ queue = {} }, Channel) + return channels[name] +end + +local threadStub = { + newThread = function() + return { + start = function() end, + getError = function() return nil end, + wait = function() end, + } + end, + getChannel = channel, +} + +local function deliverBuffer() + channel("chipaudio_out"):push({ gen = lastGen, sd = true }) +end + +-- ------- fixture dataset + +local ChipAsm = require("src.audio.ChipAsm") + +local function chipSong(octave) + return ChipAsm.song{ + channels = { { hw = 1, program = { + { notetype = { speed = 12, volume = 12, fade = 0 } }, + { octave = octave }, + { note = "C", len = 8 }, + { loop = { count = 0, to = 1 } }, + } } }, + } +end + +local function fixtureData() + return { + audio = { + songs = { Music_PalletTown = chipSong(4), Music_Routes1 = chipSong(5) }, + -- Level_Up rides Sound.lua's FANFARES fallback, which is what runs + -- when a cache carries no data.audio.fanfares table + sfx = { Level_Up = "assets/beep.wav" }, + cries = {}, + mapSongs = { PALLET_TOWN = "Music_PalletTown" }, + }, + } +end + +local Music = require("src.core.Music") +local Sound = require("src.core.Sound") + +-- ChipAudio decides sync vs threaded once per process (workerReady), so each +-- path gets its own copy of the module; Music resolves it through require on +-- every call and picks the new one up. +local function freshChipAudio(threaded) + love.thread = threaded and threadStub or nil + package.loaded["src.core.ChipAudio"] = nil + return require("src.core.ChipAudio") +end + +local function lastSource() return sources[#sources] end + +local function scenario(label, threaded) + local ChipAudio = freshChipAudio(threaded) + local data = fixtureData() + Sound.invalidate() + Music.reload() + for i = #sources, 1, -1 do sources[i] = nil end + + Music.playMap(data, "PALLET_TOWN", false, false) + local mapSrc = lastSource() + if threaded then deliverBuffer() ChipAudio.update() end + check(mapSrc and mapSrc.queueable, label .. ": map theme streams through ChipAudio") + check(mapSrc.playing, label .. ": map theme is sounding before the jingle") + + local fanfare = Sound.play(data, "Level_Up") + check(fanfare ~= nil and fanfare.playing, label .. ": Level_Up started") + check(not mapSrc.playing, label .. ": the playing song ducked under the jingle") + + -- BattleState:finish -> Music.restoreMap while the jingle still sounds; + -- restoreMap clears state.current, so even the same theme rebuilds + Music.restoreMap(data) + local held = lastSource() + check(held ~= mapSrc, label .. ": restoreMap built a new source mid-jingle") + if threaded then deliverBuffer() ChipAudio.update() end + ChipAudio.ensureMusicPlaying() + Music.update(data) + check(not held.playing, label .. ": a song cued during the jingle stays silent") + check(fanfare.playing, label .. ": the jingle is still the only thing sounding") + + fanfare:stop() + Music.update(data) + if threaded then ChipAudio.update() end + check(held.playing, label .. ": the held song starts once the jingle ends") + + -- a later song change is unaffected: the hold is released, not sticky + Music.play(data, "Music_Routes1") + local next_ = lastSource() + if threaded then deliverBuffer() ChipAudio.update() end + check(next_ ~= held and next_.playing, label .. ": later songs start normally") +end + +scenario("sync", false) +scenario("threaded", true) + +T.finish("fanfare music hold") diff --git a/tests/engine/give_item_jingle.lua b/tests/engine/give_item_jingle.lua new file mode 100644 index 00000000..7e8309dc --- /dev/null +++ b/tests/engine/give_item_jingle.lua @@ -0,0 +1,170 @@ +-- Headless regression: a gift jingle rides its received-item text instead +-- of firing when the box opens (#374). pokered's GiveItem (home/give.asm) +-- plays nothing; the sound is a trailing text command in the gift text +-- (scripts/ViridianMart.asm sound_get_key_item), and home/text.asm:506 +-- TextCommand_SOUND runs only after the last character is placed, then +-- WaitForSoundToFinish, then home/text_script.asm AfterDisplayingTextID's +-- button wait. ROM-free: fixture items plus a stub audio source. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +require("src.render.Font").load(Data) + +local Commands = require("src.script.Commands") +local Sound = require("src.core.Sound") +local SaveData = require("src.core.SaveData") +local TextBox = require("src.render.TextBox") + +-- a key item to gift: the fixture set has none, and keyItem is what picks +-- Get_Key_Item over Get_Item1 +Data.items.FIX_PARCEL = { + id = "FIX_PARCEL", index = 90, name = "FIX PARCEL", price = 0, + keyItem = true, +} + +-- one stub source per sfx file, with a playing flag the case drives: Sound. +-- play has to hand the source back for the box to block on it. The A-press +-- beep goes through the same path, so plays records which sound it was. +local plays = {} +local sources = {} +local function newSource(file) + local src = sources[file] + if src then return src end + src = { + playing = false, + setVolume = function() end, + setPitch = function() end, + stop = function() end, + isPlaying = function(self) return self.playing end, + play = function(self) + plays[#plays + 1] = file + self.playing = true + end, + } + sources[file] = src + return src +end +love.audio = { newSource = newSource } + +local function jingles() + local n = 0 + for _, file in ipairs(plays) do + if file ~= "ab.wav" then n = n + 1 end + end + return n +end +Data.audio = { + sfx = { Get_Key_Item = "key.wav", Get_Item1 = "item.wav", Press_AB = "ab.wav" }, + fanfares = {}, +} + +local stack = { states = {} } +function stack:push(s) self.states[#self.states + 1] = s end +function stack:pop() + local t = self.states[#self.states] + self.states[#self.states] = nil + return t +end +function stack:top() return self.states[#self.states] end + +local pressed = {} +local game = { + data = Data, + save = SaveData.newGame(), + stack = stack, + input = { + wasPressed = function(_, key) return pressed[key] or false end, + isDown = function() return false end, + }, +} +game.save.options = game.save.options or {} +game.save.options.textSpeed = 1 + +local resumed = 0 +local ctx = { + game = game, + save = game.save, + runner = { yield = function() end, resume = function() resumed = resumed + 1 end }, +} + +local function step(btn) + pressed = btn and { [btn] = true } or {} + local top = stack:top() + if top then top:update(1 / 60) end + pressed = {} +end + +-- ---------------------------------------------------------------- gift text +-- the Viridian Mart shape: the gift text is the whole quest, three pages, +-- and only the last one is " got / !" +-- uppercase: the fixture font has no lowercase glyphs +local QUEST = "YOU KNOW PROF\nOAK RIGHT\fHE ASKED ME TO\nDELIVER THIS\f" + .. "{PLAYER} GOT\n{RAM:wStringBuffer}!" + +Commands.give_item(ctx, "FIX_PARCEL", 1, QUEST) +T.eq(game.save.inventory.FIX_PARCEL, 1, "the parcel reached the bag") +local box = stack:top() +T.check(getmetatable(box) == TextBox, "the received-item box is up") +T.eq(#box.pages, 3, "the gift text is three pages") +T.eq(jingles(), 0, "no jingle when the box opens") + +-- type the whole text out, answering the page breaks; the jingle must stay +-- silent for every frame of it +local pagesSeen = 1 +for _ = 1, 2000 do + if box.done then break end + step(box.waiting and "a" or nil) + if box.pageIndex > pagesSeen then + pagesSeen = box.pageIndex + T.eq(jingles(), 0, "still silent on page " .. pagesSeen) + end +end +T.check(box.done, "the last page finished typing") +T.eq(jingles(), 0, "silent until the last character is placed") + +step() +T.eq(jingles(), 1, "the jingle fires once the text is out") +T.eq(plays[#plays], "key.wav", "and it is the key-item jingle") +T.eq(box.autoSrc, sources["key.wav"], + "the box holds the source Sound.play returned") + +-- WaitForSoundToFinish: A is dead while the fanfare sounds +step("a") +T.eq(stack:top(), box, "A does not close the box during the jingle") +T.eq(resumed, 0, "the script has not resumed yet") +T.eq(jingles(), 1, "and the jingle is not retriggered") + +sources["key.wav"].playing = false +step() +T.check(box.auto == nil, "the sound gate drops when the fanfare ends") +T.eq(stack:top(), box, "the box stays up for the button wait") + +step("a") +T.eq(stack:top(), nil, "A closes the box after the jingle") +T.eq(resumed, 1, "the script resumed once") + +-- ------------------------------------------------------------- plain gift +-- gotText == false is the script-shows-its-own-text form (Oak's 5 POKE +-- BALLs): nothing to hang the sound on, so it plays on the spot +plays = {} +Commands.give_item(ctx, "FIX_POTION", 1, false) +T.eq(jingles(), 1, "the no-text gift still plays its jingle immediately") +T.eq(plays[1], "item.wav", "with the plain-item sound") +T.eq(stack:top(), nil, "and pushes no box of its own") + +-- ------------------------------------------------------------ script data +-- both Viridian Mart paths must hand give_item the quest text, since that +-- is what routes the jingle through the box +local story = require("data.scripts.story") +local mart = story.VIRIDIAN_MART +local rows = mart.talk.TEXT_VIRIDIANMART_CLERK +local gift +for _, row in ipairs(rows) do + if row[1] == "give_item" and row[2] == "OAKS_PARCEL" then gift = row end +end +T.check(gift ~= nil, "the clerk talk path gifts OAKS_PARCEL") +T.eq(gift and gift[4], "_ViridianMartClerkParcelQuestText", + "and passes the quest text, not the default got-item line") + +T.finish("give_item_jingle") diff --git a/tests/engine/multihit_hp_drain.lua b/tests/engine/multihit_hp_drain.lua new file mode 100644 index 00000000..60d59a35 --- /dev/null +++ b/tests/engine/multihit_hp_drain.lua @@ -0,0 +1,96 @@ +-- A multi-hit move must step the target's HP bar down once per strike +-- (#394). ApplyDamageToEnemyPokemon (engine/battle/core.asm:4684-4727) +-- subtracts wDamage and runs UpdateHPBar2 inside the wNumAttacksLeft loop, +-- so the bar animates one hit's worth per pass; the port takes every +-- strike off the model while the turn is still being queued, so each drain +-- row has to carry its own stop. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +local Font = require("src.render.Font") +Font.load(Data) +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +local HITS = 3 +Data.moves.FIX_MULTI = { + id = "FIX_MULTI", index = 99, name = "FIX MULTI", + type = "NORMAL", power = 15, accuracy = 100, pp = 10, + effect = "TWO_TO_FIVE_ATTACKS_EFFECT", multiHit = HITS, +} + +local function mkseq(vals) -- scripted rng: pops vals, then max rolls + local i = 0 + return function(_, hi) + i = i + 1 + return vals[i] ~= nil and vals[i] or hi + end +end + +local save = SaveData.newGame() +save.party = { Pokemon.new(Data, "FIXMON_A", 30) } +local game = { data = Data, save = save, + stack = { top = function() return nil end, push = function() end } } +local battle = BattleState.newWild(game, "FIXMON_C", 40) +battle.rng = mkseq({ 0, 255, 255 }) -- hit, no crit, max damage roll + +local startHP = battle.enemy.mon.hp +battle:performMove(battle.player, battle.enemy, { id = "FIX_MULTI", pp = 10 }) + +local drains = {} +for _, row in ipairs(battle.queue) do + if row.drain then drains[#drains + 1] = row end +end +T.eq(#drains, HITS, "a drain row per strike") + +local perHit = (startHP - battle.enemy.mon.hp) / HITS +T.check(perHit > 1, "the strikes take more than a pixel of bar each") +for h, row in ipairs(drains) do + T.eq(row.battler, battle.enemy, "drain row " .. h .. " names the target") + T.eq(row.stopAt, startHP - perHit * h, + "drain row " .. h .. " stops at the HP left after strike " .. h) +end + +-- replay the rows the way updateQueue consumes them: the row's stopAt +-- becomes the battler's drainFloor, the drain runs to a stop, the floor is +-- cleared. Before the fix every row read the live post-last-hit HP, so +-- row 1 emptied the whole total and rows 2..n moved nothing. +local settled = {} +for _, row in ipairs(drains) do + local before = battle.enemy.shownHP + battle.enemy.drainFloor = row.stopAt + local frames = 0 + while battle:stepHPDrain() and frames < 4000 do frames = frames + 1 end + battle.enemy.drainFloor = nil + T.check(battle.enemy.shownHP < before, "the bar moved on this strike") + settled[#settled + 1] = battle.enemy.shownHP +end +for h, hp in ipairs(settled) do + T.eq(hp, startHP - perHit * h, "the bar rests on strike " .. h .. "'s HP") +end +T.eq(settled[#settled], battle.enemy.mon.hp, + "the last strike leaves the bar on the true HP") + +-- a single-hit drain still runs to the live HP with no stop pinned +local single = BattleState.newWild(game, "FIXMON_C", 40) +single.rng = mkseq({ 0, 255, 255 }) +single:performMove(single.player, single.enemy, { id = "FIX_TACKLE", pp = 35 }) +local rows = 0 +for _, row in ipairs(single.queue) do + if row.drain then + rows = rows + 1 + T.eq(row.stopAt, single.enemy.mon.hp, "the single drain stops at the new HP") + end +end +T.eq(rows, 1, "one strike queues one drain") +local frames = 0 +single.enemy.drainFloor = single.enemy.mon.hp +while single:stepHPDrain() and frames < 4000 do frames = frames + 1 end +T.eq(single.enemy.shownHP, single.enemy.mon.hp, "the single drain settles") + +Data.moves.FIX_MULTI = nil +T.finish("multihit hp drain") diff --git a/tests/engine/quit_thread_shutdown.lua b/tests/engine/quit_thread_shutdown.lua new file mode 100644 index 00000000..a0dff35b --- /dev/null +++ b/tests/engine/quit_thread_shutdown.lua @@ -0,0 +1,209 @@ +-- Quitting has to end the process (#339). Both background love.thread +-- workers idle in loops that only a { cmd = "quit" } command breaks +-- (src/core/chip_worker.lua, src/update/check_worker.lua) and LOVE joins +-- every live thread before the process exits, so with no shutdown the window +-- closed while the process kept spinning: on Android the relaunched task +-- re-entered an activity whose native main had already returned. Purely a +-- port lifecycle concern, so no pokered citation. +-- ROM-free: a ChipAsm blob plus a fake love.thread. +-- luajit tests/engine/quit_thread_shutdown.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") + +-- ------- audio + thread stubs + +local Source = {} +Source.__index = Source +function Source:play() self.playing = true end +function Source:stop() self.playing = false end +function Source:isPlaying() return self.playing end +function Source:getFreeBufferCount() return self.free end +function Source:queue() self.free = math.max(0, self.free - 1) end + +local ChipSynth = require("src.core.ChipSynth") + +love.audio = { + newQueueableSource = function() + return setmetatable({ free = ChipSynth.MUSIC_BUFFER_COUNT }, Source) + end, +} + +local channels, threads = {}, {} + +local Channel = {} +Channel.__index = Channel +function Channel:push(msg) self.log[#self.log + 1] = msg end +function Channel:pop() return table.remove(self.log, 1) end +function Channel:clear() self.log = {} end +function Channel:getCount() return #self.log end + +-- the command channels are read back after shutdown, so keep every command +-- pushed on them rather than letting pop/clear consume the history +local Recorder = setmetatable({}, { __index = Channel }) +Recorder.__index = Recorder +function Recorder:pop() return nil end +function Recorder:clear() end + +local function channel(name) + if not channels[name] then + local mt = name:find("cmd", 1, true) and Recorder or Channel + channels[name] = setmetatable({ log = {} }, mt) + end + return channels[name] +end + +local function commands(name) + local out = {} + for _, msg in ipairs(channel(name).log) do + out[#out + 1] = type(msg) == "table" and msg.cmd or tostring(msg) + end + return out +end + +local function counted(list, want) + local n = 0 + for _, cmd in ipairs(list) do + if cmd == want then n = n + 1 end + end + return n +end + +love.thread = { + newThread = function(path) + local th = { path = path, waited = 0, started = false } + th.start = function() th.started = true end + th.getError = function() return nil end + th.wait = function() th.waited = th.waited + 1 end + threads[#threads + 1] = th + return th + end, + getChannel = channel, +} + +local function threadFor(path) + for _, th in ipairs(threads) do + if th.path == path then return th end + end + return nil +end + +-- ------- chip audio worker + +local ChipAsm = require("src.audio.ChipAsm") + +local song = ChipAsm.song{ + channels = { { hw = 1, program = { + { notetype = { speed = 12, volume = 12, fade = 0 } }, + { octave = 4 }, + { note = "C", len = 8 }, + { loop = { count = 0, to = 1 } }, + } } }, +} +local data = { audio = { songs = { Music_PalletTown = song } } } + +-- absent before the fix; called through this so the rest of the report still +-- runs instead of erroring out on the first missing entry point +local function shutdown(mod) + if type(mod.shutdown) == "function" then mod.shutdown() end +end + +local ChipAudio = require("src.core.ChipAudio") +check(type(ChipAudio.shutdown) == "function", "ChipAudio exposes shutdown") + +check(ChipAudio.playMusic(data, song, true) ~= nil, + "threaded playMusic starts the chip worker") +local chipThread = threadFor("src/core/chip_worker.lua") +check(chipThread ~= nil and chipThread.started, "the chip worker is running") +eq(counted(commands("chipaudio_cmd"), "quit"), 0, + "nothing tells the chip worker to quit during play") + +shutdown(ChipAudio) +local chipCmds = commands("chipaudio_cmd") +eq(chipCmds[#chipCmds], "quit", "shutdown pushes the chip worker's quit command") +eq(chipThread.waited, 1, "shutdown joins the chip worker instead of leaving it live") + +-- a second call must not push onto a channel whose worker is already gone, +-- and neither must the normal playback calls that survive teardown +shutdown(ChipAudio) +ChipAudio.stopMusic() +ChipAudio.invalidate() +eq(counted(commands("chipaudio_cmd"), "quit"), 1, + "shutdown is idempotent and post-shutdown calls stay quiet") +eq(chipThread.waited, 1, "the joined worker is not waited on twice") + +-- ------- update check worker + +local Check = require("src.update.Check") +check(type(Check.shutdown) == "function", "Check exposes shutdown") + +Check.start() +local checkThread = threadFor("src/update/check_worker.lua") +check(checkThread ~= nil and checkThread.started, "the update worker is running") +eq(commands("update_check_cmd")[1], "check", "start pushes the check command") + +shutdown(Check) +local upCmds = commands("update_check_cmd") +eq(upCmds[#upCmds], "quit", "shutdown pushes the update worker's quit command") +eq(checkThread.waited, 1, "shutdown joins the update worker") + +shutdown(Check) +Check.download() +eq(counted(commands("update_check_cmd"), "quit"), 1, + "shutdown is idempotent and post-shutdown calls stay quiet") +eq(Check.state().status ~= nil, true, "state() still answers after shutdown") + +-- ------- the worker loops still break on that command + +local function source(path) + local f = io.open(path, "rb") + check(f ~= nil, path .. " is readable") + local text = f and f:read("*a") or "" + if f then f:close() end + return text +end + +local chipSrc = source("src/core/chip_worker.lua") +check(chipSrc:match('cmd%.cmd == "quit"%s*then%s*\n%s*return true') ~= nil, + "chip_worker leaves its command loop on quit") +local checkSrc = source("src/update/check_worker.lua") +check(checkSrc:match('cmd%.cmd == "quit"%s*then%s*\n%s*break') ~= nil, + "check_worker leaves its demand loop on quit") + +-- ------- main.lua wiring +-- love.quit is the established teardown hook (DiscordPresence already rides +-- it) and reaches the modules through package.loaded, so a session that never +-- touched audio or the launcher pays nothing. + +local mainSrc = source("main.lua") +local quitHook = mainSrc:match("\nfunction love%.quit%(%).-\nend\n") +check(quitHook ~= nil, "love.quit is still a single top-level function") +quitHook = quitHook or "" +check(quitHook:find('package.loaded["src.core.ChipAudio"].shutdown', 1, true) ~= nil, + "love.quit shuts the chip worker down") +check(quitHook:find('package.loaded["src.update.Check"].shutdown', 1, true) ~= nil, + "love.quit shuts the update worker down") + +-- The Android half: LOVE keeps the JVM process after the native main returns, +-- so the quit event exits the process outright. It has to sit after the +-- love.quit() veto test, or the editor's abort-quit path would die on a quit +-- it refused. +local quitBranch = mainSrc:match('if name == "quit" then(.-)\n%s*end\n') +check(quitBranch ~= nil, "love.run still handles the quit event") +quitBranch = quitBranch or "" +local vetoAt = quitBranch:find("not love%.quit()") +local osAt = quitBranch:find("os%.exit") +local androidAt = quitBranch:find('getOS() == "Android"', 1, true) +check(vetoAt ~= nil and osAt ~= nil and vetoAt < osAt, + "the process exit runs only after love.quit declined to veto") +check(androidAt ~= nil and androidAt < osAt, + "the process exit is gated on Android") +local exits = 0 +for _ in mainSrc:gmatch("os%.exit") do exits = exits + 1 end +eq(exits, 1, "os.exit appears once, at the quit event") + +T.finish("quit thread shutdown") diff --git a/tests/engine/save_convert_extra_flags.lua b/tests/engine/save_convert_extra_flags.lua new file mode 100644 index 00000000..a5eb3b35 --- /dev/null +++ b/tests/engine/save_convert_extra_flags.lua @@ -0,0 +1,199 @@ +-- Gen1 save codec (src/save_convert/GenSave.lua) for the save.flags names whose +-- vanilla home is NOT wEventFlags: exporting a slot and importing it back used +-- to drop them, so the Saffron gate guards were thirsty again (#396). Offsets +-- are re-derived here byte by byte from ram/wram.asm rather than read out of +-- GenSave.OFFSETS, and the bit numbers come from constants/ram_constants.asm +-- (BIT_GOT_OLD_ROD 3, BIT_GAVE_SAFFRON_GUARDS_DRINK 6, BIT_GOT_LAPRAS 0, +-- BIT_STARTED_ELITE_4 1). The trade bits are wWhichTrade, which +-- engine/events/in_game_trades.asm uses to index wCompletedInGameTradeFlags, +-- so they are checked against the 1-based rows the port's scripts pass. +-- luajit tests/engine/save_convert_extra_flags.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 bit = require("bit") +local GenSave = require("src.save_convert.GenSave") +local SaveData = require("src.core.SaveData") + +-- the codec crosswalks need the real dataset; CI has no ROM +local loadPokemon = loadfile("data/generated/pokemon.lua") +if not loadPokemon then + print("save_convert_extra_flags skipped (needs data/generated/ for the Gen1 save codec)") + os.exit(0) +end + +GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")()) +local events = loadfile("src/save_convert/data/event_flags.lua")() +local data = { + pokemon = loadPokemon(), + moves = loadfile("data/generated/moves.lua")(), + items = loadfile("data/generated/items.lua")(), + maps = loadfile("data/generated/maps.lua")(), + eventFlags = events, +} + +-- ------------------------------------------------------------------ +-- offsets, walked forward from wTownVisitedFlag over ram/wram.asm's own +-- declaration run: 2 wTownVisitedFlag, 2 wSafariSteps, wFossilItem, +-- wFossilMon, ds 2, wEnemyMonOrTrainerClass, wPlayerJumpingYScreenCoordsIndex, +-- wRivalStarter, ds 1, wPlayerStarter, wBoulderSpriteIndex, wLastBlackoutMap, +-- wDestinationMap, wUnusedPlayerDataByte, wTileInFrontOfBoulder..., +-- wDungeonWarpDestinationMap, wWhichDungeonWarp, wUnusedCardKeyGateID, ds 8 +-- ------------------------------------------------------------------ + +local OFF = GenSave.OFFSETS +local TOWN_VISITED = OFF.townVisited +local WRAM = { + statusFlags1 = TOWN_VISITED + 29, + statusFlags4 = TOWN_VISITED + 35, -- +30 ds 1, wBeatGymFlags, ds 1, 2/3 + elite4Flags = TOWN_VISITED + 41, -- +36 ds 1, 5, ds 1, 6, 7 + tradeFlags = TOWN_VISITED + 44, -- +42 ds 1, wMovementFlags + eventFlags = TOWN_VISITED + 60, -- the run's own end, pinned already +} + +eq(WRAM.eventFlags, OFF.eventFlags, + "the wram walk lands on wEventFlags where the codec already pins it") +eq(OFF.statusFlags1, WRAM.statusFlags1, "wStatusFlags1 is wTownVisitedFlag + 29") +eq(OFF.statusFlags4, WRAM.statusFlags4, "wStatusFlags4 is wTownVisitedFlag + 35") +eq(OFF.elite4Flags, WRAM.elite4Flags, "wElite4Flags is wTownVisitedFlag + 41") +eq(OFF.tradeFlags, WRAM.tradeFlags, "wCompletedInGameTradeFlags is wTownVisitedFlag + 44") + +-- independent flag_array read (byte = index / 8, bit = index % 8), so nothing +-- below trusts the writer it is checking +local function flagGet(bytes, base, index) + local byte = bytes:byte(base + math.floor(index / 8) + 1) + return bit.band(bit.rshift(byte, index % 8), 1) == 1 +end + +-- ------------------------------------------------------------------ +-- the names under test, and the proof they cannot ride wEventFlags +-- ------------------------------------------------------------------ + +local EXTRA = { + { "EVENT_GOT_OLD_ROD", "statusFlags1", 3 }, + { "EVENT_GOT_GOOD_ROD", "statusFlags1", 4 }, + { "EVENT_GOT_SUPER_ROD", "statusFlags1", 5 }, + { "EVENT_GAVE_GUARDS_DRINK", "statusFlags1", 6 }, + { "EVENT_GOT_LAPRAS", "statusFlags4", 0 }, + { "EVENT_STARTED_ELITE_4", "elite4Flags", 1 }, +} + +local named = 0 +for _ in pairs(events.byName) do named = named + 1 end +eq(named, 507, "event_flags.lua carries every EVENT_* constant and no more") +for _, row in ipairs(EXTRA) do + eq(events.byName[row[1]], nil, row[1] .. " has no wEventFlags bit to ride") +end + +-- ------------------------------------------------------------------ +-- trade rows as the port's scripts actually pass them: `{ "trade", N, FLAG }` +-- with N the 1-based data/events/trades.asm row, so the bit is N - 1 +-- ------------------------------------------------------------------ + +local TRADE_ROWS = {} +local scripts = io.popen("ls data/scripts/*.lua") +for path in scripts:lines() do + local f = io.open(path, "r") + local src = f:read("*a") + f:close() + for index, flag in src:gmatch('"trade",%s*(%d+),%s*"(EVENT_[A-Z0-9_]+)"') do + TRADE_ROWS[#TRADE_ROWS + 1] = { flag = flag, index = tonumber(index) } + end +end +scripts:close() +check(#TRADE_ROWS == 7, "the nine-row trade table's seven reachable trades are scripted") + +-- ------------------------------------------------------------------ +-- round trips +-- ------------------------------------------------------------------ + +local function seedSave() + local save = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" }) + save.party = { { + species = "SQUIRTLE", level = 6, exp = 200, + dvs = { hp = 1, attack = 2, defense = 3, speed = 4, special = 5 }, + statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 }, + stats = { hp = 22, attack = 12, defense = 13, speed = 11, special = 12 }, + hp = 22, + moves = { { id = "TACKLE", pp = 35, ppUps = 0 } }, + nickname = "SQ", ot = "RED", otId = save.player.id, catchRate = 45, + } } + return save +end + +local set = seedSave() +for _, row in ipairs(EXTRA) do set.flags[row[1]] = true end +for _, row in ipairs(TRADE_ROWS) do set.flags[row.flag] = true end +set.flags.EVENT_RECEIVED_BIKE_VOUCHER = true + +local setBytes = GenSave.encode(set, data, nil) +eq(#setBytes, GenSave.SAVE_SIZE, "the export is a 32768-byte save") + +for _, row in ipairs(EXTRA) do + check(flagGet(setBytes, WRAM[row[2]], row[3]), + row[1] .. " reaches the .sav as its " .. row[2] .. " bit " .. row[3]) +end +for _, row in ipairs(TRADE_ROWS) do + check(flagGet(setBytes, WRAM.tradeFlags, row.index - 1), + row.flag .. " reaches wCompletedInGameTradeFlags bit " .. (row.index - 1)) +end +-- the port spells bit 337 EVENT_RECEIVED_BIKE_VOUCHER; vanilla calls it +-- EVENT_GOT_BIKE_VOUCHER (constants/event_constants.asm) +eq(events.byName.EVENT_GOT_BIKE_VOUCHER, 337, "the bike voucher is event bit 337") +check(flagGet(setBytes, WRAM.eventFlags, 337), + "EVENT_RECEIVED_BIKE_VOUCHER reaches the real bike voucher event bit") + +-- no spill into the neighbouring bits of a shared byte +eq(bit.band(setBytes:byte(WRAM.statusFlags1 + 1), 0x87), 0, + "wStatusFlags1 keeps its other bits (0-2, 7) clear") +eq(bit.band(setBytes:byte(WRAM.tradeFlags + 1), 0x04), 0, + "the unused CHIKUCHIKU trade bit stays clear") + +local back = GenSave.decode(setBytes, data) +eq(#(back.warnings or {}), 0, "the export decodes with no warnings") +local reflags = back.flags +for _, row in ipairs(EXTRA) do + eq(reflags[row[1]], true, row[1] .. " survives export -> import") +end +for _, row in ipairs(TRADE_ROWS) do + eq(reflags[row.flag], true, row.flag .. " survives export -> import") +end +eq(reflags.EVENT_RECEIVED_BIKE_VOUCHER, true, + "EVENT_RECEIVED_BIKE_VOUCHER comes back under the port's own spelling") +eq(reflags.EVENT_GOT_BIKE_VOUCHER, true, "and under the vanilla spelling too") + +-- ------------------------------------------------------------------ +-- the port's save is the only authority for these bits, so a save that does +-- NOT hold one must clear it out of the template rather than inherit it +-- ------------------------------------------------------------------ + +local clear = seedSave() +local clearBytes = GenSave.encode(clear, data, setBytes) +for _, row in ipairs(EXTRA) do + check(not flagGet(clearBytes, WRAM[row[2]], row[3]), + row[1] .. " is cleared, not inherited from the template") +end +for _, row in ipairs(TRADE_ROWS) do + check(not flagGet(clearBytes, WRAM.tradeFlags, row.index - 1), + row.flag .. " is cleared, not inherited from the template") +end +local reclear = GenSave.decode(clearBytes, data) +eq(reclear.flags.EVENT_GAVE_GUARDS_DRINK, nil, + "a save that never watered the guards imports back thirsty") + +-- ------------------------------------------------------------------ +-- cross-file pin: the four Saffron gates read this exact spelling +-- (data/scripts/story2.lua, pokered scripts/Route5Gate.asm and its twins) +-- ------------------------------------------------------------------ + +local sf = io.open("data/scripts/story2.lua", "r") +local story2 = sf:read("*a") +sf:close() +check(story2:find("flags.EVENT_GAVE_GUARDS_DRINK", 1, true) ~= nil, + "the gate scripts still spell the drink flag EVENT_GAVE_GUARDS_DRINK") + +T.finish("save_convert_extra_flags") diff --git a/tests/engine/title_zone_seams.lua b/tests/engine/title_zone_seams.lua new file mode 100644 index 00000000..da454142 --- /dev/null +++ b/tests/engine/title_zone_seams.lua @@ -0,0 +1,222 @@ +-- #373: the two faint horizontal lines across the title screen. Both +-- halves are asserted here: Renderer's zone scissor must hand LOVE rects +-- whose framebuffer pixels stay contiguous across a zone boundary at +-- Android's non-integer DPI, and TitleState's three title zones must share +-- color 0 the way the hardware SuperPals do (pokered data/sgb/ +-- sgb_palettes.asm: PAL_LOGO1 / PAL_LOGO2 / PAL_MEWMON all start RGB +-- 31,29,31). +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local check, eq, same = T.check, T.eq, T.same + +-- ---------------------------------------------------------------- scissor +-- scissorClamped is a local in Renderer.lua and the only caller is inside +-- endFrame's blit closure, which needs canvases and a compiled shader. The +-- source is loaded directly so the rect arithmetic can be exercised with no +-- GPU, the same way parity_picker_pointer_grab reads RomImporter (#254). +local scissorClamped, captured +do + local f = io.open("src/render/Renderer.lua", "rb") + check(f ~= nil, "Renderer source is readable") + local src = f and f:read("*a") or "" + if f then f:close() end + local body = src:match("\nlocal function scissorClamped.-\nend\n") + check(body ~= nil, "scissorClamped is still a single local function") + local fakeLove = { graphics = { setScissor = function(x, y, w, h) + captured = { x = x, y = y, w = w, h = h } + end } } + local chunk = assert(loadstring("local love = ...\n" .. (body or "") + .. "\nreturn scissorClamped")) + scissorClamped = chunk(fakeLove) + check(type(scissorClamped) == "function", "scissorClamped loads standalone") +end + +-- The title's three SGB zones in canvas pixels (PaletteFX.zone turns the +-- ATTR_BLK tile rects rows 0-7 / 8-9 / 10-17 into these). +local ZONES = { + { x = 0, y = 0, w = 160, h = 64 }, + { x = 0, y = 64, w = 160, h = 16 }, + { x = 0, y = 80, w = 160, h = 64 }, +} + +-- Mirrors Renderer:endFrame's letterbox geometry and its UI blit call +-- (blit(canvas, Sx, Sy, zones, Sx, Sy, ox, oy, ox, oy, vpw, vph)). +local function viewport(pw, ph, dpiX, dpiY) + local uiw, uih = 160, 144 + local Sp = math.max(1, math.floor(math.min(pw / uiw, ph / uih))) + return { + Sp = Sp, Sx = Sp / dpiX, Sy = Sp / dpiY, + ox = math.floor((pw - uiw * Sp) / 2) / dpiX, + oy = math.floor((ph - uih * Sp) / 2) / dpiY, + vpw = uiw * (Sp / dpiX), vph = uih * (Sp / dpiY), + } +end + +local function zoneRects(v, dpiX, dpiY) + local out = {} + for i, z in ipairs(ZONES) do + captured = nil + local drew = scissorClamped(v.ox + z.x * v.Sx, v.oy + z.y * v.Sy, + z.w * v.Sx, z.h * v.Sy, + v.ox, v.oy, v.vpw, v.vph, dpiX, dpiY) + out[i] = drew and captured or nil + end + return out +end + +-- What the pre-fix code emitted: the clamp alone, fractional edges and all. +local function clampOnly(v) + local out = {} + for i, z in ipairs(ZONES) do + local x, y = v.ox + z.x * v.Sx, v.oy + z.y * v.Sy + local x2 = math.min(x + z.w * v.Sx, v.ox + v.vpw) + local y2 = math.min(y + z.h * v.Sy, v.oy + v.vph) + x, y = math.max(x, v.ox), math.max(y, v.oy) + out[i] = { x = x, y = y, w = x2 - x, h = y2 - y } + end + return out +end + +-- love.graphics.setScissor scales a rect by the DPI scalar and truncates +-- x, y, w and h to whole framebuffer pixels independently, so a rect's +-- covered rows are [floor(y*d), floor(y*d) + floor(h*d)). +local function rowSpan(r, d) + local top = math.floor(r.y * d) + return top, top + math.floor(r.h * d) +end +local function colSpan(r, d) + local left = math.floor(r.x * d) + return left, left + math.floor(r.w * d) +end + +local function seams(rects, d) + local n = 0 + for k = 1, #rects - 1 do + local _, bottom = rowSpan(rects[k], d) + local top = rowSpan(rects[k + 1], d) + if bottom < top then n = n + 1 end + end + return n +end + +-- Desktop: dpi 1, integer origin and scale. Every edge is already whole, so +-- the rounding must land on exactly the pixels the plain clamp did (that path +-- was never broken) and neighbours must abut with no overlap at all. +do + local v = viewport(1920, 1080, 1, 1) + local fixed, plain = zoneRects(v, 1, 1), clampOnly(v) + for i = 1, #ZONES do + local top, bottom = rowSpan(fixed[i], 1) + local ptop, pbottom = rowSpan(plain[i], 1) + eq(top, ptop, "desktop zone " .. i .. " starts on the same row") + eq(bottom, pbottom, "desktop zone " .. i .. " ends on the same row") + end + for k = 1, #fixed - 1 do + local _, bottom = rowSpan(fixed[k], 1) + eq(bottom, rowSpan(fixed[k + 1], 1), + "desktop zone " .. k .. " abuts its neighbour exactly") + end +end + +-- The reporter's 1920x1080 Android screen, plus DPI values around it. d is +-- the scalar LOVE applies; it equals the axis ratio on a square-DPI surface +-- and can differ when the window's two ratios do not match. +local CASES = { + { dpiX = 2.625, dpiY = 2.625 }, + { dpiX = 2.75, dpiY = 2.75 }, + { dpiX = 3.5, dpiY = 3.5 }, + { dpiX = 2.0625, dpiY = 2.0625 }, + { dpiX = 2.625, dpiY = 2.6, d = 2.625 }, + { dpiX = 2.75, dpiY = 2.8, d = 2.75 }, +} + +local brokenCases = 0 +for _, c in ipairs(CASES) do + local tag = ("dpi %s/%s"):format(c.dpiX, c.dpiY) + local v = viewport(1920, 1080, c.dpiX, c.dpiY) + local dy = c.d or c.dpiY + local dx = c.d or c.dpiX + local rects = zoneRects(v, c.dpiX, c.dpiY) + eq(#rects, 3, tag .. ": all three title zones draw") + eq(seams(rects, dy), 0, tag .. ": no black row between adjacent zones") + if seams(clampOnly(v), dy) > 0 then brokenCases = brokenCases + 1 end + + for k = 1, #rects - 1 do + local _, bottom = rowSpan(rects[k], dy) + local top = rowSpan(rects[k + 1], dy) + check(bottom - top <= 1, + tag .. ": zone " .. k .. " overlaps its neighbour by at most one row") + end + + -- The same truncation was shaving the bottom copyright row and the right + -- edge of the picture, so the outer edges have to reach the viewport too. + local firstTop = rowSpan(rects[1], dy) + local _, lastBottom = rowSpan(rects[#rects], dy) + check(firstTop <= math.floor(v.oy * dy), tag .. ": top edge is covered") + check(lastBottom >= math.floor((v.oy + v.vph) * dy), + tag .. ": the bottom copyright row is covered") + local left, right = colSpan(rects[#rects], dx) + check(left <= math.floor(v.ox * dx), tag .. ": left edge is covered") + check(right >= math.floor((v.ox + v.vpw) * dx), + tag .. ": the right edge of the picture is covered") +end +check(brokenCases > 0, + "the plain clamped rects still gap on a fractional-DPI surface, which is" + .. " the seam the rounding removes") + +-- ---------------------------------------------------------------- palettes +local PaletteFX = require("src.render.PaletteFX") +local GameVersion = require("src.core.GameVersion") +local TitleState = require("src.ui.TitleState") + +local OFF_WHITE = { 255, 239, 255 } +local function romPack(logo1) + return { palettes = { palettes = { + LOGO1 = logo1, + LOGO2 = { OFF_WHITE, { 247, 247, 140 }, { 148, 156, 148 }, { 57, 57, 132 } }, + MEWMON = { OFF_WHITE, { 247, 181, 140 }, { 132, 115, 156 }, { 24, 16, 16 } }, + } } } +end +local RED_LOGO1 = { OFF_WHITE, { 247, 247, 140 }, { 140, 189, 82 }, { 173, 0, 33 } } +local BLUE_LOGO1 = { OFF_WHITE, { 247, 247, 140 }, { 173, 0, 33 }, { 115, 156, 239 } } + +local savedMode, savedVersion = PaletteFX.mode, GameVersion.get() +local title = setmetatable({}, { __index = TitleState }) + +-- SGB (the default): the ribbon band's white is the neighbouring zones' +-- white, so the two boundaries at y=64 and y=80 draw no brighter strip. +do + PaletteFX.mode = "gbc" + GameVersion.set("red") + local z = title:sgbPalettes({ data = romPack(RED_LOGO1) }) + eq(z and #z, 3, "the title builds three SGB zones") + eq(z[1].y, 0, "logo zone starts at row 0") + eq(z[2].y, 64, "the version ribbon starts at tile row 8") + eq(z[3].y, 80, "the player/mon zone starts at tile row 10") + eq(z[1].y + z[1].h, z[2].y, "logo and ribbon share a boundary") + eq(z[2].y + z[2].h, z[3].y, "ribbon and mon zone share a boundary") + same(z[2].colors[1], z[1].colors[1], "the ribbon band's white is the logo's") + same(z[3].colors[1], z[1].colors[1], "the mon zone's white matches too") + same(z[2].colors[4], RED_LOGO1[4], "Red's \"Version\" ink survives") +end + +-- RED++ (#128): LOGO2/MEWMON come from the GBC pack, Blue's LOGO1 from the +-- ROM pack. Taking LOGO2's white must still whiten the ribbon there and +-- must not touch the blue ink. +do + PaletteFX.mode = "redpp" + GameVersion.set("blue") + local z = title:sgbPalettes({ data = romPack(BLUE_LOGO1) }) + eq(z and #z, 3, "RED++ builds three title zones") + same(z[1].colors[1], { 255, 255, 255 }, "the GBC logo pal is pure white") + same(z[2].colors[1], { 255, 255, 255 }, "the ribbon band is pure white") + same(z[3].colors[1], { 255, 255, 255 }, "the mon zone is pure white") + same(z[2].colors[4], BLUE_LOGO1[4], "Blue's \"Version\" ink stays blue") + same(z[2].colors[3], BLUE_LOGO1[3], "Blue LOGO1's other inks stay put") +end + +PaletteFX.mode = savedMode +GameVersion.set(savedVersion) + +T.finish("title zone seams") diff --git a/tests/mod_audio_tests.lua b/tests/mod_audio_tests.lua index 31da1708..aa88f952 100644 --- a/tests/mod_audio_tests.lua +++ b/tests/mod_audio_tests.lua @@ -402,7 +402,7 @@ check(ChipAudio.awaitingFirstBuffer(), clearAwait() -- sfx shape dispatch -check(Sound.play(data, "Beep") == nil, "Sound.play returns nothing") +check(Sound.play(data, "Beep") ~= nil, "Sound.play returns the source it started") check(lastSource().file == "assets/beep.wav", "a bare string sfx is a static source") resetSources() Sound.play(data, "Chip_Sfx") diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 2e36e956..f1aad5fd 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -538,7 +538,7 @@ press(fpm, "a") check(not fpm.submenu and forced == fgame.save.party[1], "forceSwitch still picks immediately (ChooseNextMon / SHIFT)") --- ------- issue #320: the STRENGTH blink sits under its texts +-- ------- issues #320/#385: the STRENGTH texts print over the party menu do local owStub = { strengthActive = false, map = { def = { tileset = "OVERWORLD" } }, dark = false } @@ -556,9 +556,8 @@ do pm.subIndex = #pm.subItems press(pm, "a") -- run STRENGTH local states = sgame.stack.states - check(#states == 2 and states[1].frames ~= nil - and states[2].pages ~= nil, - "the blink sits under the strength texts") + check(#states == 2 and states[1] == pm and states[2].pages ~= nil, + "the strength text sits over the still-open party menu") check(owStub.strengthActive == true, "strength still activates") end diff --git a/tests/mod_world_tests.lua b/tests/mod_world_tests.lua index 11114556..66d5ca7c 100644 --- a/tests/mod_world_tests.lua +++ b/tests/mod_world_tests.lua @@ -628,14 +628,19 @@ do local ow = setmetatable({ player = { facing = "down" } }, { __index = OW }) ow.stepForwardOrCrossEdge = function(_, dir) ow.stepped = dir end - ow:trySurf(10, 10) - local bottom, top = stack.states[1], stack.states[2] - check(bottom and bottom.frames ~= nil and top and top.pages ~= nil, - "the blink sits under the surf text on the stack") + local closed = false + ow:trySurf(10, 10, function() closed = true end) + local top = stack.states[1] + check(#stack.states == 1 and top and top.pages ~= nil, + "the surf text prints alone, over the party menu (#385)") check(not ow.player.surfing, "no surfing sprite while the text is up") top.onDone() - check(ow.player.surfing == true, "surfing applies with the step") + check(closed, "the party menu closes when the text ends") + check(ow.player.surfing == true, "surfing applies with the blink") + local blink = stack.states[#stack.states] + check(blink and blink.frames ~= nil, "the blink follows the text") + blink.onDone() check(ow.stepped == "down", "the step onto the water fires") end diff --git a/tests/parity_C.lua b/tests/parity_C.lua index 30bd90d9..e0e4fd58 100644 --- a/tests/parity_C.lua +++ b/tests/parity_C.lua @@ -26,11 +26,9 @@ local Sound = require("src.core.Sound") local Map = require("src.world.Map") local Warp = require("src.world.Warp") --- synchronous scriptMove/takeWarp for the walk-out: elevatorWalkOut --- (data/scripts/story3.lua) rewrites the car's exit warps then walks the --- player out through the doorway and takes the rewritten warp, instead of --- the old jump-cut startWarpTo. -local DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } +-- synchronous takeWarp: after the ride the car's exit warps are rewritten +-- and the player walks out onto one under their own control, so the test +-- takes that warp itself instead of expecting a scripted walk-out. -- the Rocket Hideout keyGate path pushes a TextBox, which needs the font -- loaded (like tests/run_tests.lua does before any TextBox use) @@ -71,10 +69,19 @@ local function restoreWarps(mapId, snap) end end --- drives one elevator map's onEnter and returns the pushed state (either a --- ListMenu or, for the keyGated Rocket Hideout without the key, a TextBox). --- fromMapId is the floor the player entered from (OverworldState:setMap --- passes it), used to seed a cancel-safe walk-out destination. +-- the car's panel bg_event (data/maps/objects/CeladonMartElevator.asm +-- etc.): the TEXT_ constant whose talk script opens the floor menu +local PANEL_TEXT = { + SILPH_CO_ELEVATOR = "TEXT_SILPHCOELEVATOR_ELEVATOR", + CELADON_MART_ELEVATOR = "TEXT_CELADONMARTELEVATOR", + ROCKET_HIDEOUT_ELEVATOR = "TEXT_ROCKETHIDEOUTELEVATOR", +} + +-- drives one elevator map's onEnter and then its panel talk script, and +-- returns the pushed state (either a ListMenu or, for the keyGated Rocket +-- Hideout without the key, a TextBox). fromMapId is the floor the player +-- entered from (OverworldState:setMap passes it), used to seed a +-- cancel-safe exit. local function openElevator(mapId, inventory, fromMapId) local script = mapScripts.get(mapId) check(script ~= nil, mapId .. " script registered") @@ -84,26 +91,14 @@ local function openElevator(mapId, inventory, fromMapId) local ow = {} -- the real elevator car map (built without the tile renderer -- Map.new -- is pure data), plus the player standing on the exit tile they warped - -- in onto (the true arrival cell), so the post-ride walk-out has a - -- door and geometry to work with + -- in onto (the true arrival cell) local carDef = Data.maps[mapId] ow.map = Map.new(carDef, Data.tilesets[carDef.tileset]) local firstWarp = carDef.warps[1] ow.player = { cellX = firstWarp.x, cellY = firstWarp.y, facing = "up" } - ow.scriptMoves = {} - ow.walkSteps = {} function ow:startWarpTo(map, x, y, facing) warpCalls[#warpCalls + 1] = { map = map, x = x, y = y, facing = facing } end - -- synchronous: advance the entity one step per tile and fire onDone - function ow:scriptMove(entity, dir, tiles, onDone) - local d = DIRVEC[dir] - entity.cellX = entity.cellX + d[1] * tiles - entity.cellY = entity.cellY + d[2] * tiles - entity.facing = dir - self.walkSteps[#self.walkSteps + 1] = dir - if onDone then onDone() end - end -- resolve the (rewritten) warp entry like OverworldState:takeWarp does function ow:takeWarp(warpDef) local destMap, x, y = Warp.destination(Data, warpDef, self.lastOutdoor) @@ -117,6 +112,12 @@ local function openElevator(mapId, inventory, fromMapId) } sfxCalls = {} script.onEnter(game, ow, fromMapId) + -- #395: entry only seeds the car's exit warps; the floor menu belongs + -- to the panel bg_event, so nothing may be pushed yet + eq(#items, 0, mapId .. " onEnter opens no menu") + local panel = script.talk and script.talk[PANEL_TEXT[mapId]] + check(panel ~= nil, mapId .. " panel bg_event has a talk script") + if panel then panel(game, ow, nil, function() end) end return items[#items], warpCalls, stack, ow end @@ -202,7 +203,6 @@ do -- cycle, then SFX_SAFARI_ZONE_PA, and only then the floor warp. clear(warpCalls) sfxCalls = {} - ow.walkSteps = {} local chosen = menu.items[5] -- "5F" menu.onChoose(chosen, menu) eq(#warpCalls, 0, "choosing a floor does not warp on the spot (the shake runs first)") @@ -222,8 +222,9 @@ do -- floor, then the player walks out onto that warp (no jump cut) eq(ow.map.def.warps[1].destMap, chosen.value.map, "the car's exit warp is rewritten to the chosen floor's map") - check(#ow.walkSteps >= 1, "the player walks out of the car (scriptMove, not a jump-cut)") - eq(#warpCalls, 1, "the rewritten warp fires exactly once, after the walk-out") + eq(#warpCalls, 0, "the ride never warps: the player walks out of the car themselves") + ow:takeWarp(ow.map.def.warps[1]) + eq(#warpCalls, 1, "walking out takes the rewritten warp") if warpCalls[1] then eq(warpCalls[1].map, chosen.value.map, "walk-out lands on the chosen floor's map") eq(warpCalls[1].x, chosen.value.x, "walk-out lands on the chosen floor's x") @@ -266,8 +267,9 @@ do eq(sfxCalls[#sfxCalls], "Safari_Zone_PA", "Celadon Mart ride ends on the PA chime") eq(ow.map.def.warps[1].destMap, chosen.value.map, "Celadon Mart car exit warp rewritten to the chosen floor") - check(#ow.walkSteps >= 1, "Celadon Mart player walks out (scriptMove, not a jump-cut)") - eq(#warpCalls, 1, "Celadon Mart rewritten warp fires once, after the walk-out") + eq(#warpCalls, 0, "Celadon Mart ride never warps on its own") + ow:takeWarp(ow.map.def.warps[1]) + eq(#warpCalls, 1, "Celadon Mart walking out takes the rewritten warp") if warpCalls[1] then eq(warpCalls[1].map, chosen.value.map, "Celadon Mart walk-out lands on the chosen floor map") eq(warpCalls[1].x, chosen.value.x, "Celadon Mart walk-out lands on the chosen floor x") @@ -326,8 +328,9 @@ do eq(sfxCalls[#sfxCalls], "Safari_Zone_PA", "Rocket Hideout ride ends on the PA chime") eq(ow.map.def.warps[1].destMap, chosen.value.map, "Rocket Hideout car exit warp rewritten to the chosen floor") - check(#ow.walkSteps >= 1, "Rocket Hideout player walks out (scriptMove, not a jump-cut)") - eq(#warpCalls, 1, "Rocket Hideout rewritten warp fires once, after the walk-out") + eq(#warpCalls, 0, "Rocket Hideout ride never warps on its own") + ow:takeWarp(ow.map.def.warps[1]) + eq(#warpCalls, 1, "Rocket Hideout walking out takes the rewritten warp") if warpCalls[1] then eq(warpCalls[1].map, chosen.value.map, "Rocket Hideout walk-out lands on the chosen floor map") eq(warpCalls[1].x, chosen.value.x, "Rocket Hideout walk-out lands on the chosen floor x") diff --git a/tests/parity_I_M.lua b/tests/parity_I_M.lua index 40e70635..980b37b0 100644 --- a/tests/parity_I_M.lua +++ b/tests/parity_I_M.lua @@ -119,10 +119,11 @@ clearCaptured() local pmStr = PartyMenu.new(Game) selectSubItem(pmStr, 3) eq(Game.overworld.strengthActive, true, "party-menu STRENGTH sets strengthActive") -check(not onStack(pmStr), "party menu closes after STRENGTH") +check(onStack(pmStr), "party menu stays under the STRENGTH texts (#385)") check(sawText("used") and sawText("STRENGTH"), "_UsedStrengthText shown") drainText() check(sawText("move boulders"), "_CanMoveBouldersText shown after it") +check(not onStack(pmStr), "party menu closes with the blink after the texts") -- now the same two bumps push the boulder (gate passes -> arm -> move) eq(ow:checkBoulderPush("right"), false, "first bump arms the push after activation") @@ -166,9 +167,10 @@ ow.player.facing = "down"; ow.player.surfing = false clearCaptured() local pmSurf = PartyMenu.new(Game) selectSubItem(pmSurf, 3) --- the blink sits under the got-on text (#320); dismissing the text is --- what mounts and steps -Game.stack:top().onDone() +-- the got-on text prints over the menu (#385); dismissing it closes the +-- menu and mounts, and the blink that follows carries the step +check(onStack(pmSurf), "party menu stays under the got-on text") +Game.stack:pop().onDone() -- a TextBox pops itself before firing onDone eq(ow.player.surfing, true, "SURF from the party menu sets player.surfing") check(not onStack(pmSurf), "party menu closes after a successful SURF") check(sawText("got on"), "_SurfingGotOnText shown on a successful SURF") @@ -375,9 +377,10 @@ clearCaptured() local pmNoOff = PartyMenu.new(Game) selectSubItem(pmNoOff, 3) check(sawText("There's no place\nto get off!"), "_SurfingNoPlaceToGetOffText verbatim") -check(not onStack(pmNoOff), "the menu closes after the message (result stays 1)") +check(onStack(pmNoOff), "the menu stays under the message (#385)") eq(ow.player.surfing, true, "still surfing after a blocked dismount") drainText() +check(not onStack(pmNoOff), "the menu closes after the message (result stays 1)") ow.player.surfing = false popToOW() @@ -501,7 +504,7 @@ clearCaptured() -- submenu order: STATS, SWITCH, FLY, CUT, STRENGTH, SURF (move order on mon) local pmFaintSurf = PartyMenu.new(Game) selectSubItem(pmFaintSurf, 6) -Game.stack:top().onDone() -- dismiss the text: mount + step (#320) +Game.stack:pop().onDone() -- dismiss the text: menu closes, mount (#320, #385) eq(ow.player.surfing, true, "fainted mon can SURF from the party menu") check(not onStack(pmFaintSurf), "party menu closes after fainted SURF") diff --git a/tests/parity_applying_attack_anim.lua b/tests/parity_applying_attack_anim.lua new file mode 100644 index 00000000..6b167580 --- /dev/null +++ b/tests/parity_applying_attack_anim.lua @@ -0,0 +1,219 @@ +-- Parity test: wAnimationType, the applying-attack animation pokered plays +-- after every move's own animation (#354). PlayApplyingAttackAnimation +-- (engine/battle/animations.asm:475) dispatches through +-- AnimationTypePointerTable (:490-497) on six types: 1 enemy damaging plain +-- (vertical shake b=8), 2 enemy damaging with an added effect (fast +-- horizontal shake b=8), 3 enemy non-damaging (slow creep b=6 c=2), 4 player +-- damaging plain (blink the enemy pic), 5 player damaging with an added +-- effect (fast horizontal shake b=2), 6 player non-damaging (slow creep b=3 +-- c=2). GetPlayerAnimationType / GetEnemyAnimationType (core.asm:3159 / +-- :5555) pick 4/5 and 1/2 off wPlayerMoveEffect; the primary status handlers +-- that end in PlayCurrentMoveAnimation2 (effects.asm:1448) set 6/3. Only 1 +-- and 4 were wired, so Bubblebeam blinked instead of shaking and Hypnosis +-- showed nothing. The shake generators themselves are +-- PredefShakeScreenHorizontally (engine/gfx/screen_effects.asm) and +-- AnimationShakeScreenHorizontallySlow (animations.asm:526-547). +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local Data = require("src.core.Data") +if not (Data.moves and Data.moves.BUBBLEBEAM) then Data:load() end +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +local Font = require("src.render.Font") +if not pcall(Font.encode, "A") then Font.load(Data) end + +local Game = require("src.core.Game") +Game.data = Data +Game.save = require("src.core.SaveData").newGame() + +local Pokemon = require("src.pokemon.Pokemon") +local BattleState = require("src.battle.BattleState") +local S = require("tests.harness").suite("parity applying attack anim") +local check, eq = S.check, S.eq + +-- rng floor: accuracyRoll compares rng(0, 255) against the scaled accuracy, +-- so the lowest roll lands HYPNOSIS (60%) every run +local function freshBattle() + Game.save.options.animations = true + Game.save.party = { Pokemon.new(Data, "SQUIRTLE", 30) } + local tb = BattleState.newWild(Game, "PIDGEY", 10) + tb.queue, tb.nextInsert = {}, 0 + tb.rng = function(a) return a end + return tb +end + +-- the hit rows a turn queued, in order (each carries sfx + animType) +local function hitRows(tb) + local out = {} + for _, row in ipairs(tb.queue) do + if row.hit then out[#out + 1] = row.hit end + end + return out +end + +local function typeOf(moveId, isPlayer) + local tb = freshBattle() + local user = isPlayer and tb.player or tb.enemy + local target = isPlayer and tb.enemy or tb.player + tb:performMove(user, target, { id = moveId, pp = 10 }, false) + local rows = hitRows(tb) + return rows[1] and rows[1].animType, #rows, rows +end + +-- ------------------------------------------------- damaging moves, both sides +-- TACKLE is NO_ADDITIONAL_EFFECT; BUBBLEBEAM is SPEED_DOWN_SIDE_EFFECT and +-- CONFUSION is CONFUSION_SIDE_EFFECT, the two moves named in the report +do + eq(Data.moves.TACKLE.effect, "NO_ADDITIONAL_EFFECT", + "TACKLE has no added effect") + eq(Data.moves.BUBBLEBEAM.effect, "SPEED_DOWN_SIDE_EFFECT", + "BUBBLEBEAM drops SPEED as a side effect") + eq(Data.moves.CONFUSION.effect, "CONFUSION_SIDE_EFFECT", + "CONFUSION confuses as a side effect") + + eq(typeOf("TACKLE", true), 4, "player TACKLE blinks the enemy pic (type 4)") + eq(typeOf("TACKLE", false), 1, "enemy TACKLE shakes vertically (type 1)") + eq(typeOf("BUBBLEBEAM", true), 5, + "player BUBBLEBEAM shakes horizontally light (type 5, #354)") + eq(typeOf("BUBBLEBEAM", false), 2, + "enemy BUBBLEBEAM shakes horizontally heavy (type 2)") + eq(typeOf("CONFUSION", true), 5, "player CONFUSION is type 5 too (#354)") + eq(typeOf("CONFUSION", false), 2, "and type 2 from the foe") + eq(select(2, typeOf("BUBBLEBEAM", true)), 1, + "one applying-attack row per hit, not one per stat drop") +end + +-- the damage sound still rides the row (PlayApplyingAttackSound off +-- wDamageMultipliers), and every damaging type keeps a blink target so a +-- type-4 turn can still use it +do + local _, _, rows = typeOf("BUBBLEBEAM", true) + eq(rows[1].sfx, "Damage", "the row carries the damage sound") + local _, _, plain = typeOf("TACKLE", true) + check(plain[1].blink ~= nil, "a type-4 row carries the pic to blink") +end + +-- ------------------------------------------------- primary status moves +-- PlayCurrentMoveAnimation2 call sites: SleepEffect (effects.asm:64), +-- PoisonEffect (:150), ConfusionSideEffectSuccess's primary branch (:1150), +-- DisableEffect (:1352), UpdateLoweredStatDone (:685) +do + eq(typeOf("HYPNOSIS", true), 6, "HYPNOSIS creeps the screen (type 6, #354)") + eq(typeOf("HYPNOSIS", false), 3, "and type 3 from the foe") + eq(typeOf("GROWL", true), 6, "GROWL is a primary stat drop: type 6") + eq(typeOf("TAIL_WHIP", true), 6, "so is TAIL_WHIP") + eq(typeOf("SAND_ATTACK", false), 3, "the foe's SAND-ATTACK is type 3") + eq(typeOf("POISONPOWDER", true), 6, "POISONPOWDER is type 6") + eq(typeOf("CONFUSE_RAY", true), 6, "CONFUSE RAY is type 6") + eq(typeOf("DISABLE", true), 6, "DISABLE is type 6") +end + +-- effects whose handler uses PlayCurrentMoveAnimation, which zeroes +-- wAnimationType: no applying animation at all +do + eq(typeOf("THUNDER_WAVE", true), nil, + "FreezeBurnParalyzeEffect zeroes the type (effects.asm:196)") + eq(typeOf("LEECH_SEED", true), nil, "LeechSeedEffect leaves it at 0") + eq(typeOf("SWORDS_DANCE", true), nil, + "StatModifierUpEffect leaves it at 0 (effects.asm:485)") + eq(typeOf("HARDEN", true), nil, "a stat-up move stays animation-free") +end + +-- a failed status effect prints AlreadyAsleep / NothingHappened with no +-- animation, so the row is peeled and nothing shakes +do + local tb = freshBattle() + tb.enemy.mon.status = "SLP" + tb.enemy.mon.sleepTurns = 3 + tb:performMove(tb.player, tb.enemy, { id = "HYPNOSIS", pp = 10 }, false) + eq(#hitRows(tb), 0, "a refused sleep queues no applying animation") +end + +-- ------------------------------------------------- what each type looks like +-- one entry per type: the pokered routine's own frame budget and amplitude. +-- 4*b*c for the slow creeps (2-frame delay up and down), 9*b for the fast +-- ones (5 frames out, 4 home, b counting down). stepProgram spends one +-- extra frame settling the tail, which SE_SHAKE_SCREEN already does. +local SHAPES = { + { t = 1, axis = "y", peak = 8, frames = 49, wait = 48, + what = "ShakeScreenVertically, PredefShakeScreenVertically b=8" }, + { t = 2, axis = "x", peak = 8, frames = 73, wait = 72, + what = "ShakeScreenHorizontallyHeavy, b=8" }, + { t = 3, axis = "x", peak = 6, frames = 49, wait = 48, + what = "ShakeScreenHorizontallySlow, b=6 c=2" }, + { t = 5, axis = "x", peak = 2, frames = 19, wait = 18, + what = "ShakeScreenHorizontallyLight, b=2" }, + { t = 6, axis = "x", peak = 3, frames = 25, wait = 24, + what = "ShakeScreenHorizontallySlow2, b=3 c=2" }, +} + +-- run the armed program to exhaustion; returns how many frames it held the +-- screen off-centre and the largest offset on each axis +local function runShake(tb) + local frames, peakX, peakY = 0, 0, 0 + for _ = 1, 400 do + if not (tb.fx and tb.fx.shakeProg) then break end + tb:updateFx() + frames = frames + 1 + peakX = math.max(peakX, math.abs(tb.fx.shakeX or 0)) + peakY = math.max(peakY, math.abs(tb.fx.shakeY or 0)) + end + return frames, peakX, peakY +end + +for _, s in ipairs(SHAPES) do + local tb = freshBattle() + tb:applyHitFx({ animType = s.t, sfx = "Damage" }) + check(tb.fx and tb.fx.shakeProg ~= nil, + ("type %d arms a shake program (%s)"):format(s.t, s.what)) + eq(tb.fx.blink, nil, ("type %d does not blink a pic"):format(s.t)) + eq(tb.waitFrames, s.wait, + ("type %d holds the queue %d frames"):format(s.t, s.wait)) + local frames, peakX, peakY = runShake(tb) + eq(frames, s.frames, ("type %d moves the screen for %d frames") + :format(s.t, s.frames)) + if s.axis == "x" then + eq(peakX, s.peak, ("type %d peaks at %dpx sideways"):format(s.t, s.peak)) + eq(peakY, 0, ("type %d never moves vertically"):format(s.t)) + else + eq(peakY, s.peak, ("type %d peaks at %dpx down"):format(s.t, s.peak)) + eq(peakX, 0, ("type %d never moves sideways"):format(s.t)) + end +end + +-- type 4 is the odd one out: the enemy pic blinks and the screen holds still +do + local tb = freshBattle() + tb:applyHitFx({ animType = 4, sfx = "Damage", blink = tb.enemy }) + eq(tb.fx.shakeProg, nil, "type 4 arms no shake (#354 must not regress it)") + check(tb.fx.blink ~= nil and tb.fx.blink.target == tb.enemy, + "type 4 blinks the enemy pic") + eq(tb.waitFrames, 20, "for the 20 frames AnimationBlinkEnemyMon takes") +end + +-- the OPTIONS animation toggle still gates the whole thing; the sound does not +do + local tb = freshBattle() + Game.save.options.animations = false + tb:applyHitFx({ animType = 5, sfx = "Damage" }) + eq(tb.fx.shakeProg, nil, "animations off arms no shake") + eq(tb.fx.blink, nil, "and no blink") + Game.save.options.animations = true +end + +-- rows queued without an animType (older save-state queues, mods) still get +-- the pre-#354 behavior off blink.isPlayer +do + local tb = freshBattle() + tb:applyHitFx({ blink = tb.enemy }) + check(tb.fx.blink ~= nil, "a bare enemy blink row still blinks") + local tb2 = freshBattle() + tb2:applyHitFx({ blink = tb2.player }) + check(tb2.fx.shakeProg ~= nil, "a bare player blink row still shakes") + local _, _, peakY = runShake(tb2) + eq(peakY, 8, "vertically, by 8px") +end + +S.finish() diff --git a/tests/parity_battle_item_turn.lua b/tests/parity_battle_item_turn.lua new file mode 100644 index 00000000..508d88d1 --- /dev/null +++ b/tests/parity_battle_item_turn.lua @@ -0,0 +1,259 @@ +-- Parity test: using an item in battle spends the turn, medicine included +-- (#379). pokered engine/items/item_effects.asm:1-3 zeroes +-- wActionResultOrTookBattleTurn to the success value before UseItem, and +-- ItemUseMedicine only clears it on its failure paths (:826 empty party, +-- :1241 .healingItemNoEffect), so a POTION that lands costs the turn the same +-- way a status cure does. The party HP bar fill (.doneHealing / UpdateHPBar2 +-- with the menu still up) is the field-only case (#252). +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local Data = require("src.core.Data") +if not (Data.pokemon and Data.pokemon.RATTATA) then Data:load() end +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +local Pokemon = require("src.pokemon.Pokemon") +local BattleState = require("src.battle.BattleState") +local ItemEffects = require("src.inventory.ItemEffects") +local S = require("tests.harness").suite("parity battle item turn") +local check, eq = S.check, S.eq + +-- Real TextBoxes want a Font atlas, and the flow under test only cares that a +-- message opened and what its onDone does. Restored at the bottom for the +-- suites run_tests.lua chains after this file. +local realTextBox = package.loaded["src.render.TextBox"] +local realBag = package.loaded["src.ui.BagMenu"] +local realParty = package.loaded["src.ui.PartyMenu"] +package.loaded["src.render.TextBox"] = { + new = function(_, text, done) return { textBox = true, text = text, done = done } end, +} +-- BagMenu and PartyMenu bind TextBox at require time, so they are reloaded +-- against the stub here and dropped again at the bottom +package.loaded["src.ui.BagMenu"] = nil +package.loaded["src.ui.PartyMenu"] = nil +local BagMenu = require("src.ui.BagMenu") +local PartyMenu = require("src.ui.PartyMenu") +-- src.ui.Screens caches its factory per id, so a suite that already opened a +-- PartyMenu would hand BagMenu the pre-reload class and the picker identity +-- check below would never match +require("src.ui.Screens").invalidate() + +-- A stack that behaves like StateStack for the two things this flow reads: +-- top() identity (PartyMenu:close) and push/pop ordering. +local function newStack() + local stack = { states = {} } + function stack:push(s) self.states[#self.states + 1] = s end + function stack:pop() return table.remove(self.states) end + function stack:top() return self.states[#self.states] end + return stack +end + +-- One button per call: PartyMenu:update reads game.input once per fixed step. +local function newInput() + local input = { pressed = nil } + function input:wasPressed(b) return self.pressed == b end + return input +end + +local function freshGame(monHP, status) + local lead = Pokemon.new(Data, "CHARIZARD", 50) + lead.hp = monHP or 10 + lead.status = status + local game = { + data = Data, + stack = newStack(), + input = newInput(), + save = { + party = { lead }, + player = { name = "RED" }, + inventory = {}, + options = { battleStyle = "set", battleAnim = "on" }, + pokedex = { seen = {}, owned = {} }, + flags = {}, + money = 0, + }, + } + local Bag = require("src.inventory.Bag") + for _, id in ipairs({ "POTION", "SUPER_POTION", "ANTIDOTE" }) do + Bag.add(game.save, id, 3) + end + return game, lead +end + +local function isPicker(s) return getmetatable(s) == PartyMenu end +local function isBox(s) return type(s) == "table" and s.textBox == true end + +local function inStack(stack, pred) + for _, s in ipairs(stack.states) do + if pred(s) then return true end + end + return false +end + +-- TextBox pops itself BEFORE firing onDone, which is what PartyMenu:close's +-- identity check depends on. +local function dismiss(stack, box) + if stack:top() == box then stack:pop() end + if box.done then box.done() end +end + +local function rowFor(list, id) + for i, r in ipairs(list.items) do + if r.value == id then return i end + end + return nil +end + +-- Open the bag on `id`, choose it, then press A on the party picker. Returns +-- the picker (nil when none opened) so the caller can inspect the fill. +local function useFromBag(game, battle, id) + local list = BagMenu.new(game, { battle = battle }) + game.stack:push(list) + local row = rowFor(list, id) + if not row then return nil, "no " .. id .. " row in the bag" end + list.index = row + list.onChoose(list.items[row], list) + -- out of battle the bag offers USE / TOSS first (start_sub_menus.asm) + local sub = game.stack:top() + if not battle and sub and sub.items and sub.items[1] + and sub.items[1].onSelect then + game.stack:pop() + sub.items[1].onSelect() + end + local picker = game.stack:top() + if not isPicker(picker) then return nil, "party picker never opened" end + game.input.pressed = "a" + picker:update(1 / 60) + game.input.pressed = nil + return picker +end + +-- Pump the picker until its bar fill lands (it blocks input while it runs). +local function runFill(picker) + for _ = 1, 400 do + if not picker.heal then return true end + picker:update(1 / 60) + end + return false +end + +-- Count the turn: BattleState:itemUsed is the only path that queues the foe's +-- action plus end-of-turn, so wrapping it is the flag check. +local function watchTurn(battle) + local rec = { itemUsed = 0, messages = {} } + local real = battle.itemUsed + battle.itemUsed = function(self, messages) + rec.itemUsed = rec.itemUsed + 1 + return real(self, messages) + end + return rec +end + +do + check(ItemEffects.healsHP("POTION"), "POTION is an HP medicine") + check(not ItemEffects.healsHP("ANTIDOTE"), "ANTIDOTE is a status cure") +end + +-- The report: a POTION mid-battle healed for free. BagMenu gated its +-- animate-the-bar branch on the picker existing, and in battle the picker is +-- non-nil (PartyMenu hands itself to onSwitch after popping), so the branch +-- swallowed the message, the itemUsed tail, and the turn. +do + local game, lead = freshGame(10) + local battle = BattleState.newWild(game, "PIDGEY", 8) + local rec = watchTurn(battle) + local picker, why = useFromBag(game, battle, "POTION") + check(picker ~= nil, "the picker opened for a POTION in battle: " .. tostring(why)) + if picker then + eq(lead.hp, 30, "the POTION restored 20 HP") + check(picker.heal == nil, "no party-menu bar fill in battle (#252 is field-only)") + check(not inStack(game.stack, isPicker), "the picker is gone") + local box = game.stack:top() + check(isBox(box), "the restored-HP message opened (#379)") + if isBox(box) then + check(box.text:find("was restored", 1, true) ~= nil, + "and it is the restored-HP line: " .. tostring(box.text)) + dismiss(game.stack, box) + end + eq(rec.itemUsed, 1, "a POTION in battle costs the turn (#379)") + check(#battle.queue > 0, "and the foe's action is queued behind it") + end +end + +-- Same for the tier above it: the gate is per-item, so a SUPER POTION taking +-- the animate branch while a POTION does not would be the bug half-fixed. +do + local game = freshGame(10) + local battle = BattleState.newWild(game, "PIDGEY", 8) + local rec = watchTurn(battle) + local picker = useFromBag(game, battle, "SUPER_POTION") + if check(picker ~= nil, "the picker opened for a SUPER POTION") then + local box = game.stack:top() + check(isBox(box), "a SUPER POTION prints its message too") + if isBox(box) then dismiss(game.stack, box) end + eq(rec.itemUsed, 1, "a SUPER POTION in battle costs the turn (#379)") + end +end + +-- Control: status cures always spent the turn, and still must. +do + local game, lead = freshGame(40, "PSN") + local battle = BattleState.newWild(game, "PIDGEY", 8) + local rec = watchTurn(battle) + local picker = useFromBag(game, battle, "ANTIDOTE") + if check(picker ~= nil, "the picker opened for an ANTIDOTE") then + check(lead.status == nil, "PSN was cured") + local box = game.stack:top() + if isBox(box) then dismiss(game.stack, box) end + eq(rec.itemUsed, 1, "a status cure still costs the turn") + end +end + +-- .healingItemNoEffect (item_effects.asm:1241) clears the flag: a POTION on a +-- full-HP mon is refused and the turn is NOT spent. +do + local game, lead = freshGame(nil) + lead.hp = lead.stats.hp + local battle = BattleState.newWild(game, "PIDGEY", 8) + local rec = watchTurn(battle) + local picker = useFromBag(game, battle, "POTION") + if check(picker ~= nil, "the picker opened for the refused POTION") then + local box = game.stack:top() + check(isBox(box) and box.text:find("won't have", 1, true) ~= nil, + "the refusal prints \"It won't have any effect.\"") + if isBox(box) then dismiss(game.stack, box) end + eq(rec.itemUsed, 0, "a refused item does not cost the turn") + eq(game.save.inventory.POTION, 3, "and the POTION is not consumed") + end +end + +-- Field regression guard for #252: out of battle the picker stays up and +-- animates, which is the behavior the #379 gate must not have taken away. +do + local game, lead = freshGame(10) + local picker = useFromBag(game, nil, "POTION") + if check(picker ~= nil, "the field picker opened for a POTION") then + check(picker.keepOpen == true, "keepOpen is set out of battle (#252)") + check(inStack(game.stack, isPicker), + "the party menu is still on the stack after the pick (#252)") + check(type(picker.heal) == "table" and picker.heal.from == 10, + "the bar fill started from the pre-heal HP") + check(runFill(picker), "the fill lands") + local box = game.stack:top() + check(isBox(box), "then the message prints over the still-drawn menu") + check(inStack(game.stack, isPicker), "...with the picker underneath it") + if isBox(box) then + dismiss(game.stack, box) + check(not inStack(game.stack, isPicker), + "dismissing the message closes the picker") + end + eq(lead.hp, 30, "and the field POTION healed the same 20 HP") + end +end + +package.loaded["src.render.TextBox"] = realTextBox +package.loaded["src.ui.BagMenu"] = realBag +package.loaded["src.ui.PartyMenu"] = realParty +require("src.ui.Screens").invalidate() +S.finish() diff --git a/tests/parity_bide_anim.lua b/tests/parity_bide_anim.lua new file mode 100644 index 00000000..1447eb7b --- /dev/null +++ b/tests/parity_bide_anim.lua @@ -0,0 +1,180 @@ +-- Parity test: which animation BIDE plays on which turn (#375). BIDE is a +-- ResidualEffects2 entry (data/battle/residual_effects_2.asm:18), so the +-- ordinary PlayMoveAnimation is skipped on the storing turn and BideEffect +-- (engine/battle/effects.asm:764-789) ends on +-- `ldh a, [hWhoseTurn] / add XSTATITEM_ANIM / jp PlayBattleAnimation2`: +-- the X-item spiral, duplicated for the enemy side. BIDE's own hit +-- animation belongs to .UnleashEnergy (engine/battle/core.asm:3501-3529), +-- which re-points wPlayerMoveNum at BIDE and rejoins +-- HandleIfPlayerMoveMissed, i.e. after UnleashedEnergyText and before the +-- HP bar drains. The accuracy half of the release is in tests/parity_J.lua. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local Data = require("src.core.Data") +if not (Data.moves and Data.moves.BIDE) then Data:load() end +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +local Font = require("src.render.Font") +if not pcall(Font.encode, "A") then Font.load(Data) end + +local Game = require("src.core.Game") +Game.data = Data +Game.save = require("src.core.SaveData").newGame() + +local Pokemon = require("src.pokemon.Pokemon") +local BattleState = require("src.battle.BattleState") +local S = require("tests.harness").suite("parity bide anim") +local check, eq = S.check, S.eq + +local function freshBattle() + Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) } + local tb = BattleState.newWild(Game, "PIDGEY", 10) + tb.queue, tb.nextInsert = {}, 0 + return tb +end + +-- the rows this suite cares about, in queue order: text lines and anim names +local function trace(tb) + local out = {} + for _, row in ipairs(tb.queue) do + if row.text then + out[#out + 1] = { text = (row.text:gsub("\n", " ")) } + elseif row.anim then + out[#out + 1] = { anim = row.anim, isPlayer = row.attackerIsPlayer } + elseif row.drain then + out[#out + 1] = { drain = true } + end + end + return out +end + +local function indexOfAnim(rows, name) + for i, r in ipairs(rows) do + if r.anim == name then return i end + end + return nil +end + +local function indexOfText(rows, needle) + for i, r in ipairs(rows) do + if r.text and r.text:find(needle, 1, true) then return i end + end + return nil +end + +local function anyAnim(rows, name) + return indexOfAnim(rows, name) ~= nil +end + +-- the three animations this behavior needs must actually be in the cache, or +-- AnimPlayer:start warns and plays nothing +do + local anims = Data.battle_anims and Data.battle_anims.moveAnims + check(anims ~= nil, "battle_anims carries moveAnims") + if anims then + check(anims.BIDE ~= nil, "BIDE has an animation") + check(anims.XSTATITEM_ANIM ~= nil, "XSTATITEM_ANIM has an animation") + check(anims.XSTATITEM_DUPLICATE_ANIM ~= nil, + "XSTATITEM_DUPLICATE_ANIM has an animation") + end +end + +-- storing turn, player side: used-BIDE line, X-item spiral, storing line. +-- The bug queued BIDE's hit row (sound = "BIDE", subanim 4) here instead. +do + local tb = freshBattle() + tb:performMove(tb.player, tb.enemy, { id = "BIDE", pp = 10 }, false) + local rows = trace(tb) + eq(tb.player.bideTurns ~= nil, true, "the storing turn locks BIDE in") + eq(tb.player.bideDamage, 0, "and zeroes the accumulator") + check(not anyAnim(rows, "BIDE"), + "BIDE's own animation does not play on the storing turn (#375)") + local spiral = indexOfAnim(rows, "XSTATITEM_ANIM") + check(spiral ~= nil, "the storing turn plays XSTATITEM_ANIM") + eq(rows[spiral] and rows[spiral].isPlayer, true, + "on the player's side of the field") + local used = indexOfText(rows, "used BIDE!") + local storing = indexOfText(rows, "storing energy!") + check(used and spiral and used < spiral, + "the spiral comes after the used-BIDE line") + check(spiral and storing and spiral < storing, + "and before the storing-energy line") + check(tb.moveAnimRow == nil, "the peeled move-anim row is not left dangling") + check(not anyAnim(rows, "XSTATITEM_DUPLICATE_ANIM"), + "the player never gets the enemy-side duplicate") +end + +-- enemy side: PlayBattleAnimation2 adds hWhoseTurn, so the foe gets +-- XSTATITEM_DUPLICATE_ANIM (AttackAnimationPointers[174]) +do + local tb = freshBattle() + tb:performMove(tb.enemy, tb.player, { id = "BIDE", pp = 10 }, false) + local rows = trace(tb) + check(not anyAnim(rows, "BIDE"), + "the foe's storing turn is animation-free of BIDE too") + check(not anyAnim(rows, "XSTATITEM_ANIM"), + "and does not borrow the player row") + local dup = indexOfAnim(rows, "XSTATITEM_DUPLICATE_ANIM") + check(dup ~= nil, "the foe's storing turn plays XSTATITEM_DUPLICATE_ANIM") + check(dup and not rows[dup].isPlayer, "attributed to the enemy side") +end + +-- locked turns: .BideCheck decrements with no text of its own in pokered and +-- no animation either; the port prints the storing line, and that is all +do + local tb = freshBattle() + tb.player.bideTurns = 3 + tb.player.bideDamage = 0 + tb:continueBide(tb.player, tb.enemy) + local rows = trace(tb) + eq(tb.player.bideTurns, 2, "a locked turn just counts down") + check(indexOfText(rows, "storing energy!") ~= nil, "and reprints the line") + for _, r in ipairs(rows) do + check(r.anim == nil, "no animation on a locked turn: " .. tostring(r.anim)) + end +end + +-- release turn: text, then BIDE's animation, then the bar drains +do + local tb = freshBattle() + tb.enemy.mon.stats.hp = 200 + tb.enemy.mon.hp = 200 + tb.player.bideTurns = 1 + tb.player.bideDamage = 40 + tb:continueBide(tb.player, tb.enemy) + local rows = trace(tb) + eq(tb.enemy.mon.hp, 120, "the release deals bideDamage*2") + local unleashed = indexOfText(rows, "unleashed energy!") + local hit = indexOfAnim(rows, "BIDE") + check(unleashed ~= nil, "the release prints UnleashedEnergyText") + check(hit ~= nil, "the release plays BIDE's own animation (#375)") + eq(rows[hit] and rows[hit].isPlayer, true, "from the player's side") + check(unleashed and hit and unleashed < hit, + "the animation follows the text") + local drainAt + for i, r in ipairs(rows) do + if r.drain then drainAt = i; break end + end + check(drainAt ~= nil, "the HP bar drain is queued") + check(hit and drainAt and hit < drainAt, + "and the animation runs before the bar moves") + check(not anyAnim(rows, "XSTATITEM_ANIM"), + "the release is not the X-item spiral") +end + +-- zero stored damage sets wMoveMissed, so the failure path stays anim-free +do + local tb = freshBattle() + local hpBefore = tb.enemy.mon.hp + tb.player.bideTurns = 1 + tb.player.bideDamage = 0 + tb:continueBide(tb.player, tb.enemy) + local rows = trace(tb) + eq(tb.enemy.mon.hp, hpBefore, "nothing stored means no damage") + check(indexOfText(rows, "But, it failed!") ~= nil, "it prints the failure") + check(not anyAnim(rows, "BIDE"), "and plays no animation") +end + +S.finish() diff --git a/tests/parity_celadon_roof_readables.lua b/tests/parity_celadon_roof_readables.lua new file mode 100644 index 00000000..a8e922b2 --- /dev/null +++ b/tests/parity_celadon_roof_readables.lua @@ -0,0 +1,135 @@ +-- Parity test: the Celadon Mansion roof house blackboard and pamphlet (#391). +-- +-- pokered data/events/hidden_events.asm, hidden_events_for +-- CELADON_MANSION_ROOF_HOUSE: +-- hidden_text_predef 3, 0 / 4, 0 PrintBlackboardLinkCableText, LinkCableHelp +-- hidden_text_predef 3, 4 PrintNotebookText, TMNotebook +-- tools/extract/field.py only parses `hidden_event` rows, so neither tile +-- reaches data/generated/field.lua and data/scripts/celadon_eevee.lua carries +-- them as an onInteract hook instead. LinkCableHelp +-- (engine/events/hidden_events/school_blackboard.asm) prints +-- _LinkCableHelpText1, then loops prompt + 15x10 heading box until B or the +-- fourth row; TMNotebook (school_notebooks.asm) is one plain text. +-- +-- Self-contained; run via `luajit tests/parity_celadon_roof_readables.lua`. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.CELADON_MANSION_ROOF_HOUSE) then Data:load() end +local Font = require("src.render.Font") +if not pcall(Font.encode, "A") then Font.load(Data) end +require("data.scripts.init") +local MapScripts = require("src.script.MapScripts") +local TextBox = require("src.render.TextBox") +local Menu = require("src.ui.Menu") +local S = require("tests.harness").suite("parity celadon roof readables") +local check, eq = S.check, S.eq + +local MAP = "CELADON_MANSION_ROOF_HOUSE" + +for _, key in ipairs({ "_LinkCableHelpText1", "_LinkCableHelpText2", + "_LinkCableInfoText1", "_LinkCableInfoText2", "_LinkCableInfoText3" }) do + check(type(Data.text[key]) == "string" and Data.text[key] ~= "", + key .. " is extracted") +end + +local hooks = MapScripts.get(MAP) +check(hooks and type(hooks.onInteract) == "function", + MAP .. " registers onInteract for the two readables") +check(hooks and hooks.talk + and hooks.talk.TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL ~= nil, + "the Eevee ball talk entry survives alongside the hook") + +-- stack stub: the blackboard loop pushes and pops, so keep the whole stack +local stack = {} +local game = { + data = Data, + save = { player = { name = "RED", rival = "BLUE" } }, + stack = { + push = function(_, state) stack[#stack + 1] = state end, + pop = function() return table.remove(stack) end, + top = function() return stack[#stack] end, + }, +} +local ow = { player = { facing = "up" } } + +local function pages(box) + local out = {} + for _, page in ipairs(box.pages or {}) do + for _, line in ipairs(page) do out[#out + 1] = line end + end + return table.concat(out, "\n") +end + +-- the pamphlet: (3,4) only, the table's other three cells stay silent +stack = {} +eq(hooks.onInteract(game, ow, 3, 4), true, "the pamphlet at (3,4) is claimed") +local box = stack[#stack] +check(getmetatable(box) == TextBox, "the pamphlet pushes a TextBox") +local pamphlet = pages(box) +for _, want in ipairs({ "pamphlet", "50 TMs", "HMs", "SILPH CO." }) do + check(pamphlet:find(want, 1, true) ~= nil, + "the pamphlet text has " .. want) +end + +for _, cell in ipairs({ { 4, 4 }, { 3, 3 }, { 4, 3 }, { 3, 5 } }) do + eq(hooks.onInteract(game, ow, cell[1], cell[2]), false, + ("(%d,%d) is not one of the readables"):format(cell[1], cell[2])) +end + +-- the blackboard: both top-row cells, and facing is irrelevant because +-- hidden_text_predef spends the facing byte on the predef id +for _, fx in ipairs({ 3, 4 }) do + stack = {} + ow.player.facing = fx == 3 and "up" or "left" + eq(hooks.onInteract(game, ow, fx, 0), true, + ("the blackboard at (%d,0) is claimed"):format(fx)) + check(pages(stack[#stack]):find("TRAINER TIPS", 1, true) ~= nil, + ("(%d,0) opens with _LinkCableHelpText1"):format(fx)) +end +eq(hooks.onInteract(game, ow, 2, 0), false, + "the wall left of the blackboard stays silent") + +-- walk the loop: intro -> prompt -> heading box -> blurb -> prompt again +stack = {} +hooks.onInteract(game, ow, 3, 0) +local intro = stack[#stack] +check(type(intro.onDone) == "function", "the intro text has a continuation") +intro.onDone() +local prompt = stack[#stack] +check(getmetatable(prompt) == TextBox + and pages(prompt):find("heading", 1, true) ~= nil, + "the intro leads into the which-heading prompt") +prompt.onDone() +local menu = stack[#stack] +check(getmetatable(menu) == Menu, "the prompt opens the heading menu") +eq(#menu.items, 4, "four headings, as in HowToLinkText") +local labels = {} +for i, item in ipairs(menu.items) do labels[i] = item.label end +eq(table.concat(labels, "/"), + "HOW TO LINK/COLOSSEUM/TRADE CENTER/STOP READING", + "the headings read in HowToLinkText order") +eq(menu.items[4].onSelect, nil, "STOP READING just closes the menu") +check(menu.tw == 15 and menu.th == 10 and menu.tx == 0 and menu.ty == 0, + "the box is the asm's 15x10 at the top left") + +for i = 1, 3 do + stack = {} + menu.items[i].onSelect() + local blurb = stack[#stack] + check(getmetatable(blurb) == TextBox, + labels[i] .. " prints a text box") + local want = Data.text["_LinkCableInfoText" .. i]:match("^[^\n\011\012]+") + check(pages(blurb):find(want, 1, true) ~= nil, + labels[i] .. " prints _LinkCableInfoText" .. i) + check(type(blurb.onDone) == "function", + labels[i] .. " returns to the prompt instead of dropping out") + blurb.onDone() + check(getmetatable(stack[#stack]) == TextBox, + "the prompt comes back after " .. labels[i]) + stack[#stack].onDone() + check(getmetatable(stack[#stack]) == Menu, + "the menu comes back after " .. labels[i]) +end + +S.finish() diff --git a/tests/parity_elevator_panel.lua b/tests/parity_elevator_panel.lua new file mode 100644 index 00000000..a3f63b92 --- /dev/null +++ b/tests/parity_elevator_panel.lua @@ -0,0 +1,207 @@ +-- Parity: the elevator floor menu belongs to the panel, not to map entry (#395). +-- +-- Oracle: data/maps/objects/CeladonMartElevator.asm declares +-- `bg_event 3, 0, TEXT_CELADONMARTELEVATOR` (SilphCoElevator.asm the same +-- cell, RocketHideoutElevator.asm `bg_event 1, 1`), and only +-- CeladonMartElevatorText reaches `predef DisplayElevatorFloorMenu` +-- (scripts/CeladonMartElevator.asm). Map entry runs +-- CeladonMartElevatorStoreWarpEntriesScript alone, so warping into the car +-- must open nothing. DisplayElevatorFloorMenu (engine/events/elevator.asm) +-- rewrites wWarpEntries through .UpdateWarp and returns: it never moves the +-- player, so nothing may walk them out of the car after the ride. +-- +-- Self-contained: `luajit tests/parity_elevator_panel.lua`; also dofile'd by +-- tests/run_tests.lua. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.CELADON_MART_ELEVATOR) then Data:load() end +local MapLoader = require("src.world.MapLoader") +local mapScripts = require("data.scripts.init") + +local S = require("tests.harness").suite("parity elevator panel") +local check, eq = S.check, S.eq + +-- restored at the bottom so the suites after this file see the real states +local realList = package.loaded["src.ui.ListMenu"] +local realShake = package.loaded["src.world.ElevatorShake"] +local realTextBox = package.loaded["src.render.TextBox"] +package.loaded["src.ui.ListMenu"] = { + new = function(_, title, items, opts) + return { kind = "list", title = title, items = items, opts = opts, + close = function() end } + end, +} +package.loaded["src.world.ElevatorShake"] = { + new = function(_, _, opts) return { kind = "shake", opts = opts } end, +} +package.loaded["src.render.TextBox"] = { + new = function(_, text, done) return { kind = "text", text = text, done = done } end, +} + +-- panel cell / reading cell from the bg_event coordinates above; facing is +-- the direction that looks at the panel from the reading cell +local CARS = { + { map = "CELADON_MART_ELEVATOR", text = "TEXT_CELADONMARTELEVATOR", + panelX = 3, panelY = 0, readX = 3, readY = 1, facing = "up", + from = "CELADON_MART_3F", pick = "CELADON_MART_2F", floors = 5 }, + { map = "SILPH_CO_ELEVATOR", text = "TEXT_SILPHCOELEVATOR_ELEVATOR", + panelX = 3, panelY = 0, readX = 3, readY = 1, facing = "up", + from = "SILPH_CO_5F", pick = "SILPH_CO_11F", floors = 11 }, + { map = "ROCKET_HIDEOUT_ELEVATOR", text = "TEXT_ROCKETHIDEOUTELEVATOR", + panelX = 1, panelY = 1, readX = 2, readY = 1, facing = "left", + from = "ROCKET_HIDEOUT_B2F", pick = "ROCKET_HIDEOUT_B4F", floors = 3, + key = "LIFT_KEY", keyText = "_RocketHideoutElevatorAppearsToNeedKeyText" }, +} + +-- ow stub: the car's warps are copied out of Data so a rewrite here cannot +-- leak into the suites that read the same map defs later. scriptMove / +-- takeWarp are the walk-out primitives the fix deleted; leaving them here as +-- tripwires is what makes "nothing moves the player" an assertion. +local function fakeCar(car) + local warps = {} + for i, w in ipairs(Data.maps[car.map].warps) do + warps[i] = { x = w.x, y = w.y, destMap = w.destMap, destWarp = w.destWarp } + end + local moves = 0 + return { + map = { id = car.map, def = { warps = warps, label = Data.maps[car.map].label } }, + player = { cellX = car.readX, cellY = car.readY, facing = car.facing }, + npcs = {}, entities = {}, scriptMoves = {}, + scriptMove = function() moves = moves + 1 end, + takeWarp = function() moves = moves + 1 end, + moveCount = function() return moves end, + } +end + +local function fakeGame(inventory) + local pushed = {} + return { + data = Data, + save = { flags = {}, inventory = inventory or {} }, + stack = { + pushed = pushed, + push = function(_, state) pushed[#pushed + 1] = state end, + pop = function() pushed[#pushed] = nil end, + top = function() return pushed[#pushed] end, + }, + }, pushed +end + +local function destinations(ow) + local maps = {} + for _, w in ipairs(ow.map.def.warps) do maps[w.destMap] = true end + local list = {} + for m in pairs(maps) do list[#list + 1] = m end + table.sort(list) + return table.concat(list, ",") +end + +for _, car in ipairs(CARS) do + local tag = car.map + local entry = mapScripts.get(car.map) + check(type(entry) == "table" and type(entry.onEnter) == "function", + tag .. ": map entry still stores the car's warps") + + -- the panel is map data, and it is unreachable except by facing it + local map = MapLoader.load(Data, car.map) + local sign = map:signAtCell(car.panelX, car.panelY) + check(sign ~= nil, + ("%s: bg_event at (%d,%d) is extracted as a sign") + :format(tag, car.panelX, car.panelY)) + eq(sign and sign.text, car.text, tag .. ": that sign carries the panel text") + check(not map:isWalkableCell(car.panelX, car.panelY), + tag .. ": the panel cell is wall, so it can only be read by facing it") + check(map:isWalkableCell(car.readX, car.readY), + ("%s: (%d,%d) is the walkable cell the panel is read from") + :format(tag, car.readX, car.readY)) + + local panel = mapScripts.talkScript(car.map, car.text) + check(type(panel) == "function", + tag .. ": the panel text runs a hand-ported handler") + + -- warping in: warps seeded to the floor just left, nothing on the stack + local game, pushed = fakeGame() + local ow = fakeCar(car) + entry.onEnter(game, ow, car.from) + eq(#pushed, 0, tag .. ": entering the car pushes no menu and no text") + eq(destinations(ow), car.from, + tag .. ": entry only points the car's exit warps back at " .. car.from) + local seeded = destinations(ow) + + if car.key then + -- RocketHideoutElevatorText with no LIFT_KEY: the line, and no menu + local box = nil + local done = false + panel(game, ow, nil, function() done = true end) + box = pushed[#pushed] + eq(#pushed, 1, tag .. ": the keyless panel pushes exactly one state") + eq(box and box.kind, "text", tag .. ": and that state is the text box") + eq(box and box.text, Data.text[car.keyText], + tag .. ": it is the appears-to-need-a-key line") + if box and box.done then box.done() end + check(done, tag .. ": the keyless panel hands control back") + eq(destinations(ow), seeded, + tag .. ": a keyless read leaves the exit warps alone") + pushed[1] = nil + game.save.inventory[car.key] = 1 + end + + -- reading the panel with a key (or with no gate): WHICH FLOOR? + local done = false + panel(game, ow, nil, function() done = true end) + local list = pushed[#pushed] + eq(list and list.kind, "list", tag .. ": reading the panel opens a list menu") + eq(list and list.title, "WHICH FLOOR?", tag .. ": titled WHICH FLOOR?") + eq(list and #list.items, car.floors, + tag .. ": every floor that warps into this car is listed") + eq(destinations(ow), seeded, + tag .. ": opening the menu has not rewritten anything yet") + + -- `ret c` on B: no warp rewrite, no ride, player still in the car + local cancelGame, cancelPushed = fakeGame(car.key and { [car.key] = 1 } or nil) + local cancelOw = fakeCar(car) + entry.onEnter(cancelGame, cancelOw, car.from) + local cancelDone = false + panel(cancelGame, cancelOw, nil, function() cancelDone = true end) + local cancelList = cancelPushed[#cancelPushed] + cancelList.opts.onCancel() + check(cancelDone, tag .. ": B closes the panel") + eq(destinations(cancelOw), car.from, + tag .. ": B leaves the exit warps on the floor entered from") + eq(cancelOw.moveCount(), 0, tag .. ": B moves nobody") + + -- choosing a floor: the ride first, the .UpdateWarp rewrite when it ends + local chosen + for _, item in ipairs(list.items) do + if item.value.map == car.pick then chosen = item end + end + check(chosen ~= nil, tag .. ": " .. car.pick .. " is on the floor list") + list.opts.onChoose(chosen, list) + local shake = pushed[#pushed] + eq(shake and shake.kind, "shake", tag .. ": choosing a floor starts the ride") + eq(destinations(ow), seeded, + tag .. ": the warps are still the entry floor's while the car shakes") + shake.opts.onDone() + check(done, tag .. ": the ride hands control back to the player") + eq(destinations(ow), car.pick, + tag .. ": the finished ride points the car's exits at " .. car.pick) + eq(ow.moveCount(), 0, + tag .. ": no scripted walk-out -- the player leaves the car themselves") + eq(ow.player.cellX .. "," .. ow.player.cellY, + car.readX .. "," .. car.readY, + tag .. ": they are still standing at the panel") + + -- the rewrite has to land on the floor's own warp back into the car, the + -- reciprocal pair wElevatorWarpMaps holds + for _, w in ipairs(ow.map.def.warps) do + local back = Data.maps[car.pick].warps[w.destWarp] + check(back ~= nil and back.destMap == car.map, + tag .. ": rewritten exit lands on " .. car.pick .. "'s elevator door") + end +end + +package.loaded["src.ui.ListMenu"] = realList +package.loaded["src.world.ElevatorShake"] = realShake +package.loaded["src.render.TextBox"] = realTextBox +S.finish() diff --git a/tests/parity_field_move_layering.lua b/tests/parity_field_move_layering.lua new file mode 100644 index 00000000..6d39964e --- /dev/null +++ b/tests/parity_field_move_layering.lua @@ -0,0 +1,184 @@ +-- Parity test: the party-menu field moves print their text with the party +-- menu still on screen, and the GBPalWhiteOutWithDelay3 blink IS the menu +-- closing afterwards (engine/menus/start_sub_menus.asm .flash / .strength / +-- .surf, each PrintText -> GBPalWhiteOutWithDelay3 -> jp .goBackToMap, +-- whose RestoreScreenTilesAndReloadTilePatterns + CloseTextDisplay tear the +-- menu down only after the white-out). Popping the menu first left the +-- opaque WhiteFlash as the only state under the message, i.e. the white +-- screen in #385, and lit a dark cave under the FLASH text. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end +local S = require("tests.harness").suite("parity field move layering") +local check, eq = S.check, S.eq + +require("src.render.Font").load(Data) +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local StateStack = require("src.core.StateStack") +local Renderer = require("src.render.Renderer") +local SaveData = require("src.core.SaveData") +local Pokemon = require("src.pokemon.Pokemon") +local PartyMenu = require("src.ui.PartyMenu") +local OW = require("src.world.OverworldController") + +Game.data = Data +Game.input = Input; Input:init() +Game.renderer = Renderer; Renderer:init() +Game.stack = StateStack; StateStack:init() +Game.save = SaveData.newGame() + +-- tests/parity_bills_pc.lua swaps the OverworldController chunk's TextBox +-- upvalue for a stub and never puts it back, and run_tests.lua dofiles every +-- parity suite into one process: point it back at the real module so trySurf +-- pushes a real box here. +local function setUpvalue(fn, name, val) + local i = 1 + while true do + local n = debug.getupvalue(fn, i) + if not n then return false end + if n == name then debug.setupvalue(fn, i, val); return true end + i = i + 1 + end +end +setUpvalue(OW.trySurf, "TextBox", require("src.render.TextBox")) + +local function frame(btns) + Input.pressed = {} + for _, b in ipairs(btns or {}) do Input.pressed[b] = true; Input.state[b] = true end + StateStack:update(1 / 60) + for _, b in ipairs(btns or {}) do Input.state[b] = false end +end +local function popAll() while Game.stack:top() do Game.stack:pop() end end +local function pushOW(mapId, x, y, facing) + popAll() + Game.stack:push(OW, mapId, x, y, facing) + return Game.stack:top() +end +local function mkMon(species, ...) + local m = Pokemon.new(Data, species, 20) + m.moves = {} + for _, id in ipairs({ ... }) do m.moves[#m.moves + 1] = { id = id, pp = 15 } end + return m +end +local function selectSubItem(pm, idx) + Game.stack:push(pm) + frame({ "a" }) + for _ = 2, idx do frame({ "down" }) end + frame({ "a" }) +end + +local function isText(s) return s ~= nil and s.pages ~= nil end +-- Transition.whiteFlash keeps its remaining frame budget and draws a full +-- opaque rect; the overworld and the menu have no `frames` +local function isBlink(s) + return s ~= nil and s.pages == nil and s.isOpaque == true + and type(s.frames) == "number" +end +-- the state the compositor starts from: StateStack:draw walks up from the +-- highest opaque state, so this is literally what the player sees behind +-- the message +local function backdrop() + return Game.stack.states[Game.stack:visibleBase()] +end +-- dismiss exactly one box (an auto page advances on its own timer, a +-- prompt page on A), leaving whatever it pushed on top +local function drainOne() + local box = Game.stack:top() + local guard = 0 + while Game.stack:top() == box and guard < 400 do + guard = guard + 1 + frame({ "a" }) + end +end +-- run frames until the stack is back on the map (the blink pops itself and +-- fires its onDone) +local function settle(ow) + local guard = 0 + while Game.stack:top() ~= ow and guard < 240 do + guard = guard + 1 + frame({}) + end +end + +check(PartyMenu.isOpaque == true, "PartyMenu is opaque, so it can be the backdrop") + +-- ===================================================================== +-- .strength: PrintStrengthText's two pages, then the blink +-- ===================================================================== +Game.save.party = { mkMon("MACHOP", "STRENGTH") } +Game.save.inventory = { RAINBOWBADGE = true } +local ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right") +local pmStr = PartyMenu.new(Game) +selectSubItem(pmStr, 3) +check(isText(Game.stack:top()), "STRENGTH opens _UsedStrengthText") +eq(backdrop(), pmStr, "the party menu is the backdrop of _UsedStrengthText") +drainOne() +check(isText(Game.stack:top()), "_CanMoveBouldersText follows") +eq(backdrop(), pmStr, "the party menu is still the backdrop of the second page") +drainOne() +check(isBlink(Game.stack:top()), "the blink comes after both pages") +eq(backdrop(), Game.stack:top(), "the blink is the top state, never under a message") +settle(ow) +eq(Game.stack:top(), ow, "STRENGTH ends on the map") + +-- ===================================================================== +-- .surf: ItemUseSurfboard's got-on text, then the blink that carries the +-- mount forward (#320 put the blink under the text, which is what left +-- the white rect as the backdrop) +-- ===================================================================== +Game.save.party = { mkMon("SQUIRTLE", "SURF") } +Game.save.inventory = { SOULBADGE = true } +ow = pushOW("PALLET_TOWN", 4, 13, "down") +ow.player.surfing = false +local pmSurf = PartyMenu.new(Game) +selectSubItem(pmSurf, 3) +check(isText(Game.stack:top()), "SURF opens _SurfingGotOnText") +eq(backdrop(), pmSurf, "the party menu is the backdrop of _SurfingGotOnText") +drainOne() +check(isBlink(Game.stack:top()), "the blink follows the got-on text") +eq(ow.player.surfing, true, "the mount happens with the blink, not before the text") +settle(ow) +eq(Game.stack:top(), ow, "SURF ends on the map") + +-- ===================================================================== +-- .cannotStopSurfing: prints and still closes, same layering +-- ===================================================================== +ow = pushOW("PALLET_TOWN", 4, 15, "down") +ow.player.surfing = true +local pmNoOff = PartyMenu.new(Game) +selectSubItem(pmNoOff, 3) +check(isText(Game.stack:top()), "a blocked dismount opens _SurfingNoPlaceToGetOffText") +eq(backdrop(), pmNoOff, "the party menu is the backdrop of the no-place message") +drainOne() +check(isBlink(Game.stack:top()), "the blink follows the no-place message") +settle(ow) +eq(Game.stack:top(), ow, "the blocked dismount ends on the map") +ow.player.surfing = false + +-- ===================================================================== +-- .flash: `xor a / ld [wMapPalOffset], a` is undone before PrintText in +-- the asm, but the map is not on screen then -- the party menu is -- so +-- the lit cave may only appear once the blink hands the screen back +-- ===================================================================== +Game.save.flashLit = false +Game.save.party = { mkMon("PIKACHU", "FLASH") } +Game.save.inventory = { BOULDERBADGE = true } +ow = pushOW("ROCK_TUNNEL_1F", 15, 4, "down") +eq(ow.dark, true, "ROCK_TUNNEL_1F loads dark before FLASH") +local pmFlash = PartyMenu.new(Game) +selectSubItem(pmFlash, 3) +check(isText(Game.stack:top()), "FLASH opens _FlashLightsAreaText") +eq(backdrop(), pmFlash, "the party menu is the backdrop of _FlashLightsAreaText") +eq(ow.dark, true, "the tunnel is still dark while the message is up") +drainOne() +check(isBlink(Game.stack:top()), "the blink follows the FLASH message") +eq(ow.dark, true, "the tunnel is still dark when the blink starts") +settle(ow) +eq(Game.stack:top(), ow, "FLASH ends on the map") +eq(ow.dark, false, "the tunnel is lit once the blink hands the map back") +eq(Game.save.flashLit, true, "FLASH is recorded on the save") + +popAll() +S.finish() diff --git a/tests/parity_hideout_gate.lua b/tests/parity_hideout_gate.lua new file mode 100644 index 00000000..c82ba8f9 --- /dev/null +++ b/tests/parity_hideout_gate.lua @@ -0,0 +1,182 @@ +-- Parity: the Rocket Hideout lift gates (#372). +-- +-- Oracle: scripts/RocketHideoutB4F.asm RocketHideoutB4FDoorCallbackScript +-- stamps $2d over the lift doorway (lb bc, 5, 12) until CheckBothEventsSet +-- EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0 / _1, then plays SFX_GO_INSIDE and +-- writes $e; scripts/RocketHideoutB1F.asm is the same shape with $54 at +-- lb bc, 8, 12 over EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4. The callback +-- runs whenever BIT_CUR_MAP_LOADED_1 is set, which home/trainers.asm +-- EndTrainerBattle does, so the gate opens the moment the last guard falls +-- rather than on the next map load. +-- +-- The .blk layouts ship both doorways open, so a cache whose closedDoors +-- predates the two rows left the gates open all game: FieldDefaults has to +-- seed them the way it seeds the PrintTrashText bins. +-- +-- Self-contained: `luajit tests/parity_hideout_gate.lua`; also globbed by +-- tests/run_tests.lua. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.ROCKET_HIDEOUT_B4F) then Data:load() end +local S = require("tests.harness").suite("parity hideout gate") +local check, eq = S.check, S.eq + +local FieldDefaults = require("src.world.FieldDefaults") + +local B4F, B1F = "ROCKET_HIDEOUT_B4F", "ROCKET_HIDEOUT_B1F" +local GUARD_0 = "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0" +local GUARD_1 = "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1" +local B1F_GUARD = "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4" + +-- ---- the shipped layout --------------------------------------------------- +-- ReplaceTileBlock only ever writes; nothing in the .blk closes these, which +-- is why a missing closedDoors row reads as a permanently open gate. +local function shippedBlock(mapId, bx, by) + local def = Data.maps[mapId] + return def.blocks[by * def.width + bx + 1] +end +eq(shippedBlock(B4F, 12, 5), 0x0e, "B4F ships the lift doorway open (block $e)") +eq(shippedBlock(B1F, 12, 8), 0x0e, "B1F ships its doorway open (block $e)") + +-- ---- the door rows ------------------------------------------------------- +local doors = FieldDefaults.fieldValue(Data, "cardKeyDoors", "closedDoors") +check(type(doors) == "table", "field.cardKeyDoors.closedDoors resolves") + +local b4 = doors[B4F] and doors[B4F][1] +check(b4 ~= nil, "B4F has a closed-door row") +if b4 then + eq(b4.bx, 12, "B4F door x (lb bc, 5, 12)") + eq(b4.by, 5, "B4F door y (lb bc, 5, 12)") + eq(b4.block, 0x2d, "B4F closed block is $2d") + eq(b4.open, 0x0e, "B4F open block is $e") + eq(b4.event, nil, "B4F is not gated on a single event") + eq(b4.events and b4.events[1], GUARD_0, "CheckBothEventsSet arg 1") + eq(b4.events and b4.events[2], GUARD_1, "CheckBothEventsSet arg 2") +end + +local b1 = doors[B1F] and doors[B1F][1] +check(b1 ~= nil, "B1F has a closed-door row") +if b1 then + eq(b1.bx, 12, "B1F door x (lb bc, 8, 12)") + eq(b1.by, 8, "B1F door y (lb bc, 8, 12)") + eq(b1.block, 0x54, "B1F closed block is $54") + eq(b1.open, 0x0e, "B1F open block is $e") + eq(b1.event, B1F_GUARD, "B1F waits on the fifth grunt") +end + +-- the events the rows name have to be the ones the guards actually set: +-- interact() writes trainerHeader().event on a win +eq(Data:trainerHeader("RocketHideoutB4F", 2).event, GUARD_0, + "the (23,12) guard sets trainer_0") +eq(Data:trainerHeader("RocketHideoutB4F", 3).event, GUARD_1, + "the (26,12) guard sets trainer_1") +eq(Data:trainerHeader("RocketHideoutB1F", 5).event, B1F_GUARD, + "B1F's fifth grunt sets trainer_4") + +-- ---- the stale cache the reporters have ---------------------------------- +-- closedDoors exists but holds only the Silph rows, so fieldValue's per-path +-- fallback resolves the cache's table and finds no hideout floors. seed() +-- fills map keys in place, which is what heals it. +do + local silph = { { block = 0x54, bx = 2, by = 2, open = 0x0e, + event = "EVENT_SILPH_CO_2_UNLOCKED_DOOR1" } } + local stale = { field = { cardKeyDoors = { doorTiles = { 24 }, + closedDoors = { SILPH_CO_2F = silph } } } } + check(FieldDefaults.fieldValue(stale, "cardKeyDoors", "closedDoors")[B4F] == nil, + "the stale cache really is missing the B4F row") + FieldDefaults.seed(stale) + local healed = FieldDefaults.fieldValue(stale, "cardKeyDoors", "closedDoors") + eq(healed[B4F] and healed[B4F][1].block, 0x2d, "seed fills the B4F gate") + eq(healed[B1F] and healed[B1F][1].block, 0x54, "seed fills the B1F gate") + eq(healed.SILPH_CO_2F, silph, "and leaves the cache's own rows alone") + eq(stale.field.cardKeyDoors.doorTiles[1], 24, "including the card-key tiles") +end + +-- ---- the live floor ------------------------------------------------------ +require("src.render.Font").load(Data) +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local StateStack = require("src.core.StateStack") +local Renderer = require("src.render.Renderer") +local SaveData = require("src.core.SaveData") +local Pokemon = require("src.pokemon.Pokemon") +local Sound = require("src.core.Sound") +local OW = require("src.world.OverworldController") + +Game.data = Data +Game.input = Input; Input:init() +Game.renderer = Renderer; Renderer:init() +Game.stack = StateStack; StateStack:init() +Game.save = SaveData.newGame() +Game.save.party = { Pokemon.new(Data, "CHARMANDER", 20) } + +local sfx = {} +local realPlay = Sound.play +Sound.play = function(_, id) sfx[#sfx + 1] = id end +local function goInsides() + local n = 0 + for _, id in ipairs(sfx) do if id == "Go_Inside" then n = n + 1 end end + return n +end + +-- Map:setBlock writes through to the shared Data record every later suite in +-- this process reads, so both gates are put back at the bottom of the file +local restore = {} +local function remember(mapId, bx, by) + restore[#restore + 1] = { mapId, bx, by, shippedBlock(mapId, bx, by) } +end +remember(B4F, 12, 5) +remember(B1F, 12, 8) + +local function arrive(mapId, x, y) + while Game.stack:top() do Game.stack:pop() end + Game.stack:push(OW, mapId, x, y, "up") + return Game.stack:top() +end + +-- one cell south of the gate, the way a player walks up to it +local ow = arrive(B4F, 24, 13) +eq(ow.map:blockAt(12, 5), 0x2d, "arriving with both guards alive bars the gate") +check(not ow.map:isWalkableCell(24, 11) and not ow.map:isWalkableCell(25, 11), + "and neither doorway cell can be stepped into") + +sfx = {} +Game.save.flags[GUARD_0] = true +ow:afterBattle("win", {}) +eq(ow.map:blockAt(12, 5), 0x2d, "one guard down leaves the gate barred") +eq(goInsides(), 0, "and plays no door sound") + +Game.save.flags[GUARD_1] = true +ow:afterBattle("win", {}) +eq(ow.map:blockAt(12, 5), 0x0e, + "the second guard opens the gate without leaving the floor") +eq(goInsides(), 1, "with one SFX_GO_INSIDE") +check(ow.map:isWalkableCell(24, 11) and ow.map:isWalkableCell(25, 11), + "the doorway cells are floor now") + +-- EVENT_ROCKET_HIDEOUT_4_DOOR_UNLOCKED: the stamp is a transition, not a +-- per-load sound +ow:afterBattle("win", {}) +eq(goInsides(), 1, "a later battle on the open floor is silent") +ow = arrive(B4F, 24, 13) +eq(ow.map:blockAt(12, 5), 0x0e, "re-entering the floor keeps it open") +eq(goInsides(), 1, "and does not re-play the door sound") + +-- ---- B1F, the same seed row --------------------------------------------- +sfx = {} +ow = arrive(B1F, 24, 17) +eq(ow.map:blockAt(12, 8), 0x54, "B1F is barred until the fifth grunt") +Game.save.flags[B1F_GUARD] = true +ow:afterBattle("win", {}) +eq(ow.map:blockAt(12, 8), 0x0e, "beating him opens it on the spot") +eq(goInsides(), 1, "with its own door sound") + +for _, r in ipairs(restore) do + local def = Data.maps[r[1]] + def.blocks[r[3] * def.width + r[2] + 1] = r[4] +end +Sound.play = realPlay +while Game.stack:top() do Game.stack:pop() end + +S.finish() diff --git a/tests/parity_silph_rockets.lua b/tests/parity_silph_rockets.lua new file mode 100644 index 00000000..e5fcc596 --- /dev/null +++ b/tests/parity_silph_rockets.lua @@ -0,0 +1,217 @@ +-- Parity: Team Rocket leaves Silph Co, and the president keeps talking (#392). +-- +-- Oracle: scripts/SilphCo11F.asm SilphCo11FTeamRocketLeavesScript walks +-- .HideToggleableObjectIDs (TOGGLE_SILPH_CO_2F_2..11F_3 plus the Saffron +-- street set, which M.SAFFRON_CITY handles) through predef HideObject just +-- before SetEvent EVENT_BEAT_SILPH_CO_GIOVANNI. The ordinals come from +-- constants/toggle_constants.asm and name the objects listed per floor in +-- data/maps/toggleable_objects.asm, so the item balls, the rescued 2F/10F +-- workers and the 7F rival keep their sprites. HideObject writes +-- wMissableObjectFlags, which the port keeps in save.objectToggles. +-- +-- Oracle: scripts/SilphCo11F.asm SilphCo11FSilphPresidentText branches on +-- EVENT_GOT_MASTER_BALL alone -- nz jumps to .got_item, which prints +-- _SilphCo11FSilphPresidentMasterBallDescriptionText -- so every later talk +-- prints something. +-- +-- Self-contained: `luajit tests/parity_silph_rockets.lua`; also globbed by +-- tests/run_tests.lua. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end + +local S = require("tests.harness").suite("parity silph rockets") +local check, eq = S.check, S.eq + +local MapScripts = require("src.script.MapScripts") +require("data.scripts.init") +local Commands = require("src.script.Commands") +local ScriptRunner = require("src.script.ScriptRunner") +local OverworldState = require("src.world.OverworldController") + +-- .HideToggleableObjectIDs, floor by floor, in the order the asm lists them +local HIDDEN = { + { "SILPH_CO_2F", { "SILPHCO2F_SCIENTIST1", "SILPHCO2F_SCIENTIST2", + "SILPHCO2F_ROCKET1", "SILPHCO2F_ROCKET2" } }, + { "SILPH_CO_3F", { "SILPHCO3F_ROCKET", "SILPHCO3F_SCIENTIST" } }, + { "SILPH_CO_4F", { "SILPHCO4F_ROCKET1", "SILPHCO4F_SCIENTIST", + "SILPHCO4F_ROCKET2" } }, + { "SILPH_CO_5F", { "SILPHCO5F_ROCKET1", "SILPHCO5F_SCIENTIST", + "SILPHCO5F_ROCKER", "SILPHCO5F_ROCKET2" } }, + { "SILPH_CO_6F", { "SILPHCO6F_ROCKET1", "SILPHCO6F_SCIENTIST", + "SILPHCO6F_ROCKET2" } }, + { "SILPH_CO_7F", { "SILPHCO7F_ROCKET1", "SILPHCO7F_SCIENTIST", + "SILPHCO7F_ROCKET2", "SILPHCO7F_ROCKET3" } }, + { "SILPH_CO_8F", { "SILPHCO8F_ROCKET1", "SILPHCO8F_SCIENTIST", + "SILPHCO8F_ROCKET2" } }, + { "SILPH_CO_9F", { "SILPHCO9F_ROCKET1", "SILPHCO9F_SCIENTIST", + "SILPHCO9F_ROCKET2" } }, + { "SILPH_CO_10F", { "SILPHCO10F_ROCKET", "SILPHCO10F_SCIENTIST" } }, + { "SILPH_CO_11F", { "SILPHCO11F_GIOVANNI", "SILPHCO11F_ROCKET1", + "SILPHCO11F_ROCKET2" } }, +} + +-- toggleable objects the hide list deliberately skips +local KEPT = { + { "SILPH_CO_2F", "SILPHCO2F_SILPH_WORKER_F" }, + { "SILPH_CO_3F", "SILPHCO3F_HYPER_POTION" }, + { "SILPH_CO_5F", "SILPHCO5F_CARD_KEY" }, + { "SILPH_CO_7F", "SILPHCO7F_RIVAL" }, + { "SILPH_CO_7F", "SILPHCO7F_TM_SWORDS_DANCE" }, + { "SILPH_CO_10F", "SILPHCO10F_SILPH_WORKER_F" }, + { "SILPH_CO_11F", "SILPHCO11F_BEAUTY" }, +} + +local function objOf(mapId, name) + for _, o in ipairs(Data.maps[mapId].objects) do + if o.name == name then return o end + end +end + +local function newSave(beat) + return { + flags = beat and { EVENT_BEAT_SILPH_CO_GIOVANNI = true } or {}, + inventory = {}, objectToggles = {}, itemsTaken = {}, + defeatedTrainers = {}, + } +end + +-- every name in both lists has to be a real object_event, or the toggle +-- write lands on a key nothing reads +for _, floor in ipairs(HIDDEN) do + for _, name in ipairs(floor[2]) do + check(objOf(floor[1], name) ~= nil, + name .. " is an object_event on " .. floor[1]) + end +end +for _, kept in ipairs(KEPT) do + check(objOf(kept[1], kept[2]) ~= nil, + kept[2] .. " is an object_event on " .. kept[1]) +end + +-- ---------------------------------------------------------------- hide pass +-- 11F's onEnter is the whole pass: it runs on the post-battle callback's +-- floor and repairs saves that beat Giovanni before the list existed +local function enter(mapId, save) + local view = MapScripts.get(mapId) + check(view and type(view.onEnter) == "function", + mapId .. " has an onEnter hook") + if view and view.onEnter then view.onEnter({ save = save }, nil) end +end + +do + local save = newSave(true) + enter("SILPH_CO_11F", save) + local hidden = 0 + for _, floor in ipairs(HIDDEN) do + for _, name in ipairs(floor[2]) do + eq(save.objectToggles[floor[1]] and save.objectToggles[floor[1]][name], + false, name .. " hidden by the 11F pass") + check(not OverworldState.objectVisible(save, floor[1], + objOf(floor[1], name)), + name .. " no longer spawns") + hidden = hidden + 1 + end + end + eq(hidden, 31, "the asm hides 31 Silph objects on 2F-11F") + + for _, kept in ipairs(KEPT) do + eq(save.objectToggles[kept[1]] and save.objectToggles[kept[1]][kept[2]], + nil, kept[2] .. " is not in .HideToggleableObjectIDs") + check(OverworldState.objectVisible(save, kept[1], objOf(kept[1], kept[2])), + kept[2] .. " still spawns") + end +end + +-- each floor repairs itself on entry and touches no other floor, so a save +-- that walks back in one elevator ride at a time still clears +for _, floor in ipairs(HIDDEN) do + local save = newSave(true) + enter(floor[1], save) + for _, name in ipairs(floor[2]) do + eq(save.objectToggles[floor[1]][name], false, + name .. " hidden on entering " .. floor[1]) + end + if floor[1] ~= "SILPH_CO_11F" then + local others = 0 + for mapId in pairs(save.objectToggles) do + if mapId ~= floor[1] then others = others + 1 end + end + eq(others, 0, floor[1] .. " onEnter writes only its own floor") + end +end + +-- before the win nothing is hidden: the grunts are still battleable +for _, floor in ipairs(HIDDEN) do + local save = newSave(false) + enter(floor[1], save) + eq(next(save.objectToggles), nil, + floor[1] .. " hides nothing while the event is unset") + for _, name in ipairs(floor[2]) do + check(OverworldState.objectVisible(save, floor[1], objOf(floor[1], name)), + name .. " still spawns before Giovanni falls") + end +end + +-- ---------------------------------------------------------------- president +local rows = MapScripts.get("SILPH_CO_11F").talk.TEXT_SILPHCO11F_SILPH_PRESIDENT +check(type(rows) == "table", "the president has a hand-ported talk script") + +-- the shipped bug: the last row jumped past the end of the script, which +-- validate reports and every branch walked into +local problems = ScriptRunner.validate(rows) +eq(#problems, 0, "president script validates: " .. table.concat(problems, "; ")) + +for _, row in ipairs(rows) do + check(row[2] ~= "EVENT_BEAT_SILPH_CO_GIOVANNI", + "no Giovanni gate: the teleport pads reach him without the trigger") +end + +for _, key in ipairs({ "_SilphCo11FSilphPresidentText", + "_SilphCo11FSilphPresidentReceivedMasterBallText", + "_SilphCo11FSilphPresidentMasterBallDescriptionText" }) do + local body = Data.text[key] + check(type(body) == "string" and body ~= "", key .. " is extracted") +end + +-- run the rows through the real interpreter with only the leaf commands +-- stubbed, so the branch arithmetic is what is under test +local shown, given +Commands.face_player = function() end +Commands.show_text = function(_, key) shown[#shown + 1] = key end +Commands.give_item = function(_, item, count) given = { item, count } end + +local function talk(save) + shown, given = {}, nil + local runner = ScriptRunner.new({ data = Data, save = save }, nil) + runner:exec(rows, runner:makeContext({})) + return shown, given +end + +do + local save = newSave(true) + local first = talk(save) + eq(first[1], "_SilphCo11FSilphPresidentText", "first talk thanks the player") + eq(given and given[1], "MASTER_BALL", "first talk hands over a MASTER BALL") + eq(given and given[2], 1, "one ball, as in lb bc, MASTER_BALL, 1") + eq(first[2], "_SilphCo11FSilphPresidentReceivedMasterBallText", + "the got-item line prints after the give (sound_get_key_item rides it)") + eq(#first, 2, "the description is not printed on the same talk") + eq(save.flags.EVENT_GOT_MASTER_BALL, true, "SetEvent EVENT_GOT_MASTER_BALL") + + local second, secondGive = talk(save) + eq(second[1], "_SilphCo11FSilphPresidentMasterBallDescriptionText", + "second talk describes the ball instead of going silent") + eq(#second, 1, "and prints nothing else") + eq(secondGive, nil, "a second ball is not handed out") +end + +-- the ball does not depend on Giovanni's coordinate trigger having fired +do + local fresh = talk(newSave(false)) + eq(fresh[1], "_SilphCo11FSilphPresidentText", + "an unbeaten-Giovanni save still gets the thank-you and the ball") +end + +S.finish() diff --git a/tests/parity_silph_tm36_pre.lua b/tests/parity_silph_tm36_pre.lua new file mode 100644 index 00000000..b7722dd3 --- /dev/null +++ b/tests/parity_silph_tm36_pre.lua @@ -0,0 +1,110 @@ +-- Parity test: the Silph Co 2F worker begs before she hands over TM36. +-- +-- scripts/SilphCo2F.asm SilphCo2FSilphWorkerFText prints .PleaseTakeThisText +-- (text/SilphCo2F.asm:1, "Eeek!" / "No! Stop! Help!" ... "please take this!") +-- and only then calls GiveItem, so the scared line always comes first and the +-- second visit never repeats it. The port skipped it because that label +-- carries no leading underscore, so the manifest harvester dropped the string +-- and the gift() row had no `pre` to print (#393). +-- +-- Self-contained; run via `luajit tests/parity_silph_tm36_pre.lua`, and also +-- dofile'd by tests/run_tests.lua's parity aggregator. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.SILPH_CO_2F) then Data:load() end +require("src.render.Font").load(Data) + +local S = require("tests.harness").suite("parity silph tm36 pre text") +local check, eq = S.check, S.eq + +local SaveData = require("src.core.SaveData") +local TextBox = require("src.render.TextBox") + +local PRE = "SilphCo2FSilphWorkerFPleaseTakeThisText" +local pre = Data.text[PRE] +check(type(pre) == "string" and pre ~= "", PRE .. " is extracted") +if type(pre) == "string" then + check(pre:find("Eeek!", 1, true) ~= nil, "it opens on \"Eeek!\"") + check(pre:find("with TEAM ROCKET.", 1, true) ~= nil, + "it works out the player is not a Rocket") + check(pre:find("please take this!", 1, true) ~= nil, + "it ends on \"please take this!\"") +end + +local handler = require("data.scripts.story5") + .SILPH_CO_2F.talk.TEXT_SILPHCO2F_SILPH_WORKER_F +check(type(handler) == "function", "SILPH_CO_2F wires the worker's talk entry") + +local stack = {} +local game = { + data = Data, + save = SaveData.newGame(), + stack = { + push = function(_, state) stack[#stack + 1] = state end, + pop = function() return table.remove(stack) end, + top = function() return stack[#stack] end, + }, +} +game.save.player.name = "RED" + +local function pages(box) + local out = {} + for _, page in ipairs(box.pages or {}) do + for _, line in ipairs(page) do out[#out + 1] = line end + end + return table.concat(out, " / ") +end + +-- runs one conversation to its end, returning the boxes in the order the +-- player reads them; `onBox` sees each box before its A press lands, which is +-- how the bag is inspected while the first box is still up +local function converse(onBox) + stack = {} + local read, done = {}, false + handler(game, nil, nil, function() done = true end) + for _ = 1, 8 do + local box = stack[#stack] + if not box then break end + check(getmetatable(box) == TextBox, "the worker pushes a TextBox") + read[#read + 1] = pages(box) + if onBox then onBox(#read, box) end + if done or not box.onDone then break end + stack[#stack] = nil + box.onDone() + end + check(done, "the conversation runs to its callback") + return read +end + +local function bagHasTm() + return (game.save.inventory or {}).TM_SELFDESTRUCT ~= nil +end + +local first = converse(function(n) + if n == 1 then + check(not bagHasTm(), + "the TM is still hers while the first box is up") + check(not game.save.flags.EVENT_GOT_TM36, + "EVENT_GOT_TM36 is still unset while the first box is up") + end +end) + +check((first[1] or ""):find("Eeek!", 1, true) ~= nil, + "the first box the player reads is the scared line") +local joined = table.concat(first, " | ") +check(joined:find("TM36", 1, true) ~= nil, "the run reaches the TM36 boxes") +check(joined:find("SELFDESTRUCT!", 1, true) ~= nil, + "and the SELFDESTRUCT explanation") +check(bagHasTm(), "TM36 is in the bag afterwards") +check(game.save.flags.EVENT_GOT_TM36, "EVENT_GOT_TM36 is set afterwards") + +-- second visit: SilphCo2F.asm jumps straight to the explanation +local again = converse() +eq(#again, 1, "talking again is one box") +check((again[1] or ""):find("SELFDESTRUCT!", 1, true) ~= nil, + "and it is the TM36 explanation") +check((again[1] or ""):find("Eeek!", 1, true) == nil, + "the scared line does not play twice") + +S.finish() diff --git a/tests/parity_ss_anne_departure.lua b/tests/parity_ss_anne_departure.lua new file mode 100644 index 00000000..b5ac20d1 --- /dev/null +++ b/tests/parity_ss_anne_departure.lua @@ -0,0 +1,275 @@ +-- Parity: the S.S. Anne rival goodbye and her departure from the dock +-- (#360). scripts/SSAnne2F.asm SSAnne2FRivalAfterBattleScript prints +-- TEXT_SSANNE2F_RIVAL_CUT_MASTER and walks him out DOWNWARD, keyed on the +-- player's X; scripts/VermilionDock.asm VermilionDockSSAnneLeavesScript +-- delays 120, blows SFX_SS_ANNE_HORN, shifts her eight columns west, then +-- VermilionDock_EraseSSAnne blows the horn again and delays 120 more. +-- +-- luajit tests/parity_ss_anne_departure.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local S = require("tests.harness").suite("parity ss anne departure") +local check, eq = S.check, S.eq + +-- restored at the bottom for the suites run after this file +local realMusic = package.loaded["src.core.Music"] +local realTextBox = package.loaded["src.render.TextBox"] +local realPicBox = package.loaded["src.ui.PicBox"] +local music = { played = {} } +package.loaded["src.core.Music"] = { + play = function(_, id) music.played[#music.played + 1] = id end, + playOnce = function() return true end, + stop = function() music.played[#music.played + 1] = "stop" end, +} +package.loaded["src.render.TextBox"] = { + new = function(_, text) return { text = text } end, +} +package.loaded["src.ui.PicBox"] = { new = function() return {} end } + +local story3 = dofile("data/scripts/story3.lua") +local story5 = dofile("data/scripts/story5.lua") +local text = dofile("data/generated/text.lua") +local audio = dofile("data/generated/audio.lua") +local maps = dofile("data/generated/maps.lua") + +local function dirsEqual(a, b) + if type(a) ~= "table" or #a ~= #b then return false end + for i = 1, #b do if a[i] ~= b[i] then return false end end + return true +end + +local function rowsOfKind(rows, kind) + local out = {} + for _, r in ipairs(rows) do + if r[1] == kind then out[#out + 1] = r end + end + return out +end + +-- ------------------------------------------------------------- rival exit +-- SSAnne2F.asm .RivalDownFourMovement (player on 37) and +-- .RivalWalkAroundPlayerMovement, which falls through into those same four +-- DOWNs (player on 36). +local RIGHT_OF_HIM = { "down", "down", "down", "down" } +local AROUND_HIM = { "right", "down", "down", "down", "down", "down" } + +local function ambush(x) + local rows + local ow = { + runner = { + isRunning = function() return false end, + run = function(_, r) rows = r end, + }, + player = { facing = "down" }, + } + local game = { save = { flags = {} }, data = {} } + check(story5.SS_ANNE_2F.onStep(game, ow, x, 8), + ("SS Anne 2F ambush fires at (%d,8)"):format(x)) + check(rows ~= nil, "the ambush queued rows") + return rows +end + +do + check(type(text._SSAnne2FRivalCutMasterText) == "string", + "_SSAnne2FRivalCutMasterText is in the cache") + check(text._SSAnne2FRivalCutMasterText:find("CUT", 1, true) ~= nil, + "the goodbye line is the CUT master one") + + for _, case in ipairs({ { 37, RIGHT_OF_HIM }, { 36, AROUND_HIM } }) do + local x, dirs = case[1], case[2] + local rows = ambush(x) + local said = {} + for _, r in ipairs(rowsOfKind(rows, "show_text")) do + said[#said + 1] = r[2] + end + local order = { + "_SSAnne2FRivalText", + "_SSAnne2FRivalDefeatedText", + "_SSAnne2FRivalCutMasterText", + } + local pi = 1 + for _, id in ipairs(said) do + if pi <= #order and id == order[pi] then pi = pi + 1 end + end + eq(pi, #order + 1, + ("x=%d text order: greeting, defeated, CUT master"):format(x)) + + local walk = rowsOfKind(rows, "walk_npc")[1] + check(walk ~= nil, ("x=%d exit is a walk_npc list"):format(x)) + check(dirsEqual(walk and walk[3], dirs), + ("x=%d exit walk matches the pokered movement data"):format(x)) + -- the port used to send him back to his (36,4) spawn by the + -- captain's-room stairs instead of out of the room + for _, r in ipairs(rowsOfKind(rows, "move_npc_to")) do + check(not (r[3] == 36 and r[4] == 4), + ("x=%d rival must not retreat to the spawn (36,4)"):format(x)) + end + -- jump_if_false on a lost battle has to clear the whole tail + local jump = rowsOfKind(rows, "jump_if_false")[1] + eq(jump and jump[2], #rows, "a lost battle jumps past the exit walk") + end +end + +-- --------------------------------------------------------------- the ship +-- data/maps/objects/VermilionDock.asm: the SS_ANNE_1F gangway warp is +-- (14,2), i.e. block (7,1), and hlowcoord 5, 2 is the hull's own block box. +local DOCK_HULL = { x0 = 5, x1 = 8, y0 = 1, y1 = 2 } +local WATER = { [1] = true, [13] = true } + +local function dockBlock(bx, by) + local def = maps.VERMILION_DOCK + return def.blocks[by * def.width + bx + 1] +end + +local function sail(cellX, cellY) + local rows, puffs = nil, 0 + local ow = { + player = { cellX = cellX, cellY = cellY }, + startDustAnim = function(_, _, _, done) + puffs = puffs + 1 + if done then done() end + end, + queueScript = function(_, r) rows = r end, + } + local game = { + save = { flags = { EVENT_GOT_HM01 = true } }, + data = { text = text }, + } + story3.VERMILION_DOCK.onEnter(game, ow) + check(rows ~= nil, "stepping off the gangway queues the departure") + check(game.save.flags.EVENT_SS_ANNE_LEFT == true, "EVENT_SS_ANNE_LEFT set") + eq(puffs, 3, "three funnel smoke puffs (LoadSmokeTileFourTimes)") + return rows +end + +do + -- the hull ids the slide reuses are the map's own blocks, so a data + -- rebuild that renumbered the tileset would be caught here + eq(dockBlock(DOCK_HULL.x0, 1), 4, "bow upper-half block id") + eq(dockBlock(DOCK_HULL.x1, 2), 11, "stern lower-half block id") + check(WATER[dockBlock(2, 1)] and WATER[dockBlock(2, 2)], + "the water she sails into is blocks 1 (upper) and 13 (lower)") + eq(audio.mapSongs.VERMILION_CITY, "Music_Vermilion", + "the city has its own theme for PlayDefaultMusic to switch to") + check(audio.songs.Music_Vermilion ~= nil, "and that song is in the cache") + + local rows = sail(14, 2) + + local horns = 0 + for _, r in ipairs(rowsOfKind(rows, "play_sound")) do + if r[2] == "SS_Anne_Horn" then horns = horns + 1 end + end + eq(horns, 2, "the horn blows twice: leaving, then once she is gone") + + local waits = rowsOfKind(rows, "wait") + eq(waits[1][2], 120, "120 frames before the first horn") + eq(waits[#waits][2], 120, "EraseSSAnne's 120 frames before the walk out") + local slide = 0 + for _, w in ipairs(waits) do + if w[2] == 20 then slide = slide + 1 end + end + eq(slide, 8, "eight column shifts, .shift_columns_up's ld e, $8") + + -- the bug was the whole hull blinking to water in a single frame with no + -- travel at all: her bow block has to be written one column further west + -- each step, and the water has to close in astern behind her + local bow, wake = {}, {} + for _, r in ipairs(rowsOfKind(rows, "replace_block")) do + if r[3] == 1 and r[4] == dockBlock(DOCK_HULL.x0, 1) then + bow[#bow + 1] = r[2] + elseif r[3] == 1 and r[4] == 1 then + wake[#wake + 1] = r[2] + 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 }), + "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 + 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 + end + end + + -- and she has to have travelled: the westmost water column of the map + -- carried hull blocks partway through + local sawWest = false + for _, r in ipairs(rowsOfKind(rows, "replace_block")) do + if r[2] == 1 and not WATER[r[4]] then sawWest = true end + end + check(sawWest, "the hull reaches the west edge of the water before it goes") +end + +do + -- fix 3: opts.keep on play_music sets keepMusicOnce, which + -- OverworldController:setMap consumes to SKIP the destination map's + -- theme -- the dock's Music_Surfing must not ride into Vermilion City + local rows = sail(14, 2) + for _, r in ipairs(rowsOfKind(rows, "play_music")) do + check(not (r[3] and r[3].keep), + "no keepMusicOnce override on the way into the city") + end + local warp = rowsOfKind(rows, "warp")[1] + check(warp ~= nil and warp[2] == "VERMILION_CITY", "the cutscene warps into town") + check(not (warp[6] and warp[6].keepMusic), "the warp itself keeps no music") + eq(music.played[#music.played], "Music_Surfing", "the sail-away plays surf") +end + +do + -- coming back later: no ghost hull, just the sailor's line and a bounce + -- back into the city + local set, rebuilt, pushed, warped = {}, false, nil, nil + local ow = { + player = { cellX = 14, cellY = 2 }, + map = { + setBlock = function(_, bx, by, block) set[bx .. "," .. by] = block end, + renderer = { rebuild = function() rebuilt = true end }, + }, + startWarpTo = function(_, m) warped = m end, + } + local game = { + save = { flags = { EVENT_SS_ANNE_LEFT = true } }, + data = { text = text }, + stack = { push = function(_, s) pushed = s end }, + } + story3.VERMILION_DOCK.onEnter(game, ow) + for bx = DOCK_HULL.x0, DOCK_HULL.x1 do + for by = DOCK_HULL.y0, DOCK_HULL.y1 do + check(WATER[set[bx .. "," .. by]], + ("re-entry: (%d,%d) is water, not a ghost hull"):format(bx, by)) + end + end + check(rebuilt, "re-entry rebuilds the tile renderer") + check(pushed ~= nil and type(pushed.text) == "string", + "re-entry shows the ship-set-sail line") + pushed.text = pushed.text or "" + eq(pushed.text, text._VermilionCitySailor1ShipSetSailText, + "and it is the sailor's own line") +end + +package.loaded["src.core.Music"] = realMusic +package.loaded["src.render.TextBox"] = realTextBox +package.loaded["src.ui.PicBox"] = realPicBox + +S.finish() diff --git a/tests/parity_ss_anne_guard.lua b/tests/parity_ss_anne_guard.lua index 23b36524..9700e9ff 100644 --- a/tests/parity_ss_anne_guard.lua +++ b/tests/parity_ss_anne_guard.lua @@ -81,7 +81,7 @@ end local function owWith(moved) return { - player = { facing = "down", cellY = 2 }, + player = { facing = "down", cellX = 14, cellY = 2 }, scriptMove = function(_, _, dir, n) moved[#moved + 1] = { dir, n } end, queueScript = function(self, rows) self._queued = rows end, startDustAnim = function() end, @@ -178,18 +178,23 @@ do end check(sawSurf, "departure plays Music_Surfing") check(ow._queued ~= nil, "departure queues the sail-away script") - local kept, horn = false, false + -- #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 + local kept, horns, slid, underPlayer = false, 0, 0, false for _, row in ipairs(ow._queued or {}) do - if row[1] == "play_music" and row[2] == "Music_Surfing" - and row[3] and row[3].keep then - kept = true - end + if row[1] == "play_music" and row[3] and row[3].keep then kept = true end if row[1] == "play_sound" and row[2] == "SS_Anne_Horn" then - horn = true + horns = horns + 1 + end + if row[1] == "replace_block" then + slid = slid + 1 + if row[2] == 7 and row[3] == 1 then underPlayer = true end end end - check(kept, "departure keeps Music_Surfing across the city warp") - check(horn, "departure queues SS_Anne_Horn") + 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") end -- Captain rub jingle: play_once Music_PkmnHealed sits after the rub text. diff --git a/tests/parity_warp_after_warp_step.lua b/tests/parity_warp_after_warp_step.lua index 75e494db..66f7d279 100644 --- a/tests/parity_warp_after_warp_step.lua +++ b/tests/parity_warp_after_warp_step.lua @@ -2,8 +2,10 @@ -- under the player's feet (#265). home/overworld.asm:391 CheckWarpsNoCollision -- runs on EVERY completed step with no first-step-after-a-warp counter, so the -- arrival disable is POSITIONAL (the cell you came in on is inert until you --- leave it), which is what warpEntryCell models. #230's stand-still bonk guard, --- still backed by justWarped, is asserted here too. +-- leave it), which is what warpEntryCell models. The two STAND-STILL triggers +-- answer to BIT_STANDING_ON_WARP instead, which the departing tile decides and +-- the warp carries over: cleared by a staircase (#230), kept by a door tile so +-- an exit mat works on the tile you land on (#378). Both are asserted here. package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end @@ -48,15 +50,18 @@ Game.overworld = OW -- these two fields once setMap has placed the player (OverworldController.lua, -- inside the Transition callback). takeWarp is stubbed per instance so the -- assertion is which warp the engine decided to take, with no Transition to pump. -local function arriveOn(mapId, x, y, facing) +local function arriveOn(mapId, x, y, facing, standingOnWarp) Input:reset() while Game.stack:top() do Game.stack:pop() end Game.stack:push(OW, mapId, x, y, facing or "down") local ow = Game.stack:top() ow.player.moving = false ow.player.turnTimer = 0 - ow.justWarped = true ow.warpEntryCell = { x = x, y = y } + -- BIT_STANDING_ON_WARP is whatever the tile we LEFT made it: a door tile + -- leaves it set, a stair/ladder tile clears it, and the map change does not + -- touch wMovementFlags. + ow.standingOnWarp = standingOnWarp and true or false ow.taken = nil ow.takeWarp = function(self, def) self.taken = def end return ow @@ -77,16 +82,16 @@ end -- onto the adjacent ladder at (25,4). do local ow = arriveOn("SEAFOAM_ISLANDS_B3F", 25, 3, "down") - check(ow:onWarpArrivalCell(), "the cell just arrived on is inert") + check(ow.warpEntryCell ~= nil, "the cell just arrived on is inert") stepTo(ow, 25, 4, "down") check(ow.taken ~= nil, "the step onto (25,4) fires a warp") eq(ow.taken and ow.taken.destMap, "SEAFOAM_ISLANDS_B4F", "and it is the ladder down to B4F") - check(ow.justWarped == false, "the arrival record is cleared by that step") + check(ow.warpEntryCell == nil, "the arrival record is cleared by that step") end -- The arrival cell stays inert while you stand on it, even if a scripted nudge --- re-runs the step handler there. That is warpEntryCell's job, not justWarped's. +-- re-runs the step handler there. That is warpEntryCell's job. do local ow = arriveOn("SEAFOAM_ISLANDS_B3F", 25, 3, "down") stepTo(ow, 25, 3, "down") @@ -106,12 +111,14 @@ do eq(ow.taken and ow.taken.destMap, "SEAFOAM_ISLANDS_B2F", "back up to B2F") end --- #230 must not come back: justWarped still guards the two STAND-STILL warp --- triggers, where no step ever completes. Red's house 2F staircase is on the --- map's east edge, so pushing into that edge has to bonk, not bounce floors. +-- #230 must not come back: the staircase tile ($1A/$1C, a warp tile that is +-- not a door tile) clears BIT_STANDING_ON_WARP on the step that takes it, so +-- the two STAND-STILL triggers stay shut on arrival. Red's house 2F staircase +-- is on the map's east edge, so pushing into that edge has to bonk, not bounce. do - local ow = arriveOn("REDS_HOUSE_2F", 7, 1, "down") - check(ow:onWarpArrivalCell(), "the staircase arrival cell reports inert") + local ow = arriveOn("REDS_HOUSE_2F", 7, 1, "down", false) + check(ow:canCollisionWarp() == false, + "the staircase arrival leaves BIT_STANDING_ON_WARP clear") check(ow:checkEdgeExit("right") == false, "pushing east off the map edge from it does not warp (#230)") Input.state.right = true @@ -138,4 +145,21 @@ do Input:reset() end +-- #378: the exit mat you warped IN on is NOT inert for the collision paths. +-- REDS_HOUSE_1F's mat (2,7) is tile $14 -- neither a door tile nor a warp tile, +-- so nothing clears the flag that Pallet Town's door tile ($1B, a door tile) +-- set on the way in, and the first press of DOWN walks straight back outside. +do + local reds1 = MapLoader.load(Data, "REDS_HOUSE_1F") + eq(reds1:cellTile(2, 7), 0x14, "REDS_HOUSE_1F (2,7) is the exit mat tile") + check(not reds1:isWarpTileCell(2, 7), "the mat is not a warp-activating tile") + eq(reds1.def.height * 2, 8, + "REDS_HOUSE_1F is 8 cells tall, so y=7 is the south edge") + + local ow = arriveOn("REDS_HOUSE_1F", 2, 7, "down", true) + check(ow:checkEdgeExit("down") == true, + "pressing DOWN on the mat just arrived on exits the house (#378)") + eq(ow.taken and ow.taken.destMap, "LAST_MAP", "back out the way we came") +end + S.finish() diff --git a/tests/parity_warp_standing_flag.lua b/tests/parity_warp_standing_flag.lua new file mode 100644 index 00000000..421f5915 --- /dev/null +++ b/tests/parity_warp_standing_flag.lua @@ -0,0 +1,142 @@ +-- Regression: BIT_STANDING_ON_WARP is decided by the tile you LEAVE and is +-- carried across the warp, so the exit mat you land on works on that same tile +-- (#378). ClearVariablesOnEnterMap (engine/overworld/clear_variables.asm) +-- never touches wMovementFlags, and IsPlayerStandingOnDoorTileOrWarpTile +-- (engine/overworld/player_state.asm) only clears the bit for a +-- warp-activating tile that is NOT a door tile -- which is why a house door +-- ($1B) leaves it set for the mat inside and a staircase ($1A/$1C) clears it +-- (#230). This file pins the flag itself: how it is derived, that a map +-- change preserves it, and what each of the two collision paths then does. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.REDS_HOUSE_1F) then Data:load() end + +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local MapLoader = require("src.world.MapLoader") +local Renderer = require("src.render.Renderer") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") +local OW = require("src.world.OverworldController") +local S = require("tests.harness").suite("parity warp standing flag") +local check, eq = S.check, S.eq + +-- ---- ground truth: the three tiles that decide the flag ------------------- +-- data/tilesets/door_tile_ids.asm .OverworldDoorTileIDs lists $1B; REDS_HOUSE_1 +-- has no door entry and .RedsHouse1WarpTileIDs is $1A/$1C; POKECENTER has +-- neither list. pokered data/maps/objects/{PalletTown,RedsHouse1F, +-- ViridianPokecenter}.asm give the warp cells. +local pallet = MapLoader.load(Data, "PALLET_TOWN") +eq(pallet:cellTile(5, 5), 0x1B, "Red's front door in Pallet Town is tile $1B") +check(pallet:isDoorTileCell(5, 5), "$1B is a door tile, so the flag stays set") +local doorWarp = pallet:warpAtCell(5, 5) +eq(doorWarp and doorWarp.def.destMap, "REDS_HOUSE_1F", "and it leads inside") + +local reds1 = MapLoader.load(Data, "REDS_HOUSE_1F") +eq(reds1:cellTile(2, 7), 0x14, "the exit mat inside is tile $14") +check(reds1:warpAtCell(2, 7) ~= nil, "the mat cell carries a warp entry") +check(not reds1:isWarpTileCell(2, 7), + "but it is neither a door nor a warp-activating tile, so nothing clears") +eq(reds1.heightCells, 8, "the map is 8 cells tall, so the mat row is the edge") +check(reds1:isWarpTileCell(7, 1) and not reds1:isDoorTileCell(7, 1), + "the staircase at (7,1) is a warp tile and not a door tile") + +local center = MapLoader.load(Data, "VIRIDIAN_POKECENTER") +eq(center:cellTile(3, 7), 0x1C, "the Poke Center mat is tile $1C") +check(center:warpAtCell(3, 7) ~= nil, "the mat cell carries a warp entry") +check(not center:isWarpTileCell(3, 7), + "POKECENTER lists no warp tiles, so $1C does not clear the flag here") +eq(center.heightCells, 8, "and its mat row is the south edge too") + +-- ---- live engine --------------------------------------------------------- +Game.data = Data +Game.input = Input; Input:init() +Game.renderer = Renderer; Renderer:init() +Game.stack = StateStack; StateStack:init() +Game.save = SaveData.newGame() +Game.overworld = OW + +-- Boot the overworld somewhere with no fade to pump. takeWarp is stubbed per +-- boot so the assertion is which warp the engine decided to take. +local function bootOn(mapId, x, y, facing) + Input:reset() + while Game.stack:top() do Game.stack:pop() end + Game.stack:push(OW, mapId, x, y, facing or "down") + local ow = Game.stack:top() + ow.player.moving = false + ow.player.turnTimer = 0 + ow.taken = nil + ow.takeWarp = function(self, def) self.taken = def end + return ow +end + +-- A loaded save standing on the mat can still walk out: enter() re-derives the +-- flag the way MapEntryAfterBattle's IsPlayerStandingOnWarp does. +do + local ow = bootOn("REDS_HOUSE_1F", 2, 7, "down") + check(ow:canCollisionWarp(), "a save loaded on the mat has the flag set") + check(ow:checkEdgeExit("down"), "so DOWN on the mat leaves the house") + eq(ow.taken and ow.taken.destMap, "LAST_MAP", "out to the map we came from") +end + +-- The same derivation on a staircase clears it, so a save loaded upstairs +-- cannot be bounced back down by pushing into the east edge (#230). +do + local ow = bootOn("REDS_HOUSE_2F", 7, 1, "down") + check(ow:canCollisionWarp() == false, + "a save loaded on the staircase has the flag clear") + check(ow:checkEdgeExit("right") == false, "pushing east off the edge bonks") + check(ow.taken == nil, "and takes no warp") +end + +-- The whole of #378: walk onto the door tile outside, warp in, and the flag the +-- door set is still there on the mat. setMap is the map change startWarpTo +-- performs, and it must leave the flag alone. +do + local ow = bootOn("PALLET_TOWN", 5, 6, "up") + check(ow:canCollisionWarp() == false, "the grass south of the door is inert") + local p = ow.player + p.cellX, p.cellY, p.facing = 5, 5, "up" + p.px, p.py = 5 * 16, 5 * 16 + ow:onStepComplete() + check(ow:canCollisionWarp(), "the completed step onto the door sets the flag") + eq(ow.taken and ow.taken.destMap, "REDS_HOUSE_1F", "and takes us inside") + + ow.taken = nil + ow:setMap("REDS_HOUSE_1F", 2, 7, "down", { via = "warp" }) + ow.warpEntryCell = { x = 2, y = 7 } + check(ow:canCollisionWarp(), "the map change does not clear the flag") + check(ow:checkEdgeExit("down"), + "so the first press of DOWN on the arrival mat exits (#378)") + eq(ow.taken and ow.taken.destMap, "LAST_MAP", "straight back to Pallet Town") +end + +-- A Poke Center mat is the same shape with a different tile: its $1C is not in +-- any warp-tile list, so the door outside decides and the exit works on arrival. +do + local ow = bootOn("VIRIDIAN_POKECENTER", 3, 7, "down") + check(ow:canCollisionWarp(), "the Center mat keeps the flag the door set") + check(ow:checkEdgeExit("down"), "DOWN on it walks back out to Viridian") + eq(ow.taken and ow.taken.destMap, "LAST_MAP", "by the LAST_MAP warp") +end + +-- The stand-still collision path reads the same flag: north of the staircase +-- cell is solid wall, and bonking it must never fire the stairs. +do + local ow = bootOn("REDS_HOUSE_2F", 7, 1, "down") + Input.state.up = true + Input.pressed = {} + for _ = 1, 30 do + ow.player.turnTimer = 0 + ow:handleInput() + ow.player:update() + end + check(ow.taken == nil, "the blocked step into the wall warps nowhere") + eq(ow.player.cellX, 7, "and the player has not moved") + eq(ow.player.cellY, 1, "in either axis") + Input:reset() +end + +S.finish() diff --git a/tools/make_rom_manifest.py b/tools/make_rom_manifest.py index c439f041..09dd5495 100755 --- a/tools/make_rom_manifest.py +++ b/tools/make_rom_manifest.py @@ -315,7 +315,10 @@ def text_metadata(pokered): for path in paths: for _, line in util.read_asm(path): stripped = line.strip() - match = re.match(r"(_\w+)::?\s*$", stripped) + # not every string label carries the far-text underscore + # (SilphCo2FSilphWorkerFPleaseTakeThisText and friends live in + # the script bank), and dropping those lost their strings (#393) + match = re.match(r"(\w+)::?\s*$", stripped) if match: current = match.group(1) labels.append(current) diff --git a/tools/rom_manifest.json b/tools/rom_manifest.json index 91d49dcf..df06f74e 100644 --- a/tools/rom_manifest.json +++ b/tools/rom_manifest.json @@ -21930,6 +21930,10 @@ 23, 21570 ], + "SilphCo2FSilphWorkerFPleaseTakeThisText": [ + 32, + 25684 + ], "SilphCo2F_h": [ 22, 23781 @@ -34047,6 +34051,7 @@ ] }, "labels": [ + "SilphCo2FSilphWorkerFPleaseTakeThisText", "_AIBattleUseItemText", "_AIBattleWithdrawText", "_AbandonLearningText", diff --git a/tools/rom_manifest_blue.json b/tools/rom_manifest_blue.json index 761b302b..29ea88c7 100644 --- a/tools/rom_manifest_blue.json +++ b/tools/rom_manifest_blue.json @@ -21907,6 +21907,10 @@ 23, 21570 ], + "SilphCo2FSilphWorkerFPleaseTakeThisText": [ + 32, + 25684 + ], "SilphCo2F_h": [ 22, 23781 @@ -34024,6 +34028,7 @@ ] }, "labels": [ + "SilphCo2FSilphWorkerFPleaseTakeThisText", "_AIBattleUseItemText", "_AIBattleWithdrawText", "_AbandonLearningText", diff --git a/tools/rom_manifest_yellow.json b/tools/rom_manifest_yellow.json index 9d565854..7612be9a 100644 --- a/tools/rom_manifest_yellow.json +++ b/tools/rom_manifest_yellow.json @@ -39058,6 +39058,7 @@ ] }, "labels": [ + "SilphCo2FSilphWorkerFPleaseTakeThisText", "_AIBattleUseItemText", "_AIBattleWithdrawText", "_AbandonLearningText",