CLOSES #503, CLOSES #511, CLOSES #515, CLOSES #518, CLOSES #522, CLOSES #523, CLOSES #525, CLOSES #528, CLOSES #529, CLOSES #533, CLOSES #535, CLOSES #536

This commit is contained in:
bryanthaboi
2026-07-31 20:20:37 -04:00
parent 1bc252741a
commit fdab15a6dc
37 changed files with 1729 additions and 81 deletions
@@ -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,
},
}
+1
View File
@@ -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",
}
+14 -3
View File
@@ -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
},
},
}
+12 -1
View File
@@ -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)
+7
View File
@@ -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
+6
View File
@@ -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
+3 -1
View File
@@ -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"
+7
View File
@@ -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
+8 -1
View File
@@ -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",
+9 -12
View File
@@ -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
+96 -9
View File
@@ -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: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
+63 -9
View File
@@ -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
+7 -3
View File
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+1 -1
View File
@@ -1 +1 @@
9820543f7a223fab
7de3051a7b3c108e
+1 -1
View File
@@ -1 +1 @@
54a52ea81751495c
227fd737aba15763
+130
View File
@@ -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()
+72
View File
@@ -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()
+102
View File
@@ -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()
+51 -12
View File
@@ -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")
+11 -1
View File
@@ -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()
+46
View File
@@ -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()
+93
View File
@@ -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()
@@ -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()
+97
View File
@@ -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()
+100
View File
@@ -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
+3 -2
View File
@@ -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
+32
View File
@@ -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"]:
+15 -15
View File
@@ -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
}
}
},
+5
View File
@@ -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()
+35 -2
View File
@@ -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
+8 -3
View File
@@ -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
+5 -2
View File
@@ -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,