diff --git a/data/scripts/flavor/viridian_school_house.lua b/data/scripts/flavor/viridian_school_house.lua new file mode 100644 index 00000000..cb690ffa --- /dev/null +++ b/data/scripts/flavor/viridian_school_house.lua @@ -0,0 +1,95 @@ +-- Viridian School House's two readables (data/events/hidden_events.asm, +-- hidden_events_for VIRIDIAN_SCHOOL_HOUSE): +-- hidden_text_predef 3, 0 PrintBlackboardLinkCableText, ViridianSchoolBlackboard +-- hidden_text_predef 3, 4 PrintNotebookText, ViridianSchoolNotebook +-- tools/extract/field.py only parses `hidden_event` rows, so neither +-- hidden_text_predef row reaches data/generated/field.lua and both tiles +-- were dead A presses (#503). Same hook shape, and the same sibling asm +-- file, as the Celadon roof house in data/scripts/celadon_eevee.lua (#391); +-- hidden_text_predef spends the facing byte on the tx_pre id, so neither +-- tile gates on facing. + +local Menu = require("src.ui.Menu") +local TextBox = require("src.render.TextBox") + +-- ViridianSchoolBlackboard (engine/events/hidden_events/school_blackboard.asm): +-- StatusAilmentText1/2 are the two columns of the 12x8 box at the top left +-- (hlcoord 0, 0 + `lb bc, 6, 10`); picking a status prints its +-- ViridianBlackboardStatusPointers entry and jumps back to .blackboardLoop, +-- QUIT or B falls through to .exitBlackboard. +local STATUS_LABELS = { + { " SLP", "_ViridianBlackboardSleepText" }, + { " PSN", "_ViridianBlackboardPoisonText" }, + { " PAR", "_ViridianBlackboardPrlzText" }, + { " BRN", "_ViridianBlackboardBurnText" }, + { " FRZ", "_ViridianBlackboardFrozenText" }, +} + +local function blackboard(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 = 12, th = 8, rowStep = 1 })) + end + -- ViridianSchoolBlackboardText2 is reprinted on every .blackboardLoop + -- pass, immediately before HandleMenuInput + function askHeading() + game.stack:push(TextBox.new(game, + text._ViridianSchoolBlackboardText2 or "Which heading do\nyou want to read?", + showMenu)) + end + items = {} + for i, row in ipairs(STATUS_LABELS) do + local label, key = row[1], row[2] + items[i] = { label = label, onSelect = function() + game.stack:push(TextBox.new(game, text[key] or label, askHeading)) + end } + end + -- no onSelect: Menu's own pop closes the box, matching .exitBlackboard + items[#items + 1] = { label = " QUIT" } + game.stack:push(TextBox.new(game, + text._ViridianSchoolBlackboardText1 + or "The blackboard\ndescribes POKéMON\vSTATUS changes\vduring battles.", + askHeading)) +end + +-- ViridianSchoolNotebook (engine/events/hidden_events/school_notebooks.asm): +-- pages 1-3 each end in TurnPageSchoolNotebook (TurnPageText + YesNoChoice) +-- and NO stops the read; page 4 turns without asking and runs straight into +-- page 5, the girl catching you at it. +local function notebook(game) + local text = game.data.text or {} + local function page(n, after) + return TextBox.new(game, text["_ViridianSchoolNotebookText" .. n] or "", after) + end + local function turnPage(nextPage) + return function() + game.stack:push(TextBox.new(game, text._TurnPageText or "Turn the page?", + nil, { choice = function(yes) + if yes then game.stack:push(nextPage()) end + end })) + end + end + local function page5() return page(5) end + local function page4() return page(4, function() game.stack:push(page5()) end) end + local function page3() return page(3, turnPage(page4)) end + local function page2() return page(2, turnPage(page3)) end + game.stack:push(page(1, turnPage(page2))) +end + +return { + VIRIDIAN_SCHOOL_HOUSE = { + onInteract = function(game, ow, fx, fy) + if fx == 3 and fy == 0 then + blackboard(game) + return true + end + if fx == 3 and fy == 4 then + notebook(game) + return true + end + return false + end, + }, +} diff --git a/data/scripts/flavor_all.lua b/data/scripts/flavor_all.lua index 2d03324d..67543798 100644 --- a/data/scripts/flavor_all.lua +++ b/data/scripts/flavor_all.lua @@ -60,6 +60,7 @@ local files = { "data.scripts.flavor.victory_road_2f", "data.scripts.flavor.viridian_city", "data.scripts.flavor.viridian_nickname_house", + "data.scripts.flavor.viridian_school_house", -- #503 "data.scripts.flavor.wardens_house", } diff --git a/data/scripts/story.lua b/data/scripts/story.lua index d9ba11fa..84d17361 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -647,7 +647,12 @@ M.WARDENS_HOUSE = { TEXT_WARDENSHOUSE_WARDEN = { { "face_player" }, -- 1 { "check_flag", "EVENT_GOT_HM04" }, -- 2 - { "jump_if_true", 13 }, -- 3 + -- #535: previously jumped to the same silent-end target as the + -- give-then-thank fallthrough (row 13), so the warden said nothing + -- on every visit after the trade. pokered's .got_item branch + -- (scripts/WardensHouse.asm) instead prints HM04ExplanationText, + -- so route here to the new row 17 that does the same. + { "jump_if_true", 17 }, -- 3 { "check_item", "GOLD_TEETH" }, -- 4 { "jump_if_false", 15 }, -- 5 { "show_text", "_WardensHouseWardenGaveTheGoldTeethText" }, -- 6 @@ -658,9 +663,15 @@ M.WARDENS_HOUSE = { { "give_item", "HM_STRENGTH", 1, false }, -- 10 { "show_text", "_WardensHouseWardenReceivedHM04Text" }, -- 11 { "set_flag", "EVENT_GOT_HM04" }, -- 12 - { "jump", 16 }, -- 13 (already got it) - { "jump", 16 }, -- 14 (unused) + { "jump", 18 }, -- 13 (already got it this convo; jp .done) + { "jump", 18 }, -- 14 (unused) { "show_text", "_WardensHouseWardenGibberish1Text" }, -- 15 + { "jump", 18 }, -- 16 (#535: skip the new explanation row below) + -- #535: pokered .got_item branch (scripts/WardensHouse.asm) -- + -- printed on every subsequent talk once EVENT_GOT_HM04 is set. + -- Text is _WardensHouseWardenHM04ExplanationText (text/WardensHouse.asm): + -- HM04 teaches Strength, and hints at the Safari Zone secret house. + { "show_text", "_WardensHouseWardenHM04ExplanationText" }, -- 17 }, }, } diff --git a/data/scripts/story5.lua b/data/scripts/story5.lua index 87d68b70..1dc376d4 100644 --- a/data/scripts/story5.lua +++ b/data/scripts/story5.lua @@ -790,8 +790,19 @@ local function bikeGateGuard(coords, stopText, explainText) local t = text(game) push(game, t[stopText] or "Hey! Wait up!", function() push(game, t[explainText] or "You need a\nBICYCLE for\nCYCLING ROAD!", function() + -- pokered's Route16Gate1FGuardScript / Route18Gate1FGuardScript + -- (scripts/Route16Gate1F.asm, Route18Gate1F.asm) simulate one + -- PAD_RIGHT step after the refusal text, and only clear + -- wJoyIgnore / hand control back once that step finishes + -- (PlayerMovingRightScript). Without it the player was left + -- parked beside the guard's counter with no way past. #518 + local function shoveRight() + ow:scriptMove(ow.player, "right", 1) + end if dist > 0 then - ow:scriptMove(ow.player, "up", dist) + ow:scriptMove(ow.player, "up", dist, shoveRight) + else + shoveRight() end end) end) diff --git a/docs/new-features.md b/docs/new-features.md index e248b4bb..f600d9d8 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -157,6 +157,13 @@ tradeoff vs. the relay). Headless tests drive the protocol over an in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file also exercises real UDP pairing. +Red, Blue, and Yellow copies link with each other, as the real cable +does. The compatibility fingerprint hashes only data a link mode can +actually read, so Yellow's Dragonair/Dragonite catch-rate retunes (the +only R/B/Y link-surface difference) no longer read as different games +(issue #511). Moving the fingerprint is a link parity change: builds +from before this fix will refuse to pair with builds after it. + ## Fair play in link and online matches A link session is decided by the battle and nothing else, so for its diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 24a78db4..bc1544d5 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -93,6 +93,12 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed) if not (record and record.explode) then battle:cancelMoveAnim() end battle:sayNext(Strings("%s's\nattack missed!", displayName(user))) + -- MoveHitTest's INVULNERABLE branch sets the same wMoveMissed as a + -- failed accuracy roll (core.asm:5260), and the miss handler still + -- runs the explode effect ("even if Explosion or Selfdestruct + -- missed, its effect still needs to be activated", core.asm:3223), + -- so the user faints against a mid-Fly/Dig target too (#528) + if record and record.onMiss then record.onMiss(ctx, "invulnerable") end return end diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 0db7980c..675aec20 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -6,7 +6,9 @@ local RomImporter = {} RomImporter.__index = RomImporter -- Cache generation tag; bump to force every imported version to re-extract. -local CACHE_FORMAT = "rom-cache-v8:" +-- v9: Yellow audio re-anchored on pokeyellow.sym (#522) -- stale caches +-- carry Red's bank $1f header, wave-table, and CryData offsets. +local CACHE_FORMAT = "rom-cache-v9:" -- 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/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index 50d40b71..3e34ed36 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -433,6 +433,13 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) if battle then return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } end + -- FishingInit (engine/items/item_effects.asm): cp wWalkBikeSurfState, 2 + -- (surfing) sets carry, and every ItemUseXRod does jp c, ItemUseNotTime + -- on that carry -- surfing refuses the rod with the same OAK text as + -- the mid-battle case above, no rod-specific message (#533) + if ow and ow.player and ow.player.surfing then + return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } + end return "fish", itemId end diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua index d9bed50c..699eb68e 100644 --- a/src/link/Fingerprint.lua +++ b/src/link/Fingerprint.lua @@ -128,7 +128,14 @@ end -- ------- the link surface -local SPECIES_FIELDS = { "baseStats", "types", "catchRate", "baseExp", +-- catchRate stays out (#511): no link mode ever reads it -- a ball thrown +-- in a link battle is a trainer-battle throw and always refused (pokered +-- engine/items/item_effects.asm ItemUseBall), and the trade rebuild never +-- touches it. Hashing it split Red/Blue from Yellow, whose only link +-- surface delta is the Dragonair/Dragonite catch-rate bytes +-- (data/pokemon/base_stats/dragonair.asm db 45 vs 27, dragonite.asm 45 +-- vs 9), when the real cable links R/B/Y freely. +local SPECIES_FIELDS = { "baseStats", "types", "baseExp", "growthRate", "evolutions" } local MOVE_FIELDS = { "power", "type", "accuracy", "pp", "effect", "category", "priority", "highCrit", "fixedDamage", "multiHit", diff --git a/src/link/Protocol.lua b/src/link/Protocol.lua index 6b147af8..fac1c453 100644 --- a/src/link/Protocol.lua +++ b/src/link/Protocol.lua @@ -178,19 +178,16 @@ end -- ------- subset negotiation --- the species and moves this party actually references, so the exchange --- stays small (six mons) instead of shipping the whole catalog +-- every record hash, not just this party's slice: the receiver filters +-- its OWN party against these (eligibleParty), so a message limited to the +-- sender's party read "not on the other game" for every species the two +-- parties did not happen to share, and a subset trade between different +-- games showed neither side (#511). The full catalog is ~300 short hash +-- strings -- still one message. function Protocol.recordsMessage(data, party) - local species = Fingerprint.records(data, "pokemon") - local moves = Fingerprint.records(data, "moves") - local outSpecies, outMoves = {}, {} - for _, mon in ipairs(party or {}) do - if species[mon.species] then outSpecies[mon.species] = species[mon.species] end - for _, mv in ipairs(mon.moves or {}) do - if moves[mv.id] then outMoves[mv.id] = moves[mv.id] end - end - end - return { type = "records", pokemon = outSpecies, moves = outMoves } + return { type = "records", + pokemon = Fingerprint.records(data, "pokemon"), + moves = Fingerprint.records(data, "moves") } end -- a mon may cross the wire only if both peers rebuild it identically: the diff --git a/src/ui/YellowIntro.lua b/src/ui/YellowIntro.lua index 17d53eed..0cfc1446 100644 --- a/src/ui/YellowIntro.lua +++ b/src/ui/YellowIntro.lua @@ -148,6 +148,37 @@ local SPEED_BARS = { { 0xE0, 0x80, 4 }, { 0xF0, 0x90, 2 }, } +-- PlayIntroScene's .loop (pokeyellow engine/movie/intro_yellow.asm) ends +-- every iteration with its own `call DelayFrame`, so one scene handler costs +-- one frame plus whatever DelayFrame calls it makes itself. The port runs a +-- handler inside a single update() tick, so every one of those internal +-- DelayFrame calls has to be spent explicitly or the movie runs short of +-- hardware. Three places owe frames: +-- +-- SETUP_SCENE_DELAY scenes 2/4/6/8/10/12 open with +-- YellowIntro_BlankPalsDelay2AndDisableLCD: rBGP/rOBP0/ +-- rOBP1 to $00 (color 0 in every shade -- the lightest, +-- white on DMG), two DelayFrame calls, then DisableLCD. +-- Two internal plus the loop's own is a 3-frame +-- iteration, after which the next wait scene takes its +-- first timer decrement. Func_f9e9a restores rBGP $e4 +-- at the end of the same handler. +-- HEAD_FRAMES PlayIntroScene spends a DelayFrame between +-- InitYellowIntroGFXAndMusic (which ends in PlayMusic) +-- and the first .loop pass, and YellowIntroScene0 then +-- owns a whole iteration of its own. +-- EXIT_FRAMES .go_to_title_screen blanks the palettes and spends +-- four DelayFrame calls clearing the tilemap and OAM +-- before the title screen is built. +-- +-- These restore the movie's real elapsed length; they do NOT lengthen the +-- intro song. title.asm's StopAllMusic before MUSIC_TITLE_SCREEN is +-- unconditional (only PikachuCry1 is waited on), so hardware cuts +-- Music_YellowIntro off mid-phrase too (#523). +local SETUP_SCENE_DELAY = 3 +local HEAD_FRAMES = 2 +local EXIT_FRAMES = 4 + -- scene-6 sine (YellowIntro_Copy8BitSineWave.SineWave), signed SCY deltas local WAVE = { 0, 0, 1, 2, 2, 3, 3, 3, 4, 3, 3, 3, 2, 2, 1, 0, 0, 0, -1, -2, -2, -3, -3, -3, -4, -3, -3, -3, -2, -2, -1, 0 } @@ -208,6 +239,8 @@ function YellowIntro.new(game, onDone) self.palName = "MEWMON" -- PalPacket_Generic self.objects = {} self.cloudFrame = 0 + self.pendingThen = nil -- what enterDelay runs once pendingDelay hits 0 (#523) + self.pendingDelay = 0 self.atlas1 = tryImage("assets/generated/intro/yellow_intro_1.png") self.atlas2 = tryImage("assets/generated/intro/yellow_intro_2.png") @@ -559,6 +592,24 @@ function YellowIntro:startScene(scene) end end +-- Spend `frames` real frames, then run fn: the port's stand-in for the +-- DelayFrame calls a pokeyellow scene handler makes inside its own +-- PlayIntroScene .loop iteration (#523). fn may be nil to just burn time. +function YellowIntro:enterDelay(frames, fn) + self.pendingDelay = frames + self.pendingThen = fn +end + +-- YellowIntro_BlankPalsDelay2AndDisableLCD blanks the palettes for the +-- delay; Func_f9e9a puts rBGP back to $e4 at the tail of the same handler. +function YellowIntro:enterSetupScene(scene) + self.bgp = 0x00 + self:enterDelay(SETUP_SCENE_DELAY, function() + self.bgp = 0xE4 + self:startScene(scene) + end) +end + function YellowIntro:enter() if self.pre then return end -- pre-roll first; beginScenes takes over self:beginScenes() @@ -571,7 +622,11 @@ function YellowIntro:beginScenes() local song = songs and (songs.Music_YellowIntro and "Music_YellowIntro" or songs.Music_IntroBattle and "Music_IntroBattle") if song then pcall(Music.play, data, song, false) end - self:startScene(0) + -- HEAD_FRAMES: the DelayFrame between PlayMusic and the first .loop pass + -- plus YellowIntroScene0's own iteration. new() already laid down the + -- Func_f9e5f letterbox that InitYellowIntroGFXAndMusic writes, so these + -- frames show what hardware shows: the letterbox with no pika yet. + self:enterDelay(HEAD_FRAMES, function() self:startScene(0) end) end -- The movie's exit never stops the song (intro_yellow.asm PlayIntroScene @@ -586,16 +641,44 @@ function YellowIntro:finish() if self.onDone then self.onDone() end end +-- Both scene-loop exits (scene 17 running out, and the A/B/START skip) land +-- on .go_to_title_screen, which calls YellowIntro_BlankPalettes and then +-- spends EXIT_FRAMES DelayFrame calls clearing the tilemap and the OAM +-- buffers before the title screen is built (#523). The pre-roll's own skip +-- is IntroMovie's path, not this one, and still finishes immediately. +function YellowIntro:exitToTitle() + if self.finished or self.exiting then return end + self.exiting = true + self.bgp = 0x00 + self:clearObjects() + self:enterDelay(EXIT_FRAMES, function() self:finish() end) +end + function YellowIntro:update(dt) if self.finished then return end if self.pre then self.pre:update(dt) return end + -- Ahead of the skip poll on purpose: JoypadLowSensitivity runs once at the + -- top of each .loop iteration, never inside a handler, so hardware is deaf + -- for the DelayFrame calls these ticks stand in for (#523). + if self.pendingDelay > 0 then + self.pendingDelay = self.pendingDelay - 1 + if self.pendingDelay == 0 then + local fn = self.pendingThen + self.pendingThen = nil + if fn then fn() end + end + self:updateObjects() + if self.bgDirty then self:rebuildBgCanvas() end + return + end + local input = self.game.input if input:wasPressed("a") or input:wasPressed("b") or input:wasPressed("start") then - self:finish() + self:exitToTitle() return end @@ -605,7 +688,7 @@ function YellowIntro:update(dt) self.timer = self.timer - 1 else self:clearObjects() - self:startScene(scene + 1) + self:enterSetupScene(scene + 1) -- BlankPalsDelay2AndDisableLCD (#523) end elseif scene == 3 then if self.timer > 0 then @@ -613,7 +696,7 @@ function YellowIntro:update(dt) if self.scx ~= 0x68 then self.scx = self.scx + 4 end else self:clearObjects() - self:startScene(4) + self:enterSetupScene(4) -- BlankPalsDelay2AndDisableLCD (#523) end elseif scene == 7 then if self.timer > 0 then @@ -625,7 +708,7 @@ function YellowIntro:update(dt) self.wave[255] = first else self:clearObjects() - self:startScene(8) + self:enterSetupScene(8) -- BlankPalsDelay2AndDisableLCD (#523) end elseif scene == 11 then if self.timer > 0 then @@ -640,7 +723,7 @@ function YellowIntro:update(dt) self.timer = self.timer - 1 else self:clearObjects() - self:startScene(12) + self:enterSetupScene(12) -- BlankPalsDelay2AndDisableLCD (#523) end elseif scene == 13 then if self.timer > 0 then @@ -656,13 +739,17 @@ function YellowIntro:update(dt) if v then self.bgp = v else - -- .expired: everything despawns, letterbox returns, logo/face - -- object $7 appears for the strobe scene + -- .expired: everything despawns and the letterbox returns, then the + -- handler spends three DelayFrame calls with hAutoBGTransferEnabled + -- pushing that rebuilt tilemap to VRAM before it restores rBGP $e4 + -- and spawns the logo/face object $7 for the strobe scene (#523). self:clearObjects() self:bgLetterbox() - self.bgp = 0xE4 - self:spawn(7, 0x58, 0x58) - self:startScene(15) + self:enterDelay(3, function() + self.bgp = 0xE4 + self:spawn(7, 0x58, 0x58) + self:startScene(15) + end) end elseif scene == 15 then if self.timer > 0 then @@ -687,7 +774,7 @@ function YellowIntro:update(dt) if self.timer > 0 then self.timer = self.timer - 1 else - self:finish() + self:exitToTitle() return end end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 7f33a2dc..c67683f7 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -360,6 +360,36 @@ function OverworldState:setMap(mapId, x, y, facing, opts) else self.player = Player.new(Game.data, x, y, facing) end + -- boot only: the original persists the surf state. wWalkBikeSurfState + -- (ram/wram.asm) lives inside wMainDataStart..wMainDataEnd, which + -- engine/menus/save.asm block-copies into sMainData on save and back out + -- on load (sram.asm declares sMainData as `ds wMainDataEnd - + -- wMainDataStart`), and Continue never clears it -- the only `xor a / + -- ld [wWalkBikeSurfState], a` on that path is the cable club's. Restore + -- it here, before Music.playMap reads it below and before + -- PikachuFollower.onMapEntered, matching LoadMapData calling + -- LoadPlayerSpriteGraphics ahead of PlayDefaultMusic (home/overworld.asm, + -- home/audio.asm). Without this the player resumed on foot on a water + -- cell, which softlocks here: Collision.canMove (src/world/Collision.lua) + -- picks land tile-pairs whenever mover.surfing is falsy, and land + -- tile-pairs never permit stepping off a water cell (#536). Same + -- boot-only shape as the refreshStandingOnWarp door-mat restore (#378). + if opts and opts.via == "boot" then + local ps = Game.save and Game.save.player + if ps and ps.surfing ~= nil then + self.player.surfing = ps.surfing and true or false + else + -- saves written before #536 carry no flag. Map:isWaterCell alone is + -- not self-sufficient (see src/world/Map.lua: water and shore share + -- one lookup, and no tileset stamps waterTiles, so tile $14 -- a + -- walkable floor in HOUSE/GATE/LOBBY/MANSION/MUSEUM -- reads as + -- water), so gate it the way facingIsShoreOrWater does and require a + -- cell you could not be standing on upright. + self.player.surfing = self:tilesetHasWater() + and not self.map:isWalkableCell(x, y) + and self.map:isWaterCell(x, y) + end + end -- crossConnection re-arms this after setMap; clear so a warp/reload -- cannot leave a stale deferred PlayMapMusic pending self.pendingSeamMusic = nil @@ -1058,19 +1088,38 @@ function OverworldState:handleInput() -- OverworldLoop (home/overworld.asm) gates ALL of JoypadOverworld on -- wWalkCounter == 0 ("if the player sprite has not yet completed the -- walking animation" it jumps straight to .moveAhead): A, START and - -- direction initiation are only ever looked at while the player stands - -- on a tile, and a button pressed mid-step is simply never seen. - -- Without this gate a mid-step A/START pushed its TextBox/StartMenu - -- right there and froze Red between tiles, mid-animation (#286). Held - -- directions need no buffering -- isDown below picks them up on the - -- landing frame. - if self.player.moving then return end + -- direction initiation are only ever ACTED ON while the player stands on + -- a tile. Without this gate a mid-step A/START pushed its TextBox/ + -- StartMenu right there and froze Red between tiles, mid-animation + -- (#286). Held directions need no buffering -- isDown below picks them + -- up on the landing frame. + -- + -- The original defers the poll rather than discarding it, though. Joypad + -- (engine/joypad.asm _Joypad) computes hJoyPressed against hJoyLast and + -- advances hJoyLast only when something calls it; the mid-step path never + -- does, and vblank's per-frame ReadJoypad refreshes hJoyInput alone. + -- hJoyLast is frozen for the whole animation, so a button pressed + -- mid-step and STILL HELD when the step lands reads as a fresh press at + -- the next poll -- one released before then is genuinely lost. Dropping + -- the edge outright made START a coin flip on the Cycling Road roll, + -- where the pull below re-arms a step on the single idle frame in + -- bikeStepFrames (#525). + if self.player.moving then + local held = self.joyLatch + if not held then held = {}; self.joyLatch = held end + if input:wasPressed("a") then held.a = true end + if input:wasPressed("start") then held.start = true end + return + end + local latch = self.joyLatch + self.joyLatch = nil - if input:wasPressed("a") then + if input:wasPressed("a") or (latch and latch.a and input:isDown("a")) then self:interact() return end - if input:wasPressed("start") then + if input:wasPressed("start") + or (latch and latch.start and input:isDown("start")) then require("src.core.Sound").play(Game.data, "Start_Menu") Screens.push(Game, "StartMenu") return @@ -4561,6 +4610,11 @@ function OverworldState:captureSave(save) save.player.x = self.player.cellX save.player.y = self.player.cellY save.player.facing = self.player.facing + -- wWalkBikeSurfState (ram/wram.asm) sits inside the wMainDataStart.. + -- wMainDataEnd range engine/menus/save.asm block-copies into sMainData, + -- so the original saves and restores the surf state; setMap's boot path + -- reads this back (#536). + save.player.surfing = self.player.surfing and true or false end return OverworldState diff --git a/src/world/Player.lua b/src/world/Player.lua index b331b7f4..7c0e58e1 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -12,9 +12,13 @@ local Player = {} Player.__index = Player local STEP_FRAMES = 16 --- a turn in place holds for the ~2 frames the original spends on the --- extra OverworldLoop pass (home/overworld.asm .handleDirectionButtonPress --- returns to the loop without moving after a direction change) +-- a turn in place blocks movement for the one extra OverworldLoop pass the +-- original spends after a direction change: .handleDirectionButtonPress ends +-- `jp OverworldLoop` (home/overworld.asm), and OverworldLoop burns two +-- DelayFrame calls before the next JoypadOverworld, so the sample that can +-- commit to a step lands exactly 2 fixed steps after the turn -- the same +-- 2-frames-per-iteration cadence that makes STEP_FRAMES 16 above +-- (wWalkCounter = 8, 2px per AdvancePlayerSprite) (#415) local TURN_FRAMES = 2 function Player.new(data, cx, cy, facing) diff --git a/tests/drivers/cycling_road_menu_bug525_test.lua b/tests/drivers/cycling_road_menu_bug525_test.lua new file mode 100644 index 00000000..3dec58c5 --- /dev/null +++ b/tests/drivers/cycling_road_menu_bug525_test.lua @@ -0,0 +1,99 @@ +-- Driver: opening the START menu while the bike force-rolls down Cycling +-- Road (#525). src/world/OverworldController.lua handleInput() used to +-- drop A/START outright while self.player.moving, so a press that landed +-- mid-step and was still held when the step completed vanished -- on +-- Cycling Road's forced roll, where a step re-arms on the single idle +-- frame between steps, that made START a coin flip. The fix latches a +-- still-held press and acts on it on the landing frame instead +-- (see tests/parity_midstep_buttons.lua for the mechanical half). +-- +-- POKEPORT_DRIVER=tests/drivers/cycling_road_menu_bug525_test.lua \ +-- POKEPORT_IDENTITY=bug525 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \ +-- 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 function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- Same clear stretch bug255 uses: x=2 is open road from y=8 past y=34. + local MAP, START_X, START_Y = "ROUTE_17", 2, 20 + + game.save.onBike = true + U.teleport(game, MAP, START_X, START_Y, "down") + U.wait(15) + local ow = game.overworld + check("standing on Cycling Road, on the bike", + ow.map.id == MAP and game.save.onBike) + + -- hands off the pad: the slope pulls the bike south on its own + -- (field.forcedMovement.slopeMaps, same mechanism bug255 proved) + U.wait(20) + check("the roll is already under way", ow.player.moving == true + or ow.player.cellY > START_Y) + U.shot(game, DIR .. "/bug525_1_rolling.png") + + -- press START mid-step and keep it held through the landing frame, the + -- exact shape a real thumb makes reaching for the menu while rolling + local pressedMidStep = false + local guard = 0 + while guard < 60 do + guard = guard + 1 + if ow.player.moving and ow.player.progress + and ow.player.progress > 2 and not pressedMidStep then + table.insert(game.input.pressQueue, "start") + game.input.state.start = true + pressedMidStep = true + U.log("pressed START mid-step at progress", ow.player.progress) + end + coroutine.yield() + if getmetatable(game.stack:top()) ~= nil + and game.stack:top() ~= ow and pressedMidStep then + break + end + end + U.wait(2) + game.input.state.start = false + + local top = game.stack:top() + U.shot(game, DIR .. "/bug525_2_after_start.png") + check("START held through the landing opened the start menu", + top ~= ow and top ~= nil) + check("the player is not frozen mid-tile (px/py land on a 16px cell)", + ow.player.px % 16 == 0 and ow.player.py % 16 == 0) + + -- close it and let the roll continue, proving the pull did not get + -- eaten along with the buffered button + if top ~= ow then + while game.stack:top() ~= ow do + U.tap(game, "b") + U.wait(10) + end + end + local yBeforeResume = ow.player.cellY + U.wait(48) + check("the roll resumes south after closing the menu", + ow.player.cellY > yBeforeResume) + U.shot(game, DIR .. "/bug525_3_resumed.png") + + -- park with road left to roll, on the bike, before handing over + U.teleport(game, MAP, START_X, START_Y, "down") + U.wait(10) + game.save.onBike = true + + U.log("On the bike on Cycling Road at (" .. START_X .. "," .. START_Y .. + "), rolling south hands-off.") + U.log("Press START right as you see a step land, and again mid-step") + U.log("while releasing before it lands. Right: a START you keep pressed") + U.log("into the landing opens the menu every time, one you let go of") + U.log("mid-step does nothing (same as standing still). #525 was the") + U.log("held case sometimes doing nothing at all, a coin flip on this hill.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/fighting_dojo_master_bug502_test.lua b/tests/drivers/fighting_dojo_master_bug502_test.lua new file mode 100644 index 00000000..8d7d078f --- /dev/null +++ b/tests/drivers/fighting_dojo_master_bug502_test.lua @@ -0,0 +1,107 @@ +-- Driver: Fighting Dojo Karate Master does not leave his post (#502), +-- the opposite-direction report against the same script #495 fixed +-- (data/scripts/story4.lua dojoMasterGate / M.FIGHTING_DOJO.onStep). +-- +-- Before the fix his trainer header used range=4/DOWN sight aggro +-- (src/core/Data.lua seedFightingDojoKarateMaster), so walking up his +-- column from below put him in CheckFightingMapTrainers' generic path: +-- the NPC walks toward the player before the pre-battle text, straight +-- through FIGHTINGDOJO_BLACKBELT3/4's tiles at (5,5)/(5,7) -- the +-- "merges with a pupil" glitch, and it fired regardless of whether the +-- pupils in front of him had been beaten yet ("forces the fight early"). +-- The fix sets range=0 and gates him on the single tile at his left +-- (4,3) instead (onStep = dojoMasterGate): he only ever turns to face +-- the player there, never steps. +-- +-- POKEPORT_DRIVER=tests/drivers/fighting_dojo_master_bug502_test.lua \ +-- POKEPORT_IDENTITY=bug502 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 function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local function masterIn(ow) + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == "FIGHTINGDOJO_KARATE_MASTER" then return n end + end + return nil + end + + -- No pupil beaten, master not beaten: the exact state the report says + -- triggered the early merge (walking up before clearing the pupils). + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_BEAT_KARATE_MASTER = nil + game.save.defeatedTrainers = {} + + ------------------------------------------------------------------ + -- Direction 1 (#502): stand in the master's own sight column, below + -- him, with his southern pupils (BLACKBELT3 at (5,5), BLACKBELT4 at + -- (5,7)) still in the way -- exactly the "walk towards Karate Master" + -- report. The old range=4/DOWN header put any of these cells in his + -- sight; his first step toward the player collided with BLACKBELT3's + -- tile. Collision blocks a real walk onto an NPC's cell, so probe by + -- teleport (a scripted approach cannot walk through them either) and + -- watch several fixed steps for the master moving or engaging at all. + ------------------------------------------------------------------ + local ow, master + local function probe(px, py, label) + U.teleport(game, "FIGHTING_DOJO", px, py, "up") + U.wait(10) + ow = game.stack:top() + master = masterIn(ow) + U.shot(game, DIR .. "/bug502_1_" .. label .. "_before.png") + local moved, engaged = false, false + for _ = 1, 40 do + U.wait(1) + if master.cellX ~= 5 or master.cellY ~= 3 then moved = true end + if getmetatable(game.stack:top()) == TextBox then engaged = true; break end + end + U.shot(game, DIR .. "/bug502_1_" .. label .. "_after.png") + check("Fighting Dojo is loaded (" .. label .. ")", ow.map.id == "FIGHTING_DOJO") + check("Karate Master stayed at (5,3), did not walk toward (" .. label .. ")", + not moved) + check("standing at " .. label .. " started no battle on its own", + not engaged) + return moved or engaged + end + + -- (5,4): directly below the master, one tile from BLACKBELT3 (5,5). + -- (5,6): the gap between BLACKBELT3 and BLACKBELT4, still in the old + -- sight range and still boxed in by pupils on both sides. + local bad1 = probe(5, 4, "below_master") + local bad2 = probe(5, 6, "between_pupils") + + ------------------------------------------------------------------ + -- Direction 2 (#495, same script): the ONLY tile that should start his + -- fight is one step left of him. Confirm the gate still works so the + -- fix didn't just make him inert in both directions. + ------------------------------------------------------------------ + U.teleport(game, "FIGHTING_DOJO", 4, 2, "down") + U.wait(10) + ow = game.stack:top() + master = masterIn(ow) + U.shot(game, DIR .. "/bug502_3_gate_before.png") + U.tap(game, "down") + U.wait(30) + local gateOpened = getmetatable(game.stack:top()) == TextBox + U.shot(game, DIR .. "/bug502_4_gate_after.png") + check("stepping onto (4,3) still starts the Master's challenge", gateOpened) + check("he turned to face the player instead of walking to them", + master.cellX == 5 and master.cellY == 3 and master.facing == "left") + + U.log("Compare bug502_1/2: the Master's sprite should sit in the same") + U.log("spot in both, never sliding down onto BLACKBELT3 or BLACKBELT4.") + U.log("bug502_4 should show his pre-battle text box, with him still on") + U.log("(5,3), just turned to face left -- that is the correct trigger,") + U.log("distinct from the column walk that used to glitch him south.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/turn_in_place_bug415_test.lua b/tests/drivers/turn_in_place_bug415_test.lua new file mode 100644 index 00000000..1d796ce5 --- /dev/null +++ b/tests/drivers/turn_in_place_bug415_test.lua @@ -0,0 +1,42 @@ +-- Driver: a quick directional tap should turn the player in place, not +-- also take a step (#415). Player:tryMove (src/world/Player.lua) only +-- steps once turnTimer has counted down; TURN_FRAMES/turnFrames set how +-- many fixed steps a facing change blocks movement for before a held +-- direction is allowed to commit to a step. This is an input-timing feel +-- bug: no assertion here can tell a quick tap from a held one the way a +-- human thumb can, so this hands the pad over rather than scripting taps +-- at a fixed, unrealistic frame count. +-- +-- POKEPORT_DRIVER=tests/drivers/turn_in_place_bug415_test.lua \ +-- POKEPORT_IDENTITY=bug415 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . + +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 + + -- REDS_HOUSE_2F: the player's own bedroom, the default new-game spawn + -- (src/core/SaveData.lua bedroom fallback). Small enough that an + -- accidental step is obvious -- a stray step toward the stairs at + -- (7,1)/(7,2) or into the desk/bed furniture is immediately visible. + U.teleport(game, "REDS_HOUSE_2F", 5, 6, "down") + U.wait(10) + local ow = game.stack:top() + check("Red's bedroom is loaded", ow.map.id == "REDS_HOUSE_2F") + check("player starts facing down", ow.player.facing == "down") + + U.log("Standing in the middle of the bedroom, facing down.") + U.log("Tap Up once, quickly, then tap Right once, quickly.") + U.log("Right: each tap only turns you to face that way -- your feet") + U.log("stay on the same tile both times. Wrong (#415): a quick tap") + U.log("still slides you one tile in the new direction, same as holding.") + U.log("Now hold Up for a beat: turning into a real step, then a walk,") + U.log("should feel identical to before -- no extra pause was added there.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/yellow_audio_bug522_test.lua b/tests/drivers/yellow_audio_bug522_test.lua new file mode 100644 index 00000000..e75722e5 --- /dev/null +++ b/tests/drivers/yellow_audio_bug522_test.lua @@ -0,0 +1,99 @@ +-- Manual check that Yellow's bank $1f music headers, wave table and cry +-- data read from the right ROM addresses (#522). Before the fix every +-- header past Music_YellowIntro (a 3-channel header where Red's +-- Music_IntroBattle was 4) was read 3 bytes off, so Viridian Forest's +-- Music_Dungeon2 lost 3 of its 4 channels and its tempo command; every +-- song's channel 3 sampled engine code instead of a wave table. +-- POKEPORT_DRIVER=tests/drivers/yellow_audio_bug522_test.lua POKEPORT_IDENTITY=bug522 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local ChipSynth = require("src.core.ChipSynth") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local audio = game.data.audio or {} + local header = audio.musicHeaders and audio.musicHeaders.Music_Dungeon2 + + -- pokeyellow.sym: Music_Dungeon2 = 1f:42a9 (bank 31, address 17065) + check("Music_Dungeon2 header reads pokeyellow.sym's address", + header and header.address == 17065 and header.bank == 31) + + -- pokeyellow.sym: CryData = 0e:5462 (bank 14, address 21602) + local cry = audio.cryData + check("cryData reads pokeyellow.sym's CryData", + cry and cry.address == 21602 and cry.bank == 14) + + -- pokeyellow only has one wave table, Audio1_WavePointers.wave0 = 02:5a26 + local waves = audio.waveBanks or {} + local waveOk = true + for _, engine in ipairs({ "1", "2", "3" }) do + local w = waves[engine] + if not (w and w.address == 23078 and w.bank == 2) then waveOk = false end + end + check("all three waveBanks point at Audio1_WavePointers.wave0", waveOk) + + -- Rebuild the engine exactly as ChipAudio does and count the channels a + -- misread header would drop to 1 (see headerChannels, ChipSynth.lua). + local engine, engErr + if header then + local ok + ok, engine = pcall(ChipSynth.newEngine, game.data, header) + if not ok then engErr = engine; engine = nil end + end + check("Music_Dungeon2 engine builds" .. (engErr and (": " .. tostring(engErr)) or ""), + engine ~= nil) + check("Music_Dungeon2 has all 4 channels, not the misread header's 1", + engine and #engine.channels == 4) + + -- Ch1 opens with the tempo command (dungeon2.asm "Music_Dungeon2_Ch1:: + -- tempo 144"); a header pointed at the wrong row drops it and the engine + -- is left at the 0x100 default, ~1.78x slower. + if engine and engine.channels[1] then + engine.channels[1]:nextEvent() + end + check("Ch1's tempo command set engine.tempo to 144, not the 0x100 default", + engine and engine.tempo == 144) + + -- pokered data/maps/objects/ViridianForest.asm: the south gate warps land + -- around (16-18, 47); one step north of that is inside the forest proper. + local MAP = "VIRIDIAN_FOREST" + local STAND = { x = 16, y = 46, facing = "up" } + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + + local ow = game.overworld + if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then + local function nearestWalkable() + for dy = -3, 3 do + for dx = -3, 3 do + local cx, cy = STAND.x + dx, STAND.y + dy + if ow.map:isWalkableCell(cx, cy) then return cx, cy end + end + end + return nil + end + local cx, cy = nearestWalkable() + if cx then + U.log("start cell blocked, standing at", cx, cy, "instead") + U.teleport(game, MAP, cx, cy, "up") + end + end + + if game.save and game.save.options and game.save.options.musicVol == 0 then + U.log("FAIL musicVol is 0 -- turn it up, this run is silent by design") + end + + U.log("Standing in Viridian Forest; Music_Dungeon2 is playing now.") + U.log("Right: four voices at a brisk clip -- lead melody, a counter line") + U.log("underneath it, a soft rounded bass, and a hi-hat tick on top.") + U.log("Wrong (the bug): one thin lonely voice at a noticeably slower,") + U.log("draggy tempo, with no bass or hi-hat at all.") + U.log("Also listen to that bass note's timbre -- it should be a soft") + U.log("rounded triangle-ish tone, not a harsh buzzy rasp (waveBanks).") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/yellow_intro_bug523_test.lua b/tests/drivers/yellow_intro_bug523_test.lua new file mode 100644 index 00000000..48821a57 --- /dev/null +++ b/tests/drivers/yellow_intro_bug523_test.lua @@ -0,0 +1,73 @@ +-- Manual check that the Yellow intro now runs long enough for +-- Music_YellowIntro's descending run and long final note to be audible +-- before title.asm's unconditional StopAllMusic swaps in the title theme +-- (#523). Never taps a button: Game:load() already pushed YellowIntro, +-- so this driver only watches it play out at real, unaccelerated speed. +-- POKEPORT_SPEED must stay unset -- Music runs on its own real-time 60Hz +-- accumulator (Game:update), decoupled from the fast-forwardable logic +-- clock, so speeding up the driver would desync the exact ordering under +-- test here instead of just playing it back faster. +-- POKEPORT_DRIVER=tests/drivers/yellow_intro_bug523_test.lua POKEPORT_IDENTITY=bug523 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Music = require("src.core.Music") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + check("booted into a state (Yellow intro, per bootScreens)", + game.stack:top() ~= nil) + + if game.save and game.save.options and game.save.options.musicVol == 0 then + U.log("FAIL musicVol is 0 -- turn it up, this run is silent by design") + end + + local sawIntroSong = false + local sawTitleSong = false + + -- Music.lua keeps the current song in a module-local `state` table with + -- no public getter, so watch the one seam that is public: wrap Music.play + -- itself for the life of this driver and log what it's handed. Both the + -- YellowIntro:beginScenes() pcall and title.asm's StopAllMusic-then- + -- PlaySound land here. + local frame = 0 + local originalPlay = Music.play + Music.play = function(data, song, loop, ctx) + if song and song:find("Intro") and not sawIntroSong then + sawIntroSong = true + U.log("Music_YellowIntro started") + end + if song == "Music_TitleScreen" and not sawTitleSong then + sawTitleSong = true + U.log("Music_TitleScreen started") + end + return originalPlay(data, song, loop, ctx) + end + + -- 40s ceiling counted from driver start, not from the intro song: roughly 500 frames of + -- boot and bootScreens run before Music_YellowIntro begins, and the title swap lands + -- around frame 1680 (28s), so a 20s ceiling expired before the moment under test (#523). + while frame < 40 * 60 and not sawTitleSong do + frame = frame + 1 + coroutine.yield() + end + + check("Music_YellowIntro started", sawIntroSong) + check("the movie reached the title screen and Music_TitleScreen took over", + sawTitleSong) + + U.log("Listen for Music_YellowIntro's ending: a short descending run of") + U.log("notes (F#, F, F#...F#, E, D#, C#) followed by one long low note,") + U.log("right before the title theme cuts in. Before #523 the swap landed") + U.log("early and those descending notes never played -- the track just") + U.log("stopped mid-phrase. The swap itself is still an abrupt cut by") + U.log("design (pokeyellow's title.asm stops the intro song unconditionally,") + U.log("it never waits for it to finish); what changed is how much of the") + U.log("track plays before that cut lands.") + + while true do + coroutine.yield() + end +end diff --git a/tests/goldens/fixture_fingerprint.txt b/tests/goldens/fixture_fingerprint.txt index 6b90a321..3ab0752d 100644 --- a/tests/goldens/fixture_fingerprint.txt +++ b/tests/goldens/fixture_fingerprint.txt @@ -1 +1 @@ -9820543f7a223fab +7de3051a7b3c108e diff --git a/tests/goldens/vanilla_fingerprint.txt b/tests/goldens/vanilla_fingerprint.txt index 41e86474..ee73d375 100644 --- a/tests/goldens/vanilla_fingerprint.txt +++ b/tests/goldens/vanilla_fingerprint.txt @@ -1 +1 @@ -54a52ea81751495c +227fd737aba15763 diff --git a/tests/parity_bike_gate_shove_bug518.lua b/tests/parity_bike_gate_shove_bug518.lua new file mode 100644 index 00000000..511c793d --- /dev/null +++ b/tests/parity_bike_gate_shove_bug518.lua @@ -0,0 +1,130 @@ +-- Regression (#518): without a BICYCLE, the Route 16/18 gate guard must +-- still shove the player one tile aside after refusing them, not leave +-- them parked against the counter. +-- +-- pokered's Route16Gate1FGuardScript / Route18Gate1FGuardScript (scripts/ +-- Route16Gate1F.asm, Route18Gate1F.asm) print the refusal text, then +-- simulate one PAD_RIGHT step (StartSimulatingJoypadStates) and only clear +-- wJoyIgnore once that step lands. data/scripts/story5.lua's bikeGateGuard +-- walked the player up to the counter and stopped there -- the ported +-- refusal text played, but nothing ever moved the player off the doorway. +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.ROUTE_16_GATE_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 bike gate shove (#518)") +local check, eq = S.check, S.eq + +-- ground truth: data/generated/maps.lua ROUTE_16_GATE_1F.objects, the guard +-- sits at (4,5) behind a solid counter row (row 6); rows 7-10 south of it +-- are the open waiting area bikeGateGuard's coords cover. +local r16 = MapLoader.load(Data, "ROUTE_16_GATE_1F") +check(r16:isWalkableCell(4, 7), "the counter-adjacent stop cell (4,7) is walkable") +check(r16:isWalkableCell(5, 7), "the shove target (5,7) is walkable") +check(not r16:isWalkableCell(4, 6), "row 6 (the counter) blocks straight through") + +Game.data = Data +Game.input = Input; Input:init() +Game.renderer = Renderer; Renderer:init() +Game.stack = StateStack; StateStack:init() + +-- stub TextBox the way parity_A.lua drives gym leader text: a bare table +-- capturing (text, onDone) so the test can dismiss it without pagination. +local realTB = package.loaded["src.render.TextBox"] +package.loaded["src.render.TextBox"] = { + new = function(_, text, done) return { text = text, onDone = done } end, +} + +local mapScripts = require("data.scripts.init") +local hooks = mapScripts.get("ROUTE_16_GATE_1F") +check(hooks and hooks.onStep, "ROUTE_16_GATE_1F has an onStep hook") + +-- one 16-frame step per fixed update, same as parity_ledge_seam_hop.lua +local function runFrames(ow, n) + for _ = 1, n do + ow:updateScriptMoves() + ow.player:update() + end +end + +-- dismiss the top stubbed box the way TextBox:update does: pop, then onDone +local function dismissTop() + local box = Game.stack:pop() + if box.onDone then box.onDone() end + return box +end + +local function standNoBike(x, y, facing) + Game.save = SaveData.newGame() + Game.save.inventory.BICYCLE = nil + while Game.stack:top() do Game.stack:pop() end + Game.stack:push(OW, "ROUTE_16_GATE_1F", x, y, facing or "up") + local ow = Game.stack:top() + ow.player.moving = false + return ow +end + +-- --- approaching from three tiles out: walk-up then shove ----------------- +do + local ow = standNoBike(4, 10) + local handled = hooks.onStep(Game, ow, ow.player.cellX, ow.player.cellY) + check(handled, "onStep claims the coordinate trigger without a BICYCLE") + + local box1 = Game.stack:top() + check(box1 ~= nil, "the wait-up refusal text box opened") + check(box1.text:find("Wait up", 1, true) ~= nil + or box1.text:find("Excuse me", 1, true) ~= nil, + "first box is the guard's stop line") + dismissTop() + + local box2 = Game.stack:top() + check(box2 ~= nil, "the second (explanation) box opened") + eq(box2.text, Data.text._Route16Gate1FGuardNoPedestriansAllowedText, + "second box is the guard's no-pedestrians explanation, verbatim") + dismissTop() + + check(#ow.scriptMoves > 0 or ow.player.moving, + "closing the explanation queues the walk-up-then-shove movement") + runFrames(ow, 200) + check(not ow.player.moving, "the scripted movement has settled") + eq(#ow.scriptMoves, 0, "no scripted move is left hanging") + eq(ow.player.cellX, 5, "the player ends one tile right of the counter (#518 shove)") + eq(ow.player.cellY, 7, "the player ends on the counter-adjacent row, not further") +end + +-- --- already standing at the counter-adjacent row: shove with no walk-up -- +do + local ow = standNoBike(4, 7) + local handled = hooks.onStep(Game, ow, ow.player.cellX, ow.player.cellY) + check(handled, "onStep still claims it when already adjacent to the counter") + dismissTop() -- wait-up + dismissTop() -- explanation + runFrames(ow, 200) + check(not ow.player.moving, "the shove-only movement has settled") + eq(#ow.scriptMoves, 0, "no scripted move is left hanging") + eq(ow.player.cellX, 5, "still ends one tile right (#518 shove with dist=0)") + eq(ow.player.cellY, 7, "y is unchanged since there was no walk-up needed") +end + +-- --- control: a BICYCLE in the bag lets the trigger pass through untouched +do + local ow = standNoBike(4, 10) + ow.player.moving = false + Game.save.inventory.BICYCLE = true + local handled = hooks.onStep(Game, ow, ow.player.cellX, ow.player.cellY) + check(not handled, "with a BICYCLE, onStep does not intercept the step") + check(Game.stack:top() == ow, "no text box opened when riding a BICYCLE") +end + +package.loaded["src.render.TextBox"] = realTB + +S.finish() diff --git a/tests/parity_explode_invulnerable.lua b/tests/parity_explode_invulnerable.lua new file mode 100644 index 00000000..6615e5b2 --- /dev/null +++ b/tests/parity_explode_invulnerable.lua @@ -0,0 +1,72 @@ +-- Parity test: Self-Destruct/Explosion against a mid-Fly/Dig +-- (semi-invulnerable) target still faints the user (#528). +-- +-- EffectRegistry.runDamaging's target.invulnerable branch prints the miss +-- text and returned without ever calling record.onMiss, while the +-- accuracy-roll, type-immunity and floored-damage miss branches all did. +-- EXPLODE_EFFECT (src/battle/MoveEffects.lua) relies on onMiss to run +-- battle:selfDestruct(user); skipping it left the user at full HP. +-- +-- pokered engine/battle/core.asm MoveHitTest: "bit INVULNERABLE, [hl] / +-- jp nz, .moveMissed" sets the same wMoveMissed as a failed accuracy roll, +-- and the shared miss handler runs the explode effect regardless of which +-- branch set it ("even if Explosion or Selfdestruct missed, its effect +-- still needs to be activated"). JUMP_KICK_EFFECT's onMiss filters on +-- reason == "accuracy", so a crash-damage kick must NOT also fire here. +-- +-- Self-contained; run via `luajit tests/parity_explode_invulnerable.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 explode invulnerable") +local check, eq = S.check, S.eq + +local Data = require("src.core.Data") +Data:load() + +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local StateStack = require("src.core.StateStack") +local Pokemon = require("src.pokemon.Pokemon") +local BattleState = require("src.battle.BattleState") + +Game.data = Data +Game.input = Input; Input:init() +Game.stack = StateStack; StateStack:init() +Game.save = require("src.core.SaveData").newGame() + +-- Self-Destruct against a target mid-Fly (invulnerable, never-miss moves +-- aside): the move never rolls accuracy at all, it just hits the +-- invulnerable branch in EffectRegistry.runDamaging. +local function selfDestructVsInvulnerable(moveId) + Game.save.party = { Pokemon.new(Data, "GEODUDE", 30) } + local b = BattleState.newWild(Game, "PIDGEY", 20) + b.enemy.invulnerable = true -- mid-Fly/Dig, set by ChargeEffect on turn 1 + local hpBefore = b.enemy.mon.hp + b:performMove(b.player, b.enemy, { id = moveId, pp = 5 }) + return b, hpBefore +end + +for _, moveId in ipairs({ "SELFDESTRUCT", "EXPLOSION" }) do + local b, enemyHpBefore = selfDestructVsInvulnerable(moveId) + eq(b.player.mon.hp, 0, moveId .. " vs an invulnerable target still faints the user") + eq(b.enemy.mon.hp, enemyHpBefore, + moveId .. " vs an invulnerable target deals the target no damage") + local sawMiss = false + for _, r in ipairs(b.queue) do + if r.text and r.text:find("attack missed", 1, true) then sawMiss = true end + end + check(sawMiss, moveId .. " still prints the miss text against an invulnerable target") +end + +-- Companion: JUMP_KICK's onMiss must reject the "invulnerable" reason, so +-- a mid-Fly target does not also take Jump Kick's crash damage (its +-- onMiss guard only fires for reason == "accuracy"). +Game.save.party = { Pokemon.new(Data, "GEODUDE", 30) } +local jk = BattleState.newWild(Game, "PIDGEY", 20) +jk.enemy.invulnerable = true +local jkHpBefore = jk.player.mon.hp +jk:performMove(jk.player, jk.enemy, { id = "JUMP_KICK", pp = 5 }) +eq(jk.player.mon.hp, jkHpBefore, + "JUMP KICK vs an invulnerable target takes no crash damage (reason filter holds)") + +S.finish() diff --git a/tests/parity_link_fingerprint_yellow.lua b/tests/parity_link_fingerprint_yellow.lua new file mode 100644 index 00000000..d1c9d670 --- /dev/null +++ b/tests/parity_link_fingerprint_yellow.lua @@ -0,0 +1,102 @@ +-- Parity test: the link fingerprint no longer hashes catchRate, so a +-- Yellow-shaped dataset that differs from Red/Blue only at +-- Dragonair/Dragonite's catch rate digests identical and the handshake +-- reports "full" (#511). +-- +-- Real cross-version link play (two processes, Red vs Yellow) is outside +-- what this checkout can assert: it needs both a Red and a Yellow +-- data/generated/ cache and two live UDP peers. What IS assertable from +-- one process: (1) catchRate is excluded from the hashed species surface, +-- so editing only that field never moves the digest, and (2) a synthetic +-- Yellow stand-in built by copying the real Data and retuning just those +-- two species' catchRate (the only real R/B vs Yellow link-surface delta, +-- per data/pokemon/base_stats/dragonair.asm 45 vs 27 and dragonite.asm 45 +-- vs 9) fingerprints identically and clears Handshake.checkCompat as +-- "full", not "subset". +-- +-- A human still has to run the actual cross-version check: launch a Red +-- instance and a Yellow instance (POKEPORT_VERSION=red / =yellow, +-- distinct POKEPORT_IDENTITY sandboxes), host/join on localhost, and +-- confirm no "game data is not the same" notice appears and a trade goes +-- through both ways. +-- +-- Self-contained; run via `luajit tests/parity_link_fingerprint_yellow.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 link fingerprint yellow") +local check, eq = S.check, S.eq + +local Data = require("src.core.Data") +Data:load() + +local Fingerprint = require("src.link.Fingerprint") +local Handshake = require("src.link.Handshake") + +local function copy(record) + local out = {} + for k, v in pairs(record) do out[k] = v end + return out +end + +local function cloneData(base) + local out = { pokemon = {}, moves = {}, type_chart = base.type_chart, + constants = base.constants } + for id, record in pairs(base.pokemon) do out.pokemon[id] = record end + for id, record in pairs(base.moves) do out.moves[id] = record end + return out +end + +-- (1) catchRate alone must not move the digest: this is the field +-- allowlist change at src/link/Fingerprint.lua SPECIES_FIELDS. +local vanilla = cloneData(Data) +local first = Fingerprint.compute(vanilla, {}) + +local rateOnly = cloneData(Data) +local pidgeyRate = copy(Data.pokemon.PIDGEY) +pidgeyRate.catchRate = (pidgeyRate.catchRate or 0) + 1 +rateOnly.pokemon.PIDGEY = pidgeyRate +eq(Fingerprint.compute(rateOnly, {}), first, + "a catchRate-only edit does not move the link fingerprint") + +-- (2) a Yellow stand-in: same records, Dragonair/Dragonite catch rate +-- retuned to Yellow's values (the only real R/B vs Yellow link delta). +Fingerprint.forget(vanilla) +local yellowish = cloneData(Data) +if Data.pokemon.DRAGONAIR then + local dragonair = copy(Data.pokemon.DRAGONAIR) + dragonair.catchRate = 27 -- pokeyellow data/pokemon/base_stats/dragonair.asm + yellowish.pokemon.DRAGONAIR = dragonair +end +if Data.pokemon.DRAGONITE then + local dragonite = copy(Data.pokemon.DRAGONITE) + dragonite.catchRate = 9 -- pokeyellow data/pokemon/base_stats/dragonite.asm + yellowish.pokemon.DRAGONITE = dragonite +end +check(Data.pokemon.DRAGONAIR ~= nil and Data.pokemon.DRAGONITE ~= nil, + "fixture has Dragonair and Dragonite to retune") +local yellowPrint = Fingerprint.compute(yellowish, {}) +eq(yellowPrint, first, + "a Yellow-shaped dataset (only Dragonair/Dragonite catch rate differs) " .. + "fingerprints identically to Red/Blue") + +-- (3) the handshake actually reads "full" for that pairing, not "subset" +local redHello = { protocol = 2, engineVersion = "0.1.0", fingerprint = first } +local yellowHello = { protocol = 2, engineVersion = "0.1.0", fingerprint = yellowPrint } +local verdict, reason = Handshake.checkCompat(redHello, yellowHello) +eq(verdict, "full", "Red vs Yellow-shaped peer clears the handshake as full") +check(reason == nil, "a full verdict carries no mismatch reason") + +-- control: a real gameplay-affecting edit (base stats) still splits the +-- two builds, so the fingerprint has not gone toothless +local buffed = cloneData(Data) +local strongPidgey = copy(Data.pokemon.PIDGEY) +strongPidgey.baseStats = copy(Data.pokemon.PIDGEY.baseStats) +strongPidgey.baseStats.attack = strongPidgey.baseStats.attack + 1 +buffed.pokemon.PIDGEY = strongPidgey +local buffedPrint = Fingerprint.compute(buffed, {}) +check(buffedPrint ~= first, "a baseStats edit still moves the digest") +local buffedHello = { protocol = 2, engineVersion = "0.1.0", fingerprint = buffedPrint } +local verdict2 = Handshake.checkCompat(redHello, buffedHello) +eq(verdict2, "subset", "a genuinely different dataset still reads subset, not full") + +S.finish() diff --git a/tests/parity_midstep_buttons.lua b/tests/parity_midstep_buttons.lua index d5fb2833..04031b9d 100644 --- a/tests/parity_midstep_buttons.lua +++ b/tests/parity_midstep_buttons.lua @@ -6,17 +6,25 @@ -- is nonzero ("the player sprite has not yet completed the walking -- animation"), jumps straight to .moveAhead -- JoypadOverworld, and with -- it the START check, the A check, and every direction initiation, only --- ever runs while the player stands on a tile. A button pressed mid-step --- is simply never seen. +-- ever runs while the player stands on a tile. -- -- The port ran handleInput() every frame regardless of player.moving, so a -- mid-step A/START press pushed its TextBox/StartMenu right there and -- froze Red between tiles, mid-animation (#286: running up to Nurse Joy -- and mashing A stops him half off the tile). -- +-- Second oracle, engine/joypad.asm _Joypad: hJoyPressed is +-- (hJoyLast ^ hJoyInput) & hJoyInput, and hJoyLast only advances on an +-- explicit `call Joypad`. vblank's per-frame ReadJoypad writes hJoyInput +-- alone, and the mid-step path never calls Joypad, so hJoyLast is FROZEN +-- for the whole animation. A button pressed mid-step and still held when +-- the step lands therefore reads as a fresh press at the next poll; one +-- released before the step lands is genuinely lost. The port used to drop +-- both, which on the Cycling Road roll made START a coin flip (#525). +-- -- The invariant: while a step is in progress, A and START change nothing --- (no TextBox, no StartMenu, the step completes); once the player stands --- on the tile again, both work. +-- (no TextBox, no StartMenu, the step completes). On the landing frame a +-- still-held A or START is acted on, a released one is not. package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end @@ -52,6 +60,15 @@ local function step(pressedBtn) ow:update(1 / 60) end +-- A synthetic pressQueue inject has no source entry, so Input:step sets +-- state[btn] = true and nothing ever clears it (src/core/Input.lua) -- the +-- harness models a HELD button. Most cases below want a tap, so release it +-- explicitly; the held cases are called out where they matter. +local function tap(btn) + step(btn) + Input.state[btn] = false +end + -- start a step south (held direction, like hJoyHeld) Input.state.down = true step() @@ -67,14 +84,14 @@ ow.interact = function(self, ...) return baseInteract(self, ...) end --- mid-step A press: nothing may happen (the original never sees it) -step("a") +-- mid-step A press: nothing may happen (the original acts on nothing here) +tap("a") eq(interactCalls, 0, "mid-step A never reaches interact()") check(Game.stack:top() == ow, "mid-step A pushes no TextBox") check(ow.player.moving, "mid-step A does not interrupt the step") -- mid-step START press: no start menu either -step("start") +tap("start") check(Game.stack:top() == ow, "mid-step START opens no menu") check(ow.player.moving, "mid-step START does not interrupt the step") @@ -84,7 +101,10 @@ while ow.player.moving and guard < 60 do step(); guard = guard + 1 end eq(ow.player.cellY, startY + 1, "the step completes onto the next tile") -- the issue's actual repro ("press A quickly/early" running up to Nurse --- Joy): start another step and press A on its FINAL mid-step frame +-- Joy): start another step and press A on its FINAL mid-step frame, then +-- RELEASE it before the step lands. hJoyLast is frozen through the +-- animation, so the next poll sees the button already up and computes no +-- edge (engine/joypad.asm) -- this press really is lost. Input.state.down = true step() Input.state.down = false @@ -93,17 +113,36 @@ guard = 0 while ow.player.moving and guard < 60 do guard = guard + 1 if guard == (ow.player.stepFramesCur or 16) - 1 then - step("a") -- the last frame before landing + tap("a") -- the last frame before landing, released immediately else step() end end check(not ow.player.moving, "the second step completes") -eq(interactCalls, 0, "a last-frame A press is still swallowed (no buffering)") -check(Game.stack:top() == ow, "last-frame A pushes no TextBox") +step() -- the landing frame, where a still-held button would be polled +eq(interactCalls, 0, "a mid-step A released before landing is still lost") +check(Game.stack:top() == ow, "the released last-frame A pushes no TextBox") + +-- ...but a mid-step A that is STILL HELD when the step lands is delivered +-- on the landing frame, because hJoyLast never advanced (#525). Nothing +-- happens mid-step either way: the poll is deferred, not the action. +Input.state.down = true +step() +Input.state.down = false +check(ow.player.moving, "third step starts") +step("a") -- pressed mid-step and left held +eq(interactCalls, 0, "the held A still does nothing mid-step") +check(ow.player.moving, "the held A does not interrupt the step") +guard = 0 +while ow.player.moving and guard < 60 do step(); guard = guard + 1 end +eq(interactCalls, 0, "still nothing while the step runs out") +step() -- landing frame +eq(interactCalls, 1, "a held mid-step A is polled on the landing frame") +Input.state.a = false -- standing on the tile again, START and A work as always -step("start") +interactCalls = 0 +tap("start") check(Game.stack:top() ~= ow, "START opens the start menu on a tile") while Game.stack:top() do Game.stack:pop() end Game.stack:push(OW, "PALLET_TOWN", 6, 9, "down") diff --git a/tests/parity_silph_rockets.lua b/tests/parity_silph_rockets.lua index e5fcc596..7048a42f 100644 --- a/tests/parity_silph_rockets.lua +++ b/tests/parity_silph_rockets.lua @@ -176,7 +176,14 @@ for _, key in ipairs({ "_SilphCo11FSilphPresidentText", end -- run the rows through the real interpreter with only the leaf commands --- stubbed, so the branch arithmetic is what is under test +-- stubbed, so the branch arithmetic is what is under test. Commands is a +-- shared module singleton read by every later suite dofile'd into this +-- same process (tests/run_tests.lua), so the three stubs must be restored +-- before this file falls off the end -- an unrestored give_item stub +-- silently swallows any later test's item grants, matching the save/ +-- restore parity_gift_atomicity.lua already does for show_text. +local origFacePlayer, origShowText, origGiveItem = + Commands.face_player, Commands.show_text, Commands.give_item local shown, given Commands.face_player = function() end Commands.show_text = function(_, key) shown[#shown + 1] = key end @@ -214,4 +221,7 @@ do "an unbeaten-Giovanni save still gets the thank-you and the ball") end +Commands.face_player, Commands.show_text, Commands.give_item = + origFacePlayer, origShowText, origGiveItem + S.finish() diff --git a/tests/parity_surf_rod_refusal.lua b/tests/parity_surf_rod_refusal.lua new file mode 100644 index 00000000..a2791d2d --- /dev/null +++ b/tests/parity_surf_rod_refusal.lua @@ -0,0 +1,46 @@ +-- Parity test: all three fishing rods are refused while surfing (#533). +-- +-- pokered's FishingInit (engine/items/item_effects.asm) reads +-- wWalkBikeSurfState after IsNextTileShoreOrWater passes, and refuses +-- with carry set when it equals 2 (surfing). Every ItemUseXRod entry does +-- `jp c, ItemUseNotTime` on that carry, so a surfing player gets the same +-- "OAK: %s! This isn't the time to use that!" text used for the +-- mid-battle refusal, and the rod is never consumed. The port's ow.player +-- surf flag was never checked, so BagMenu's isWaterCell check alone let a +-- surfing player fish (the faced cell while surfing is always water). +-- +-- Self-contained; run via `luajit tests/parity_surf_rod_refusal.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 surf rod refusal") +local check, eq = S.check, S.eq + +local Data = require("src.core.Data") +Data:load() + +local ItemEffects = require("src.inventory.ItemEffects") +local save = require("src.core.SaveData").newGame() + +for _, rod in ipairs({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }) do + -- surfing: refused even though ow/player exists and the faced cell + -- would otherwise be fishable water + local surfingOw = { player = { surfing = true } } + local result, msgs = ItemEffects.use(Data, save, rod, nil, false, nil, surfingOw) + eq(result, "failed", rod .. " is refused while surfing") + check(msgs and msgs[1] and msgs[1]:find("isn't the", 1, true) ~= nil, + rod .. " surfing refusal uses the OAK 'not the time' text") + eq(save.inventory[rod], nil, rod .. " is not consumed while surfing") + + -- not surfing: still allowed to fish + local groundOw = { player = { surfing = false } } + local r2 = ItemEffects.use(Data, save, rod, nil, false, nil, groundOw) + eq(r2, "fish", rod .. " still fishes normally when not surfing") + + -- no overworld handle at all (e.g. called from a context without ow): + -- must not error, and must not silently allow fishing that a surfing + -- player would be refused + local r3 = ItemEffects.use(Data, save, rod, nil, false, nil, nil) + eq(r3, "fish", rod .. " with no ow handle falls through to fish (no surf info available)") +end + +S.finish() diff --git a/tests/parity_surf_save_load_bug536.lua b/tests/parity_surf_save_load_bug536.lua new file mode 100644 index 00000000..d4616691 --- /dev/null +++ b/tests/parity_surf_save_load_bug536.lua @@ -0,0 +1,93 @@ +-- Regression (#536): a save made mid-surf must resume mid-surf. +-- +-- wWalkBikeSurfState (ram/wram.asm) sits inside wMainDataStart..wMainDataEnd, +-- the range engine/menus/save.asm block-copies into sMainData on save and +-- back out on load, so the original persists the surf state across a save. +-- OverworldState:captureSave now writes save.player.surfing and +-- OverworldState:setMap's boot path (opts.via == "boot", only taken from +-- :enter, which every StateStack push runs through) restores it before +-- Music/PikachuFollower see the player. Before the fix, self.player.surfing +-- was never serialized at all, so a reload always came back on foot; +-- Collision.canMove (src/world/Collision.lua) then picks land tile-pairs for +-- a non-surfing mover, which never permit standing on a water cell, so the +-- reload could softlock on the water tile the save was made on. +-- +-- Self-contained; run via `luajit tests/parity_surf_save_load_bug536.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.ROUTE_20) then Data:load() end + +local Game = require("src.core.Game") +local Input = require("src.core.Input") +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 surf save/load (#536)") +local check, eq = S.check, S.eq + +Game.data = Data +Game.input = Input; Input:init() +Game.renderer = Renderer; Renderer:init() +Game.stack = StateStack +StateStack:init() + +-- ground truth from parity_cinnabar_east_surf.lua: ROUTE_20 (0,8) is water. +local r20 = require("src.world.MapLoader").load(Data, "ROUTE_20") +check(r20:isWaterCell(0, 8), "ROUTE_20 (0,8) is water") + +-- --- a save made mid-surf on a water cell resumes surfing -------------- +Game.save = SaveData.newGame() +Game.save.player.surfing = true +while Game.stack:top() do Game.stack:pop() end +Game.stack:push(OW, "ROUTE_20", 0, 8, "left") +local ow = Game.stack:top() +eq(ow.player.surfing, true, "boot onto a water cell with surfing=true restores surfing") +-- sprite/movement mode: Player:pose() (src/world/Player.lua) only ever +-- selects surfSprite when self.surfing is truthy, so this is the same +-- switch that put the walking sheet back on a surfing player pre-fix +local sprite = ow.player:pose() +check(sprite == ow.player.surfSprite, + "restored surfing selects the surf sprite sheet, not the walking one") + +-- --- a save made on foot on land resumes on foot ------------------------ +Game.save = SaveData.newGame() +Game.save.player.surfing = false +while Game.stack:top() do Game.stack:pop() end +Game.stack:push(OW, "PALLET_TOWN", 5, 6, "down") +ow = Game.stack:top() +eq(ow.player.surfing, false, "boot onto land with surfing=false stays on foot") + +-- --- saves written before #536 (no surfing field at all) default safely - +Game.save = SaveData.newGame() +Game.save.player.surfing = nil +while Game.stack:top() do Game.stack:pop() end +Game.stack:push(OW, "PALLET_TOWN", 5, 6, "down") +ow = Game.stack:top() +check(not ow.player.surfing, "a pre-#536 save with no surfing field boots on foot, not truthy-nil") + +-- --- the true round trip: captureSave -> a fresh boot reads it back back --- +Game.save = SaveData.newGame() +while Game.stack:top() do Game.stack:pop() end +Game.stack:push(OW, "ROUTE_20", 0, 8, "left") +ow = Game.stack:top() +ow.player.surfing = true -- player mounted SURF while already standing on water +local captured = SaveData.newGame() +ow:captureSave(captured) +eq(captured.player.map, "ROUTE_20", "captureSave records the map") +eq(captured.player.x, 0, "captureSave records x") +eq(captured.player.y, 8, "captureSave records y") +eq(captured.player.surfing, true, "captureSave records surfing=true") + +-- reload from that captured save: a brand-new boot must come back surfing +Game.save = captured +while Game.stack:top() do Game.stack:pop() end +Game.stack:push(OW, captured.player.map, captured.player.x, captured.player.y, + captured.player.facing) +ow = Game.stack:top() +eq(ow.player.surfing, true, + "round trip: captureSave -> reboot restores surfing on the same water cell") + +S.finish() diff --git a/tests/parity_viridian_school_blackboard_bug503.lua b/tests/parity_viridian_school_blackboard_bug503.lua new file mode 100644 index 00000000..c2b131c1 --- /dev/null +++ b/tests/parity_viridian_school_blackboard_bug503.lua @@ -0,0 +1,171 @@ +-- Regression (#503): the Viridian School House blackboard and notebook +-- were both dead A presses -- data/events/hidden_events.asm's two +-- hidden_text_predef rows for this map never reached data/generated/ +-- field.lua (tools/extract/field.py only parses `hidden_event` rows), and +-- there was no onInteract hook for this map at all. Same shape as the +-- Celadon Mansion roof house fix (#391, tests/parity_celadon_roof_ +-- readables.lua): ViridianSchoolBlackboard (engine/events/hidden_events/ +-- school_blackboard.asm) prints the intro, then loops a "which heading" +-- prompt and a 5-status menu (SLP/PSN/PAR/BRN/FRZ) plus QUIT until B/QUIT +-- closes it; ViridianSchoolNotebook (school_notebooks.asm) is a 5-page +-- book that asks to turn pages 1-3, auto-turns 4, and the girl catches you +-- on page 5. +-- +-- Self-contained; run via +-- `luajit tests/parity_viridian_school_blackboard_bug503.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.VIRIDIAN_SCHOOL_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 Viridian School blackboard/notebook (#503)") +local check, eq = S.check, S.eq + +local MAP = "VIRIDIAN_SCHOOL_HOUSE" + +for _, key in ipairs({ + "_ViridianSchoolBlackboardText1", "_ViridianSchoolBlackboardText2", + "_ViridianBlackboardSleepText", "_ViridianBlackboardPoisonText", + "_ViridianBlackboardPrlzText", "_ViridianBlackboardBurnText", + "_ViridianBlackboardFrozenText", + "_ViridianSchoolNotebookText1", "_ViridianSchoolNotebookText5", + "_TurnPageText" }) 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") + +local stack = {} +local game = { + data = Data, + save = { player = { name = "RED" } }, + 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 + +-- cells that are neither the blackboard (3,0) nor the notebook (3,4) stay silent +for _, cell in ipairs({ { 4, 0 }, { 3, 1 }, { 4, 4 }, { 3, 3 }, { 2, 0 } }) 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: intro -> prompt -> 6-item menu (5 statuses + QUIT) === +stack = {} +eq(hooks.onInteract(game, ow, 3, 0), true, "the blackboard at (3,0) is claimed") +local intro = stack[#stack] +check(getmetatable(intro) == TextBox, "the blackboard opens a TextBox") +check(pages(intro):find("STATUS", 1, true) ~= nil, + "the intro is _ViridianSchoolBlackboardText1 (describes STATUS changes)") +check(type(intro.onDone) == "function", "the intro 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 status menu") +eq(#menu.items, 6, "five statuses plus QUIT") +local labels = {} +for i, item in ipairs(menu.items) do labels[i] = (item.label or ""):gsub("^%s+", "") end +eq(table.concat(labels, "/"), "SLP/PSN/PAR/BRN/FRZ/QUIT", + "the statuses read SLP/PSN/PAR/BRN/FRZ, QUIT last") +eq(menu.items[6].onSelect, nil, "QUIT has no onSelect; Menu's own B/pop closes it") + +local STATUS_KEYS = { + "_ViridianBlackboardSleepText", "_ViridianBlackboardPoisonText", + "_ViridianBlackboardPrlzText", "_ViridianBlackboardBurnText", + "_ViridianBlackboardFrozenText", +} +for i, key in ipairs(STATUS_KEYS) do + stack = {} + menu.items[i].onSelect() + local blurb = stack[#stack] + check(getmetatable(blurb) == TextBox, labels[i] .. " prints a text box") + local want = Data.text[key]:match("^[^\n\011\012]+") + check(pages(blurb):find(want, 1, true) ~= nil, + labels[i] .. " prints " .. key) + check(type(blurb.onDone) == "function", + labels[i] .. " returns to the prompt instead of dropping out (loops)") + 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] .. " (loop, not exit)") +end + +-- === the notebook: five pages, three yes/no turns, then the girl catches you == +stack = {} +eq(hooks.onInteract(game, ow, 3, 4), true, "the notebook at (3,4) is claimed") +local page1 = stack[#stack] +check(getmetatable(page1) == TextBox, "the notebook opens on page 1") +check(pages(page1):find("POKé BALL", 1, true) ~= nil, + "page 1 is _ViridianSchoolNotebookText1") + +-- pages 1-3 ask "Turn the page?" and only turn on yes. The real ChoiceBox +-- pops the asking TextBox itself before invoking .choice (TextBox.lua:228- +-- 235); mirror that pop-then-call order here. +local function resolveChoice(box, yes) + table.remove(stack) -- the ChoiceBox pops the asking TextBox first + box.choice(yes) +end + +page1.onDone() +local ask1 = stack[#stack] +check(getmetatable(ask1) == TextBox and ask1.choice ~= nil, + "page 1 asks _TurnPageText with a yes/no choice") +resolveChoice(ask1, true) +local page2 = stack[#stack] +check(pages(page2):find("weaken", 1, true) ~= nil, "yes turns to page 2") + +page2.onDone() +resolveChoice(stack[#stack], true) +local page3 = stack[#stack] +check(pages(page3):find("engage", 1, true) ~= nil, "page 3 follows page 2") + +page3.onDone() +resolveChoice(stack[#stack], true) +local page4 = stack[#stack] +check(pages(page4):find("ELITE FOUR", 1, true) ~= nil, "page 4 follows page 3") + +-- page 4 turns automatically into page 5, no prompt +page4.onDone() +local page5 = stack[#stack] +check(getmetatable(page5) == TextBox, "page 4 auto-turns") +check(pages(page5):find("GIRL", 1, true) ~= nil, + "page 5 is the girl catching you reading her notes") + +-- saying no on page 1 stops the read right there: no further page pushed +stack = {} +hooks.onInteract(game, ow, 3, 4) +stack[#stack].onDone() +local askAgain = stack[#stack] +local before = #stack +resolveChoice(askAgain, false) +eq(#stack, before - 1, + "saying no to the first turn pops the ask box and pushes nothing else") + +S.finish() diff --git a/tests/parity_wardens_house_bug535.lua b/tests/parity_wardens_house_bug535.lua new file mode 100644 index 00000000..aafda88c --- /dev/null +++ b/tests/parity_wardens_house_bug535.lua @@ -0,0 +1,97 @@ +-- Regression (#535): after handing over the GOLD TEETH and receiving +-- HM04, every later talk to the Warden must still say something. +-- +-- data/scripts/story.lua's TEXT_WARDENSHOUSE_WARDEN pointed the +-- EVENT_GOT_HM04 branch (row 3, jump_if_true) at the same silent-end jump +-- the give-then-thank fallthrough uses (row 13), so ScriptRunner's pc ran +-- straight past the end of the row list with zero show_text calls -- the +-- Warden went mute on every visit after the trade. pokered's .got_item +-- branch (scripts/WardensHouse.asm) instead prints .HM04ExplanationText +-- (text/WardensHouse.asm: "HM04 teaches STRENGTH ... SECRET HOUSE in +-- SAFARI ZONE") on every subsequent talk. +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 wardens house (#535)") +local check, eq = S.check, S.eq + +local Commands = require("src.script.Commands") +local Flags = require("src.script.Flags") +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local SaveData = require("src.core.SaveData") +local ScriptRunner = require("src.script.ScriptRunner") +local StateStack = require("src.core.StateStack") + +Game.data = Data +Game.input = Input; Input:init() +Game.stack = StateStack; StateStack:init() +require("src.render.Font").load(Data) + +local story = require("data.scripts.story") +local script = story.WARDENS_HOUSE.talk.TEXT_WARDENSHOUSE_WARDEN + +-- instrument show_text the way parity_gift_atomicity.lua does, to record +-- exactly which text ids actually printed +local shown = {} +local origShow = Commands.show_text +Commands.show_text = function(ctx, textId, subs) + shown[#shown + 1] = textId + return origShow(ctx, textId, subs) +end + +local function runScript() + shown = {} + StateStack:init() + local ow = { map = { id = "WARDENS_HOUSE", def = { label = "WardensHouse" } }, + npcs = {}, entities = {} } + local r = ScriptRunner.new(Game, ow) + r:run(script, { npc = { def = {}, facePlayer = function() end }, + overworld = ow }) + local guard = 0 + while r:isRunning() and guard < 3000 do + guard = guard + 1 + Input.pressed = { a = true } + StateStack:update(1 / 60) + r:update() + end + Input.pressed = {} + return not r:isRunning() +end + +-- === 1) first talk, holding the GOLD TEETH: gives HM04, sets the flag === +Game.save = SaveData.newGame() +Game.save.inventory.GOLD_TEETH = 1 +check(runScript(), "give-the-teeth talk completes") +eq(table.concat(shown, ","), + "_WardensHouseWardenGaveTheGoldTeethText,_WardensHouseWardenThanksText," + .. "_WardensHouseWardenReceivedHM04Text", + "handing over the teeth shows the give/thanks/received sequence, nothing after") +check(Flags.get(Game.save, "EVENT_GOT_HM04"), "EVENT_GOT_HM04 is set") +check(Flags.get(Game.save, "EVENT_GAVE_GOLD_TEETH"), "EVENT_GAVE_GOLD_TEETH is set") +check(Game.save.inventory.HM_STRENGTH ~= nil, "HM04 (Strength) lands in the bag") +check(not Game.save.inventory.GOLD_TEETH, "the GOLD TEETH is taken") + +-- === 2) the regression itself: every later talk, once EVENT_GOT_HM04 is +-- set, must print the explanation text instead of nothing === +check(runScript(), "post-gift talk completes") +eq(table.concat(shown, ","), "_WardensHouseWardenHM04ExplanationText", + "every subsequent talk now prints the HM04/Safari Zone explanation (#535)") + +-- run it again to confirm this is not a one-shot: it repeats every visit +check(runScript(), "a third talk completes") +eq(table.concat(shown, ","), "_WardensHouseWardenHM04ExplanationText", + "the explanation text repeats on every later talk, not just the first") + +-- === 3) unrelated path is unchanged: no GOLD TEETH, no flag yet === +Game.save = SaveData.newGame() +check(runScript(), "empty-handed talk completes") +eq(table.concat(shown, ","), "_WardensHouseWardenGibberish1Text", + "without the GOLD TEETH the Warden's gibberish line is unchanged") +check(not Flags.get(Game.save, "EVENT_GOT_HM04"), "no HM04 yet") + +Commands.show_text = origShow + +S.finish() diff --git a/tests/run_save_editor_tests.lua b/tests/run_save_editor_tests.lua index 9a312a37..f1ea4888 100644 --- a/tests/run_save_editor_tests.lua +++ b/tests/run_save_editor_tests.lua @@ -528,5 +528,105 @@ do end end +do + -- #515: badge read/write agreement between SaveData/the in-game grant + -- and the editor. checkVictoryRewards (src/world/OverworldController.lua) + -- writes save.inventory[badge] = 1, a truthy number, not the boolean + -- `true` the editor used to compare against with `== true`. This drives + -- the same field through a real save encode/decode round trip and checks + -- src/inventory/Badges.count (what the in-game badge case/count reads) + -- agrees with what the editor's own badge state shows. + local Badges = require("src.inventory.Badges") + + local save = SaveData.newGame() + local id = Badges.list(Data)[1].id + -- simulate the in-game grant's exact representation, not the editor's + save.inventory[id] = 1 + eq(Badges.count(Data, save), 1, "Badges.count sees a numeric 1 grant as earned") + + local encoded = SaveData.encode(save) + local back = SaveData.decode(encoded) + eq(back.inventory[id], 1, "save round trip preserves the numeric badge flag") + eq(Badges.count(Data, back), 1, "Badges.count still agrees after the round trip") + + local S = State.new() + S.data = Data + S.cat = Catalog.build(Data) + S.save = back + check(Ops.badgeIds(S)[1] ~= nil, "the editor's badge catalog is non-empty") + check(S.save.inventory[id] and true or false, + "the editor's own truthy read (panel badge chip state) sees the grant as earned") + + -- toggling off then on again round-trips through the editor's own write + -- shape and still agrees with Badges.count + Ops.toggleBadge(S, id) + eq(S.save.inventory[id], nil, "toggleBadge clears the badge (nil, not false)") + eq(Badges.count(Data, S.save), 0, "Badges.count agrees once cleared") + Ops.toggleBadge(S, id) + eq(S.save.inventory[id], 1, "toggleBadge re-earns the badge as a truthy 1, matching the in-game grant") + eq(Badges.count(Data, S.save), 1, "Badges.count agrees once re-earned") +end + +do + -- #529: focusing a text field raises the OS soft keyboard on Android/iOS + -- (love.keyboard.setTextInput(true, x, y, w, h)) and blurring lowers it; + -- desktop raises the same way but never lowers, since setTextInput is + -- global SDL state and the launcher's own text fields (RomImporter slot + -- rename, ROM finder) depend on it staying enabled. + -- + -- This is the closest honest check this checkout can run: the real soft + -- keyboard is OS chrome outside the LOVE frame, unreachable by any + -- driver. A human still has to verify on an Android build that the + -- keyboard visibly rises over the Items search bar, typed characters + -- filter the list, and Enter/Escape/switching tabs lowers it again. + local Kit = require("Kit") + local calls = {} + local savedKeyboard, savedSystem = love.keyboard, love.system + + local function stubOS(name) + love.system = { getOS = function() return name end } + love.keyboard = { + isDown = function() return false end, + setTextInput = function(...) calls[#calls + 1] = { ... } end, + } + end + + -- Android: focusing raises with the field's rect, restaying focused on + -- the same id does not re-raise, and blur lowers it. + stubOS("Android") + Kit.focus = nil + Kit.beginFrame(15, 15, true) + Kit.textfield("kb-test", 10, 10, 100, 20, "", "type here") + eq(#calls, 1, "Android: focusing a field raises the soft keyboard") + check(calls[1][1] == true, "Android: raise call passes enable=true") + eq(calls[1][2], 10, "Android: raise call passes the field's x") + eq(calls[1][3], 10, "Android: raise call passes the field's y") + eq(calls[1][4], 100, "Android: raise call passes the field's w") + eq(calls[1][5], 20, "Android: raise call passes the field's h") + + Kit.beginFrame(15, 15, false) + Kit.textfield("kb-test", 10, 10, 100, 20, "abc", "type here") + eq(#calls, 1, "Android: staying focused on the same field does not re-raise") + + Kit.blur() + eq(#calls, 2, "Android: blur lowers the soft keyboard") + eq(calls[2][1], false, "Android: lower call passes enable=false") + check(Kit.focus == nil, "blur clears Kit.focus") + + -- Desktop: focusing still raises (harmless there), but blur must not + -- disable text input globally -- the launcher's own fields rely on it + -- staying on. + calls = {} + stubOS("Mac OS X") + Kit.beginFrame(65, 65, true) + Kit.textfield("kb-test2", 60, 60, 80, 24, "", "") + eq(#calls, 1, "desktop: focusing a field still raises setTextInput") + Kit.blur() + eq(#calls, 1, "desktop: blur does not call setTextInput(false)") + + love.keyboard, love.system = savedKeyboard, savedSystem + Kit.focus = nil +end + print(string.format("save editor tests: %d passed, %d failed", passed, failed)) if failed > 0 then os.exit(1) end diff --git a/tests/save_editor_task6_tests.lua b/tests/save_editor_task6_tests.lua index 011315b9..b84f9749 100644 --- a/tests/save_editor_task6_tests.lua +++ b/tests/save_editor_task6_tests.lua @@ -230,13 +230,14 @@ do end do - -- badges are boolean flags on inventory, toggled not stacked + -- badges are truthy flags on inventory, toggled not stacked; the engine + -- writes them as 1 (#515), so the editor must too local S = newState() local ids = Ops.badgeIds(S) check(#ids > 0, "the catalog exposes badge ids") local id = ids[1] Ops.toggleBadge(S, id) - eq(S.save.inventory[id], true, "toggleBadge earns the badge") + eq(S.save.inventory[id], 1, "toggleBadge earns the badge") Ops.toggleBadge(S, id) eq(S.save.inventory[id], nil, "toggleBadge removes the badge (nil, not false)") end diff --git a/tools/make_yellow_manifest.py b/tools/make_yellow_manifest.py index e3d54bbb..164e8ca5 100755 --- a/tools/make_yellow_manifest.py +++ b/tools/make_yellow_manifest.py @@ -235,6 +235,37 @@ def _rebuild_yellow_sourced(yellow, pokeyellow): } +def _remap_audio(yellow, yellow_symbols): + """Re-anchor the Red-copied audio section on pokeyellow.sym (#522). + + Yellow's music bank $1f shifts after 1f:4294: Music_YellowIntro is a + 3-channel header (pokeyellow audio/headers/musicheaders3.asm) where + Red's Music_IntroBattle had 4, so Red's addresses for every later + header land on a channel-2 row (one voice, and no tempo command -- + Viridian Forest slow and hollow). Yellow also has a single + wave-sample table at Audio1_WavePointers (audio.asm "Music 1"), not + Red's per-engine 0x4373 copies, and CryData moved to 0e:5462. Names + absent from the sym (Music_IntroBattle, which Yellow only knows as + Music_YellowIntro) keep their positional mapping. + """ + for name, header in yellow["audio"]["musicHeaders"].items(): + symbol = yellow_symbols.by_name.get(name) + if symbol is not None: + header["bank"] = symbol.bank + header["address"] = symbol.address + wave = yellow_symbols.by_name.get("Audio1_WavePointers.wave0") + if wave is None: + raise SystemExit("pokeyellow.sym missing Audio1_WavePointers.wave0") + for engine in yellow["audio"]["waveBanks"]: + yellow["audio"]["waveBanks"][engine] = { + "bank": wave.bank, "address": wave.address, + } + cry = yellow_symbols.by_name.get("CryData") + if cry is None: + raise SystemExit("pokeyellow.sym missing CryData") + yellow["audio"]["cryData"] = {"bank": cry.bank, "address": cry.address} + + def derive(red, pokeyellow, symbols_path): """Return the Yellow manifest derived from the Red manifest dict.""" yellow = copy.deepcopy(red) @@ -261,6 +292,7 @@ def derive(red, pokeyellow, symbols_path): yellow = _drop_strings(yellow, dropped) rebuilt = _rebuild_yellow_sourced(yellow, pokeyellow) + _remap_audio(yellow, yellow_symbols) # #522 for label in YELLOW_EXTRA_TEXT_LABELS: if label not in yellow["text"]["labels"]: diff --git a/tools/rom_manifest_yellow.json b/tools/rom_manifest_yellow.json index 37882147..1acadb2f 100644 --- a/tools/rom_manifest_yellow.json +++ b/tools/rom_manifest_yellow.json @@ -10,7 +10,7 @@ "wildWin": "Music_DefeatedWildMon" }, "cryData": { - "address": 21574, + "address": 21602, "bank": 14 }, "cryHeaders": { @@ -473,7 +473,7 @@ "engine": 1 }, "Music_CinnabarMansion": { - "address": 17092, + "address": 17089, "bank": 31, "engine": 3 }, @@ -508,17 +508,17 @@ "engine": 2 }, "Music_Dungeon1": { - "address": 17056, + "address": 17053, "bank": 31, "engine": 3 }, "Music_Dungeon2": { - "address": 17068, + "address": 17065, "bank": 31, "engine": 3 }, "Music_Dungeon3": { - "address": 17080, + "address": 17077, "bank": 31, "engine": 3 }, @@ -573,12 +573,12 @@ "engine": 1 }, "Music_MeetEvilTrainer": { - "address": 17122, + "address": 17119, "bank": 31, "engine": 3 }, "Music_MeetFemaleTrainer": { - "address": 17131, + "address": 17128, "bank": 31, "engine": 3 }, @@ -588,7 +588,7 @@ "engine": 3 }, "Music_MeetMaleTrainer": { - "address": 17140, + "address": 17137, "bank": 31, "engine": 3 }, @@ -628,7 +628,7 @@ "engine": 1 }, "Music_PokemonTower": { - "address": 17104, + "address": 17101, "bank": 31, "engine": 3 }, @@ -663,7 +663,7 @@ "engine": 1 }, "Music_SilphCo": { - "address": 17113, + "address": 17110, "bank": 31, "engine": 3 }, @@ -1526,16 +1526,16 @@ }, "waveBanks": { "1": { - "address": 17267, + "address": 23078, "bank": 2 }, "2": { - "address": 17267, - "bank": 8 + "address": 23078, + "bank": 2 }, "3": { - "address": 17267, - "bank": 31 + "address": 23078, + "bank": 2 } } }, diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua index 0e005f60..018d108a 100644 --- a/tools/save-editor/App.lua +++ b/tools/save-editor/App.lua @@ -219,6 +219,11 @@ function App.unload() S = nil mods = nil App.dataVersion = nil + -- Kit is never evicted from package.loaded, so a Close taken while a text + -- field still owns focus would leak Kit.focus and a raised soft keyboard + -- (against a rect that is gone) into the launcher and the next session + -- (#529). + Kit.blur() end function App.save() diff --git a/tools/save-editor/Kit.lua b/tools/save-editor/Kit.lua index caa2d869..8fb484c6 100644 --- a/tools/save-editor/Kit.lua +++ b/tools/save-editor/Kit.lua @@ -24,6 +24,34 @@ Kit.scale = 1 local G = love and love.graphics or nil local edits = {} -- queued textinput / backspace since the last frame +local kbField = nil -- id of the field the OS soft keyboard is raised for + +-- Mobile LOVE only delivers love.textinput while setTextInput(true) is +-- active, and that call is what raises the Android/iOS soft keyboard; the +-- rect keeps the focused field visible above it. Desktop has text input on +-- by default and the launcher hosting this editor depends on that -- nothing +-- in src/import/RomImporter.lua (slot rename #205, ROM finder, mod index +-- prompt) ever enables it -- so the editor only ever raises there and never +-- lowers, since setTextInput is global SDL state, not per-widget (#529). +local function mobile() + local osName = love and love.system and love.system.getOS + and love.system.getOS() + return osName == "Android" or osName == "iOS" +end + +local function syncSoftKeyboard(id, x, y, w, h) + if not (love and love.keyboard and love.keyboard.setTextInput) then return end + if id then + if kbField ~= id then + kbField = id + love.keyboard.setTextInput(true, math.floor(x), math.floor(y), + math.ceil(w), math.ceil(h)) + end + elseif kbField then + kbField = nil + if mobile() then love.keyboard.setTextInput(false) end + end +end local function canPrintf() return G and type(G.printf) == "function" @@ -81,7 +109,10 @@ function Kit.keypressed(key) return false end -function Kit.blur() Kit.focus = nil end +function Kit.blur() + Kit.focus = nil + syncSoftKeyboard(nil) -- the soft keyboard follows focus down too (#529) +end -- ------------------------------------------------------------- hit testing function Kit.hit(x, y, w, h) @@ -319,11 +350,13 @@ function Kit.textfield(id, x, y, w, h, value, placeholder) if Kit.press(x, y, w, h) then Kit.focus = id end local focused = (Kit.focus == id) if focused then + -- raise (or hand off) the soft keyboard while this field owns focus (#529) + syncSoftKeyboard(id, x, y, w, h) for _, e in ipairs(edits) do if e == "\b" then value = value:sub(1, -2) elseif e == "\r" then - Kit.focus = nil + Kit.blur() -- commit/cancel also lowers the soft keyboard (#529) focused = false else value = value .. e diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua index 4cfb760f..0a883a22 100644 --- a/tools/save-editor/Ops.lua +++ b/tools/save-editor/Ops.lua @@ -411,7 +411,7 @@ function Ops.pcDrop(S, id) return Ops.mark(S, ("Dropped all %d %s from PC storage"):format(qty, id)) end --- Badges are boolean inventory flags, not stackable items, which is why the +-- Badges are truthy inventory flags, not stackable items, which is why the -- design gives them toggle chips instead of quantity rows. function Ops.isBadgeId(id) return id:find("BADGE", 1, true) ~= nil @@ -426,8 +426,13 @@ function Ops.badgeIds(S) end function Ops.toggleBadge(S, id) - local on = S.save.inventory[id] == true - S.save.inventory[id] = (not on) or nil + -- #515: badges are truthy inventory entries written as 1 by the in-game + -- grant (checkVictoryRewards, src/world/OverworldController.lua) and by + -- GenSave's .sav import; read and write that same shape here, or a badge + -- earned in game reads as unowned and an editor-written boolean blows up + -- Bag.add's `(inv[id] or 0) + qty` (src/inventory/Bag.lua). + local on = S.save.inventory[id] and true or false + S.save.inventory[id] = (not on) and 1 or nil return Ops.mark(S, ("%s %s"):format(id, on and "removed" or "earned")) end diff --git a/tools/save-editor/panels/Items.lua b/tools/save-editor/panels/Items.lua index affda449..f66291ab 100644 --- a/tools/save-editor/panels/Items.lua +++ b/tools/save-editor/panels/Items.lua @@ -154,7 +154,10 @@ function M.draw(S, Kit, x, y, w, h) Kit.card(x, badgeY, leftW, badgeH) local earned = 0 for _, id in ipairs(badgeIds) do - if S.save.inventory[id] == true then earned = earned + 1 end + -- #515: truthy check, not `== true` -- the in-game grant path stores a + -- number (see OverworldController.lua checkVictoryRewards), matching + -- src/inventory/Badges.lua's own truthy read. + if S.save.inventory[id] then earned = earned + 1 end end Kit.caption(x + pad, badgeY + pad, "BADGES") Kit.textRight("mono", ("%d/%d"):format(earned, #badgeIds), x + leftW - pad, @@ -164,7 +167,7 @@ function M.draw(S, Kit, x, y, w, h) for i, id in ipairs(badgeIds) do local bc = (i - 1) % badgeCols local br = math.floor((i - 1) / badgeCols) - local on = S.save.inventory[id] == true + local on = S.save.inventory[id] local short = id:gsub("BADGE$", "") if Kit.chip(x + pad + bc * (bW + 7 * s), bTop + br * (28 * s + 7 * s), bW, 28 * s, Kit.ellipsize("micro", short, bW - 8 * s), on,