CLOSES #1831, CLOSES #1834, CLOSES #1836, CLOSES #1860

This commit is contained in:
bryanthaboi
2026-08-27 06:09:50 -04:00
parent c9d06779d8
commit eed246f645
15 changed files with 485 additions and 62 deletions
+2 -1
View File
@@ -2753,7 +2753,8 @@ function RomExtractorGen2:extractAudio(maps)
local speciesOrder = self.manifest.constants.speciesOrder or {} local speciesOrder = self.manifest.constants.speciesOrder or {}
local cries = {} local cries = {}
for index, species in ipairs(speciesOrder) do for index, species in ipairs(speciesOrder) do
if species ~= "UNOWN" and not tostring(species):match("^UNUSED") then -- data/pokemon/cries.asm:209 gives UNOWN a real cry row
if not tostring(species):match("^UNUSED") then
local row = self.rom:bytes( local row = self.rom:bytes(
pokeCryPtr.bank, pokeCryPtr.address + (index - 1) * 6, 6) pokeCryPtr.bank, pokeCryPtr.address + (index - 1) * 6, 6)
local cryIndex = row[1] + row[2] * 256 local cryIndex = row[1] + row[2] * 256
+12
View File
@@ -112,6 +112,8 @@ function TextBox.new(game, text, onDone, opts)
self.stay = opts and opts.stay self.stay = opts and opts.stay
-- engine/events/hidden_events/cinnabar_gym_quiz.asm:119 -- engine/events/hidden_events/cinnabar_gym_quiz.asm:119
self.preSound = opts and opts.preSound self.preSound = opts and opts.preSound
-- pokegold engine/overworld/scripting.asm:485 WaitSFX
self.sfxWait = opts and opts.sfxWait
-- opts.instant: put the LAST page up already typed, with no typewriter and -- opts.instant: put the LAST page up already typed, with no typewriter and
-- no page waits. A `yesorno` follows a `writetext` that has already been -- no page waits. A `yesorno` follows a `writetext` that has already been
-- read, so re-typing the line under the YES/NO box would be wrong -- the -- read, so re-typing the line under the YES/NO box would be wrong -- the
@@ -328,6 +330,14 @@ function TextBox:visibleText()
return #out > 0 and out or nil return #out > 0 and out or nil
end end
-- pokegold engine/overworld/scripting.asm:484-485 PlaySFX / WaitSFX
function TextBox:sfxHeld()
if not self.sfxWait then return false end
if require("src.core.Sound").sfxBusy() then return true end
self.sfxWait = nil
return false
end
function TextBox:update(dt) function TextBox:update(dt)
local input = self.game.input local input = self.game.input
self.blink = (self.blink + 1) % 60 self.blink = (self.blink + 1) % 60
@@ -444,6 +454,7 @@ function TextBox:update(dt)
end end
return return
end end
if self:sfxHeld() then return end
if input:wasPressed("a") or input:wasPressed("b") then if input:wasPressed("a") or input:wasPressed("b") then
require("src.core.Sound").play(self.game.data, "Press_AB") require("src.core.Sound").play(self.game.data, "Press_AB")
self.game.stack:pop() self.game.stack:pop()
@@ -459,6 +470,7 @@ function TextBox:update(dt)
self.preWait = self.preWait - 1 self.preWait = self.preWait - 1
return return
end end
if self:sfxHeld() then return end
if input:wasPressed("a") or input:wasPressed("b") then if input:wasPressed("a") or input:wasPressed("b") then
require("src.core.Sound").play(self.game.data, "Press_AB") require("src.core.Sound").play(self.game.data, "Press_AB")
self.waiting = false self.waiting = false
+3 -6
View File
@@ -150,13 +150,10 @@ end
-- true while the spinner arrow tiles should show the 'blur' graphic; false -- true while the spinner arrow tiles should show the 'blur' graphic; false
-- means draw nothing extra (the static window tile shows through, -- means draw nothing extra (the static window tile shows through,
-- matching the asm's restore-to-original behavior). The 8-tick -- matching the asm's restore-to-original behavior).
-- half-period approximates one GB movement step (2px/frame); this is a -- spinners.asm:17-22, home/overworld.asm:1844-1846, :49-52
-- deliberate approximation of wSimulatedJoypadStatesIndex bit-0 parity, not
-- a cycle-accurate replication -- the port's tweened scriptMove has no
-- direct equivalent discrete step counter.
function TileRenderer.spinBlurActive() function TileRenderer.spinBlurActive()
return spinning and (math.floor(animFrame / 8) % 2 == 0) return spinning and (math.floor(animFrame / 16) % 2 == 0)
end end
-- ------------------------------------------------------------------ -- ------------------------------------------------------------------
+12 -6
View File
@@ -152,6 +152,12 @@ local function answer(vm, value)
vm.scriptVar = value or 0 vm.scriptVar = value or 0
end end
-- WaitSFX (pokegold home/audio.asm); a test stub that calls a handler off
-- the coroutine has no sfx to drain.
local function drainSfx()
if coroutine.running() then coroutine.yield({ kind = "waitsfx" }) end
end
-- Every routine that ends `call GetPokemonName / jp -- Every routine that ends `call GetPokemonName / jp
-- CopyPokemonName_Buffer1_Buffer3` puts a name where the next writetext's -- CopyPokemonName_Buffer1_Buffer3` puts a name where the next writetext's
-- {STRBUF} will find it. -- {STRBUF} will find it.
@@ -452,8 +458,10 @@ H.BugContestJudging = function(vm)
local what = (h.monName and h.monName(entry.species)) or entry.species local what = (h.monName and h.monName(entry.species)) or entry.species
or "" or ""
vm:showRaw(Strings(row.page, who, what)) vm:showRaw(Strings(row.page, who, what))
-- pokegold engine/events/bug_contest/judging.asm:29-32
drainSfx()
if h.playSfxNamed then h.playSfxNamed(row.sfx) end if h.playSfxNamed then h.playSfxNamed(row.sfx) end
vm:showRaw(Strings(row.score, entry.score or 0)) vm:showRaw(Strings(row.score, entry.score or 0), nil, nil, true)
end end
end end
answer(vm, place) answer(vm, place)
@@ -2033,12 +2041,10 @@ H.ProfOaksPCBoot = function(vm)
vm:showRaw(Strings(OAK_PC_TEXT.counts, seen, caught)) vm:showRaw(Strings(OAK_PC_TEXT.counts, seen, caught))
local rating = findOakRating(caught) local rating = findOakRating(caught)
local h = hooks(vm) local h = hooks(vm)
-- pokegold engine/events/prof_oaks_pc.asm:18-20 PlaySFX / JoyWaitAorB / WaitSFX
drainSfx()
if h.playSfxNamed then h.playSfxNamed(rating.sfx) end if h.playSfxNamed then h.playSfxNamed(rating.sfx) end
vm:showRaw(Strings(rating.text)) vm:showRaw(Strings(rating.text), nil, nil, true)
-- `call PlaySFX / call JoyWaitAorB / call WaitSFX`: the fanfare is left
-- playing under the rating text, and the caller (OaksLab's own script)
-- waits it out before closing the box.
coroutine.yield({ kind = "waitsfx" })
end end
-- The whose-PC menu's PROF.OAK's PC row (src/ui/gen2/CenterPcMenu.lua) runs -- The whose-PC menu's PROF.OAK's PC row (src/ui/gen2/CenterPcMenu.lua) runs
+10 -6
View File
@@ -700,8 +700,10 @@ local function runCmd(self, cmd, op)
-- GetPocketName fills from ItemPocketNames: KEY ITEMs, BALLs and TMs -- GetPocketName fills from ItemPocketNames: KEY ITEMs, BALLs and TMs
-- name their own pocket, not the ITEM one (data/text/common_2.asm -- name their own pocket, not the ITEM one (data/text/common_2.asm
-- :1351, data/items/pocket_names.asm:10-13). -- :1351, data/items/pocket_names.asm:10-13).
-- Script_specialsound's WaitSFX (scripting.asm:485): the box holds
-- its press until the jingle ends.
self:showRaw(Strings("{PLAYER} put the\n%s in\nthe %s.", self:showRaw(Strings("{PLAYER} put the\n%s in\nthe %s.",
name, self:pocketName(item))) name, self:pocketName(item)), nil, nil, true)
else else
self:showRaw(Strings("The %s\nis full…", self:pocketName(item))) self:showRaw(Strings("The %s\nis full…", self:pocketName(item)))
end end
@@ -1510,8 +1512,8 @@ local function runCmd(self, cmd, op)
self.playSoundFn(SFX_ITEM) self.playSoundFn(SFX_ITEM)
end end
-- FruitTreeScript's tail is `specialsound / itemnotify` with NOTHING -- FruitTreeScript's tail is `specialsound / itemnotify` with NOTHING
-- between them (engine/events/fruit_trees.asm:23-24), and Script_specialsound -- between them (engine/events/fruit_trees.asm:23-24);
-- is a bare PlaySFX -- it does not wait either (scripting.asm:476-483). -- Script_specialsound ends PlaySFX / WaitSFX (scripting.asm:484-485)
-- The port used to park here on a `waitsfx`, which is the same seam -- The port used to park here on a `waitsfx`, which is the same seam
-- GiveItemScript's did: this port's box takes its own button and pops on -- GiveItemScript's did: this port's box takes its own button and pops on
-- it, so the park ran with an EMPTY state stack and the bare overworld -- it, so the park ran with an EMPTY state stack and the bare overworld
@@ -1523,7 +1525,7 @@ local function runCmd(self, cmd, op)
-- noun still comes from ItemPocketNames rather than from a third copy of -- noun still comes from ItemPocketNames rather than from a third copy of
-- the literal (data/items/pocket_names.asm:10-13). -- the literal (data/items/pocket_names.asm:10-13).
self:showRaw(Strings("{PLAYER} put the\n%s in\nthe %s.", self:showRaw(Strings("{PLAYER} put the\n%s in\nthe %s.",
name, self:pocketName(item))) name, self:pocketName(item)), nil, nil, true)
return "end" return "end"
elseif op == "describedecoration" then elseif op == "describedecoration" then
-- `describedecoration byte` picks one of five DECODESC_* arms -- `describedecoration byte` picks one of five DECODESC_* arms
@@ -2427,7 +2429,7 @@ end
-- the port's world does not tick while a box is on the stack (Game2:update -- the port's world does not tick while a box is on the stack (Game2:update
-- stops at the top state), so the only clock that can count it is the box's; -- stops at the top state), so the only clock that can count it is the box's;
-- World:showText is where it lands. -- World:showText is where it lands.
function Vm:showRaw(body, stay, hold) function Vm:showRaw(body, stay, hold, sfxWait)
if not body or body == "" then body = "..." end if not body or body == "" then body = "..." end
if self.stringBuffer and self.stringBuffer ~= "" then if self.stringBuffer and self.stringBuffer ~= "" then
body = body:gsub("{STRBUF}", self.stringBuffer) body = body:gsub("{STRBUF}", self.stringBuffer)
@@ -2438,6 +2440,8 @@ function Vm:showRaw(body, stay, hold)
text = body, text = body,
stay = (stay or self:textStays()) and true or false, stay = (stay or self:textStays()) and true or false,
hold = hold, hold = hold,
-- pokegold engine/overworld/scripting.asm:485 WaitSFX
sfxWait = sfxWait and true or nil,
}) })
end end
end end
@@ -2646,7 +2650,7 @@ function Vm:resume(resumeValue)
if req and req.kind == "text" and self.showTextFn then if req and req.kind == "text" and self.showTextFn then
self.showTextFn(req.text, function() self.showTextFn(req.text, function()
self:resume() self:resume()
end, req.stay, req.hold) end, req.stay, req.hold, req.sfxWait)
elseif req and req.kind == "wait" then elseif req and req.kind == "wait" then
self.waitLeft = req.frames or 0 self.waitLeft = req.frames or 0
elseif req and req.kind == "waitbutton" then elseif req and req.kind == "waitbutton" then
+1 -1
View File
@@ -605,7 +605,7 @@ function BattleState:animData(mon)
local def = self.pokemon and mon and self.pokemon[mon.species] local def = self.pokemon and mon and self.pokemon[mon.species]
if not def then return nil end if not def then return nil end
if mon.species == Unown.SPECIES and def.letters then if mon.species == Unown.SPECIES and def.letters then
local entry = def.letters[Unown.monLetter(mon)] local entry = def.letters[Unown.name(Unown.monLetter(mon))]
if entry and entry.anim then return entry.anim end if entry and entry.anim then return entry.anim end
end end
return def.anim return def.anim
+1 -1
View File
@@ -336,7 +336,7 @@ function SummaryMenu:startPicAnim()
if not def then return end if not def then return end
local data = def.anim local data = def.anim
if mon.species == Unown.SPECIES and def.letters then if mon.species == Unown.SPECIES and def.letters then
local entry = def.letters[Unown.monLetter(mon)] local entry = def.letters[Unown.name(Unown.monLetter(mon))]
if entry and entry.anim then data = entry.anim end if entry and entry.anim then data = entry.anim end
end end
if not data then return end if not data then return end
+5 -9
View File
@@ -182,6 +182,9 @@ function Player:update()
if self.turnTimer > 0 then if self.turnTimer > 0 then
self.turnTimer = self.turnTimer - 1 self.turnTimer = self.turnTimer - 1
end end
if self.spinning then
self.spinTimer = (self.spinTimer or 0) + 1
end
if self.spinFrames then if self.spinFrames then
self.spinFrames = self.spinFrames - 1 self.spinFrames = self.spinFrames - 1
if self.spinFrames <= 0 then if self.spinFrames <= 0 then
@@ -261,11 +264,6 @@ local SPIN_ORDER = { "down", "left", "up", "right" }
-- --
-- The last return says the player is mid-ledge-hop, which is what the 2D -- The last return says the player is mid-ledge-hop, which is what the 2D
-- path draws the ground shadow from and a 3D path turns into vertical lift. -- path draws the ground shadow from and a 3D path turns into vertical lift.
--
-- This ADVANCES the surf-bob and spinner timers, so exactly one of pose()
-- and draw() may run per frame -- and draw() is written in terms of pose()
-- to keep that true by construction. (hopFrames counts down in
-- Player:update, on the fixed step, so it is safe to read here.)
function Player:pose() function Player:pose()
local py = self.py local py = self.py
local hopping = false local hopping = false
@@ -288,10 +286,8 @@ function Player:pose()
-- the leg cadence -- the leg cadence
local flip = math.floor((self.animClock or 0) / 16) % 2 == 1 local flip = math.floor((self.animClock or 0) / 16) % 2 == 1
if self.spinning then if self.spinning then
-- spinner tiles whirl the sprite on its standing pose, one facing -- spinners.asm:1-11, home/overworld.asm:41-44, :268-272
-- per frame (LoadSpinnerArrowTiles runs every OverworldLoop frame) facing = SPIN_ORDER[math.floor((self.spinTimer or 0) / 2) % 4 + 1]
self.spinTimer = (self.spinTimer or 0) + 1
facing = SPIN_ORDER[self.spinTimer % 4 + 1]
phase, flip = 0, false phase, flip = 0, false
-- teleport arrivals spin the sprite down into place -- teleport arrivals spin the sprite down into place
-- (EnterMapAnim PlayerSpinWhileMovingDown) -- (EnterMapAnim PlayerSpinWhileMovingDown)
+31 -24
View File
@@ -187,6 +187,11 @@ function FieldMoves.bindEngineFlags(order)
-- row, so every id from BUG_CONTEST_TIMER up shifts one. -- row, so every id from BUG_CONTEST_TIMER up shifts one.
FieldMoves.BUG_CONTEST_FLAG = byName["ENGINE_BUG_CONTEST_TIMER"] or 16 FieldMoves.BUG_CONTEST_FLAG = byName["ENGINE_BUG_CONTEST_TIMER"] or 16
FieldMoves.BIKE_SHOP_CALL_FLAG = byName["ENGINE_BIKE_SHOP_CALL_ENABLED"] or 19 FieldMoves.BIKE_SHOP_CALL_FLAG = byName["ENGINE_BIKE_SHOP_CALL_ENABLED"] or 19
-- pokecrystal constants/engine_flags.asm:66-92 vs pokegold :65-91
for _, row in ipairs(FieldMoves.FLYPOINTS or {}) do
row.goldFlag = row.goldFlag or row.flag
row.flag = byName[row.name] or row.goldFlag
end
return flags return flags
end end
@@ -475,33 +480,35 @@ end
-- const_def count (0-based, ENGINE_RADIO_CARD is 0): the byte a town's own -- const_def count (0-based, ENGINE_RADIO_CARD is 0): the byte a town's own
-- MAPCALLBACK_NEWMAP callback sets with `setflag` the first time you walk in, -- MAPCALLBACK_NEWMAP callback sets with `setflag` the first time you walk in,
-- and what FieldMoves.hasVisitedSpawn below actually reads. -- and what FieldMoves.hasVisitedSpawn below actually reads.
-- Ids are pokegold's; bindEngineFlags rebinds by name (pokecrystal
-- constants/engine_flags.asm:25 ENGINE_MOBILE_SYSTEM shifts them +1).
FieldMoves.FLYPOINTS = { FieldMoves.FLYPOINTS = {
-- Johto -- Johto
{ landmark = "LANDMARK_NEW_BARK_TOWN", spawn = "SPAWN_NEW_BARK", flag = 64 }, { landmark = "LANDMARK_NEW_BARK_TOWN", spawn = "SPAWN_NEW_BARK", flag = 64, name = "ENGINE_FLYPOINT_NEW_BARK" },
{ landmark = "LANDMARK_CHERRYGROVE_CITY", spawn = "SPAWN_CHERRYGROVE", flag = 65 }, { landmark = "LANDMARK_CHERRYGROVE_CITY", spawn = "SPAWN_CHERRYGROVE", flag = 65, name = "ENGINE_FLYPOINT_CHERRYGROVE" },
{ landmark = "LANDMARK_VIOLET_CITY", spawn = "SPAWN_VIOLET", flag = 66 }, { landmark = "LANDMARK_VIOLET_CITY", spawn = "SPAWN_VIOLET", flag = 66, name = "ENGINE_FLYPOINT_VIOLET" },
{ landmark = "LANDMARK_AZALEA_TOWN", spawn = "SPAWN_AZALEA", flag = 67 }, { landmark = "LANDMARK_AZALEA_TOWN", spawn = "SPAWN_AZALEA", flag = 67, name = "ENGINE_FLYPOINT_AZALEA" },
{ landmark = "LANDMARK_GOLDENROD_CITY", spawn = "SPAWN_GOLDENROD", flag = 69 }, { landmark = "LANDMARK_GOLDENROD_CITY", spawn = "SPAWN_GOLDENROD", flag = 69, name = "ENGINE_FLYPOINT_GOLDENROD" },
{ landmark = "LANDMARK_ECRUTEAK_CITY", spawn = "SPAWN_ECRUTEAK", flag = 71 }, { landmark = "LANDMARK_ECRUTEAK_CITY", spawn = "SPAWN_ECRUTEAK", flag = 71, name = "ENGINE_FLYPOINT_ECRUTEAK" },
{ landmark = "LANDMARK_OLIVINE_CITY", spawn = "SPAWN_OLIVINE", flag = 70 }, { landmark = "LANDMARK_OLIVINE_CITY", spawn = "SPAWN_OLIVINE", flag = 70, name = "ENGINE_FLYPOINT_OLIVINE" },
{ landmark = "LANDMARK_CIANWOOD_CITY", spawn = "SPAWN_CIANWOOD", flag = 68 }, { landmark = "LANDMARK_CIANWOOD_CITY", spawn = "SPAWN_CIANWOOD", flag = 68, name = "ENGINE_FLYPOINT_CIANWOOD" },
{ landmark = "LANDMARK_MAHOGANY_TOWN", spawn = "SPAWN_MAHOGANY", flag = 72 }, { landmark = "LANDMARK_MAHOGANY_TOWN", spawn = "SPAWN_MAHOGANY", flag = 72, name = "ENGINE_FLYPOINT_MAHOGANY" },
{ landmark = "LANDMARK_LAKE_OF_RAGE", spawn = "SPAWN_LAKE_OF_RAGE", flag = 73 }, { landmark = "LANDMARK_LAKE_OF_RAGE", spawn = "SPAWN_LAKE_OF_RAGE", flag = 73, name = "ENGINE_FLYPOINT_LAKE_OF_RAGE" },
{ landmark = "LANDMARK_BLACKTHORN_CITY", spawn = "SPAWN_BLACKTHORN", flag = 74 }, { landmark = "LANDMARK_BLACKTHORN_CITY", spawn = "SPAWN_BLACKTHORN", flag = 74, name = "ENGINE_FLYPOINT_BLACKTHORN" },
{ landmark = "LANDMARK_SILVER_CAVE", spawn = "SPAWN_MT_SILVER", flag = 75 }, { landmark = "LANDMARK_SILVER_CAVE", spawn = "SPAWN_MT_SILVER", flag = 75, name = "ENGINE_FLYPOINT_SILVER_CAVE" },
-- Kanto -- Kanto
{ landmark = "LANDMARK_PALLET_TOWN", spawn = "SPAWN_PALLET", flag = 52 }, { landmark = "LANDMARK_PALLET_TOWN", spawn = "SPAWN_PALLET", flag = 52, name = "ENGINE_FLYPOINT_PALLET" },
{ landmark = "LANDMARK_VIRIDIAN_CITY", spawn = "SPAWN_VIRIDIAN", flag = 53 }, { landmark = "LANDMARK_VIRIDIAN_CITY", spawn = "SPAWN_VIRIDIAN", flag = 53, name = "ENGINE_FLYPOINT_VIRIDIAN" },
{ landmark = "LANDMARK_PEWTER_CITY", spawn = "SPAWN_PEWTER", flag = 54 }, { landmark = "LANDMARK_PEWTER_CITY", spawn = "SPAWN_PEWTER", flag = 54, name = "ENGINE_FLYPOINT_PEWTER" },
{ landmark = "LANDMARK_CERULEAN_CITY", spawn = "SPAWN_CERULEAN", flag = 55 }, { landmark = "LANDMARK_CERULEAN_CITY", spawn = "SPAWN_CERULEAN", flag = 55, name = "ENGINE_FLYPOINT_CERULEAN" },
{ landmark = "LANDMARK_VERMILION_CITY", spawn = "SPAWN_VERMILION", flag = 57 }, { landmark = "LANDMARK_VERMILION_CITY", spawn = "SPAWN_VERMILION", flag = 57, name = "ENGINE_FLYPOINT_VERMILION" },
{ landmark = "LANDMARK_ROCK_TUNNEL", spawn = "SPAWN_ROCK_TUNNEL", flag = 56 }, { landmark = "LANDMARK_ROCK_TUNNEL", spawn = "SPAWN_ROCK_TUNNEL", flag = 56, name = "ENGINE_FLYPOINT_ROCK_TUNNEL" },
{ landmark = "LANDMARK_LAVENDER_TOWN", spawn = "SPAWN_LAVENDER", flag = 58 }, { landmark = "LANDMARK_LAVENDER_TOWN", spawn = "SPAWN_LAVENDER", flag = 58, name = "ENGINE_FLYPOINT_LAVENDER" },
{ landmark = "LANDMARK_CELADON_CITY", spawn = "SPAWN_CELADON", flag = 60 }, { landmark = "LANDMARK_CELADON_CITY", spawn = "SPAWN_CELADON", flag = 60, name = "ENGINE_FLYPOINT_CELADON" },
{ landmark = "LANDMARK_SAFFRON_CITY", spawn = "SPAWN_SAFFRON", flag = 59 }, { landmark = "LANDMARK_SAFFRON_CITY", spawn = "SPAWN_SAFFRON", flag = 59, name = "ENGINE_FLYPOINT_SAFFRON" },
{ landmark = "LANDMARK_FUCHSIA_CITY", spawn = "SPAWN_FUCHSIA", flag = 61 }, { landmark = "LANDMARK_FUCHSIA_CITY", spawn = "SPAWN_FUCHSIA", flag = 61, name = "ENGINE_FLYPOINT_FUCHSIA" },
{ landmark = "LANDMARK_CINNABAR_ISLAND", spawn = "SPAWN_CINNABAR", flag = 62 }, { landmark = "LANDMARK_CINNABAR_ISLAND", spawn = "SPAWN_CINNABAR", flag = 62, name = "ENGINE_FLYPOINT_CINNABAR" },
{ landmark = "LANDMARK_INDIGO_PLATEAU", spawn = "SPAWN_INDIGO", flag = 63 }, { landmark = "LANDMARK_INDIGO_PLATEAU", spawn = "SPAWN_INDIGO", flag = 63, name = "ENGINE_FLYPOINT_INDIGO_PLATEAU" },
} }
-- spawn -> row, built once, so hasVisitedSpawn below does not walk the whole -- spawn -> row, built once, so hasVisitedSpawn below does not walk the whole
+19 -6
View File
@@ -929,8 +929,8 @@ function World:load()
-- `hold` is the same story one argument along: the cart `pause` a held box -- `hold` is the same story one argument along: the cart `pause` a held box
-- stands through (FindItemInBallScript's `pause 60`). Dropping it made the -- stands through (FindItemInBallScript's `pause 60`). Dropping it made the
-- box hand back the instant it finished typing. -- box hand back the instant it finished typing.
showText = function(body, onDone, stay, hold) showText = function(body, onDone, stay, hold, sfxWait)
self:showText(body, onDone, stay, hold) self:showText(body, onDone, stay, hold, sfxWait)
end, end,
facePlayer = function() facePlayer = function()
if self.talkNpc and self.player then if self.talkNpc and self.player then
@@ -4224,7 +4224,7 @@ function World:tryWildEncounter()
-- That is what keeps the Ruins chambers empty until a wall has been solved. -- That is what keeps the Ruins chambers empty until a wall has been solved.
local monOpts = nil local monOpts = nil
if roll.species == Unown.SPECIES then if roll.species == Unown.SPECIES then
local flags = self:engineFlags() local flags = self:unownUnlockFlags()
if not Unown.anyUnlocked(flags) then return false end if not Unown.anyUnlocked(flags) then return false end
-- LoadEnemyMon's .GenerateDVs loop rerolls until CheckUnownLetter clears -- LoadEnemyMon's .GenerateDVs loop rerolls until CheckUnownLetter clears
-- the form, so a chamber only ever produces letters its own puzzle -- the form, so a chamber only ever produces letters its own puzzle
@@ -4846,6 +4846,19 @@ function World:engineFlagId(name, goldId)
return ids[name] or goldId return ids[name] or goldId
end end
-- Unown.UNLOCK_SETS keys the flags by pokegold's ids; Crystal's are one
-- higher (../pokecrystal/constants/engine_flags.asm:57-60 vs ../pokegold:56-59).
function World:unownUnlockFlags()
local engine = self:engineFlags()
local out = {}
for _, set in ipairs(Unown.UNLOCK_SETS) do
if engine[self:engineFlagId(set.name, set.flag)] then
out[set.flag] = true
end
end
return out
end
-- wBikeFlags' three bits are ENGINE_* ids like any other flag, so the map -- wBikeFlags' three bits are ENGINE_* ids like any other flag, so the map
-- callbacks that set them (Route16AlwaysOnBikeCallback, -- callbacks that set them (Route16AlwaysOnBikeCallback,
-- Route17AlwaysOnBikeCallback) already land on save.engineFlags. -- Route17AlwaysOnBikeCallback) already land on save.engineFlags.
@@ -5338,7 +5351,7 @@ function World:sweetScentEncounter()
-- stays empty for SWEET SCENT too. -- stays empty for SWEET SCENT too.
local monOpts = nil local monOpts = nil
if roll.species == Unown.SPECIES then if roll.species == Unown.SPECIES then
local flags = self:engineFlags() local flags = self:unownUnlockFlags()
if not Unown.anyUnlocked(flags) then return false end if not Unown.anyUnlocked(flags) then return false end
monOpts = { dvs = Unown.wildDVs(flags, Mon.randomDVs) } monOpts = { dvs = Unown.wildDVs(flags, Mon.randomDVs) }
end end
@@ -7280,7 +7293,7 @@ end
-- stack the overworld and the VM under it do not tick at all -- so the wait has -- stack the overworld and the VM under it do not tick at all -- so the wait has
-- to be counted by the box itself. Frames, already doubled by Vm:pauseFrames -- to be counted by the box itself. Frames, already doubled by Vm:pauseFrames
-- the way Script_pause's `ld c, 2 / call DelayFrames` doubles the operand. -- the way Script_pause's `ld c, 2 / call DelayFrames` doubles the operand.
function World:showText(body, onDone, stay, hold) function World:showText(body, onDone, stay, hold, sfxWait)
local game = self.game local game = self.game
-- The box a PREVIOUS `stay` left standing (TextBox's contract is "whoever -- The box a PREVIOUS `stay` left standing (TextBox's contract is "whoever
-- pushed it owns the pop", src/render/TextBox.lua:40). `yesorno` consumes it -- pushed it owns the pop", src/render/TextBox.lua:40). `yesorno` consumes it
@@ -7330,7 +7343,7 @@ function World:showText(body, onDone, stay, hold)
game.stack:push(TextBox.new(game, body, function() game.stack:push(TextBox.new(game, body, function()
self.textbox = nil self.textbox = nil
if onDone then onDone() end if onDone then onDone() end
end)) end, sfxWait and { sfxWait = true } or nil))
end end
function World:pooledNpc(mapId, obj) function World:pooledNpc(mapId, obj)
+131
View File
@@ -0,0 +1,131 @@
-- #1860: jingles cut or dropped by the text blip. GiveItemScript
-- (pokegold engine/overworld/scripting.asm:441-449), Oak's rating
-- (engine/events/prof_oaks_pc.asm:14-21), bug contest judging
-- (engine/events/bug_contest/judging.asm:29-32).
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_jingle_bug1860_test.lua love .
local U = require("tests.drivers.util")
local Sound = require("src.core.Sound")
return function(game)
U.wait(45)
local world = game.world
assert(world and world.map and world.vm, "gold world did not boot")
local opts = game.save.options or {}
if (opts.sfxVol or 7) == 0 then
U.log("WARNING: options.sfxVol is 0 -- every jingle below will be")
U.log("WARNING: silent; raise SFX volume before judging this by ear")
end
local results = {}
local function check(label, ok)
results[#results + 1] = (ok and "PASS " or "FAIL ") .. label
end
local function specialId(name)
for index, entry in ipairs(world.vm.specialOrder or {}) do
if entry == name then return index - 1 end
end
return nil
end
-- World:specialSound resolves the numeric index (itemIdByIndex), and only
-- a TM_HM-pocket item rings Sfx_GetTm.
local tm
for _, def in pairs(game.data.items or {}) do
if type(def) == "table" and def.pocket == "TM_HM" and def.index then
tm = def.index
break
end
end
check("cache names a TM_HM-pocket item", tm ~= nil)
-- Runs one scripted moment while mashing A every 4th frame the whole way.
-- everBusy: a gated sfx started at all (the drop half of the bug).
-- maxRun: longest unbroken stretch it kept sounding (the cut half).
-- popEarly: a box with the sfx hold popped while the jingle still rang.
local function runMoment(label, script, minRun)
U.wait(30)
world.vm:start(script)
U.wait(2)
local everBusy, maxRun, run = false, 0, 0
local sawHeld, popEarly = false, false
local pressIn = 4
for _ = 1, 1500 do
local busy = Sound.sfxBusy()
if busy then
everBusy = true
run = run + 1
if run > maxRun then maxRun = run end
else
run = 0
end
local top = game.stack:top()
if top and top.sfxWait and busy then sawHeld = true end
pressIn = pressIn - 1
if pressIn <= 0 then
pressIn = 4
game.input.pressQueue[#game.input.pressQueue + 1] = "a"
game.input.state.a = true
U.wait(1)
game.input.state.a = false
if top and top.sfxWait and busy and game.stack:top() ~= top then
popEarly = true
end
else
U.wait(1)
end
if not world:busy() and not game.stack:top() then break end
end
check(label .. ": a jingle started (not dropped by the blip)", everBusy)
check(("%s: it survived mashed A for %d frames (want >= %d)")
:format(label, maxRun, minRun), maxRun >= minRun)
check(label .. ": the held box refused A while it rang",
sawHeld and not popEarly)
end
if tm then
runMoment("verbosegiveitem TM", {
{ op = "opentext" },
{ op = "verbosegiveitem", args = { tm, 1 } },
{ op = "closetext" },
{ op = "end" },
}, 60)
end
local oak = specialId("ProfOaksPCBoot")
check("specialOrder names ProfOaksPCBoot", oak ~= nil)
if oak then
game.save.pokedex = game.save.pokedex or { seen = {}, caught = {} }
game.save.pokedex.caught = {}
for i = 1, 150 do game.save.pokedex.caught["DEX_SEED_" .. i] = true end
runMoment("Oak's dex rating fanfare", {
{ op = "opentext" },
{ op = "special", id = oak },
{ op = "closetext" },
{ op = "end" },
}, 40)
end
local judging = specialId("BugContestJudging")
check("specialOrder names BugContestJudging", judging ~= nil)
if judging then
runMoment("bug contest place fanfares", {
{ op = "opentext" },
{ op = "special", id = judging },
{ op = "closetext" },
{ op = "end" },
}, 30)
end
for _, line in ipairs(results) do U.log(line) end
U.log("Right sounds like: the TM jingle, the dex-rating fanfare and each")
U.log("place fanfare play out in full even while A is mashed; the box under")
U.log("each one only closes once its jingle has finished ringing.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,99 @@
-- #1831: spinner tiles whirled the sprite per DISPLAY frame and flickered
-- the arrows at twice the cart's rate (engine/overworld/spinners.asm:1-22,
-- home/overworld.asm:41-44,268-272).
-- POKEPORT_DRIVER=tests/drivers/spinner_rate_bug1831_test.lua \
-- POKEPORT_IDENTITY=bug1831 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local TileRenderer = require("src.render.TileRenderer")
local results = {}
local function check(label, ok)
results[#results + 1] = (ok and "PASS " or "FAIL ") .. label
end
-- data/generated/field.lua ROCKET_HIDEOUT_B2F: the arrow at (12, 9)
-- slides 10 cells left, the longest straight ride on the floor.
U.teleport(game, "ROCKET_HIDEOUT_B2F", 13, 9, "left")
U.wait(10)
local ow = game.overworld
check("overworld is up on ROCKET_HIDEOUT_B2F", ow ~= nil and ow.player ~= nil)
-- one step left onto the arrow starts the forced slide
U.hold(game, "left", 20)
local spinning = false
for _ = 1, 30 do
if ow.player.spinning then spinning = true break end
U.wait(1)
end
check("stepping on the arrow starts the spin", spinning)
-- sample per fixed step while the slide runs
local facings, timers, blurs = {}, {}, {}
for _ = 1, 200 do
if not ow.player.spinning then break end
local _, _, _, facing = ow.player:pose()
facings[#facings + 1] = facing
timers[#timers + 1] = ow.player.spinTimer or 0
blurs[#blurs + 1] = TileRenderer.spinBlurActive()
U.wait(1)
end
check(("the slide gave %d samples (want >= 32)"):format(#facings),
#facings >= 32)
-- spinTimer ticks once per fixed step, not per rendered frame
local ticks = true
for i = 2, #timers do
if timers[i] - timers[i - 1] ~= 1 then ticks = false end
end
check("spinTimer advances exactly once per fixed step", ticks)
-- each facing holds for 2 fixed steps: one quarter-turn per OverworldLoop
-- iteration, two frames each (home/overworld.asm:41-44)
local runs, run = {}, 1
for i = 2, #facings do
if facings[i] == facings[i - 1] then
run = run + 1
else
runs[#runs + 1] = run
run = 1
end
end
local twos, others = 0, 0
for i = 2, #runs do -- the first run starts mid-phase, skip it
if runs[i] == 2 then twos = twos + 1 else others = others + 1 end
end
check(("facing holds 2 steps (%d runs of 2, %d other)"):format(twos, others),
twos >= 8 and others == 0)
-- arrow blur half-period is 16 frames: one whole 16-frame walked tile
-- per wSimulatedJoypadStatesIndex parity (spinners.asm:18-22). The clock
-- advances on the draw path, so allow one frame of sampling skew; the old
-- bug read 8 here.
local span, spans = 1, {}
for i = 2, #blurs do
if blurs[i] == blurs[i - 1] then
span = span + 1
else
spans[#spans + 1] = span
span = 1
end
end
local good, bad = 0, 0
for i = 2, #spans do -- first span starts mid-phase, skip it
if spans[i] >= 15 and spans[i] <= 17 then good = good + 1
else bad = bad + 1 end
end
check(("blur toggles every ~16 frames (%d good, %d off)"):format(good, bad),
good >= 1 and bad == 0)
for _, line in ipairs(results) do U.log(line) end
U.log("Right looks like: while the player is swept along the arrow the")
U.log("sprite makes roughly one full turn per 8 frames, a lazy whirl, and")
U.log("the arrow tiles swap between blur and static about twice a second.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,67 @@
-- pokecrystal constants/engine_flags.asm:66-92 vs pokegold :65-91
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local FieldMoves = require("src.world.gen2.FieldMoves")
-- pokegold constants/engine_flags.asm:65-91, in const_def order from
-- ENGINE_RADIO_CARD = 0: PALLET 52 .. INDIGO_PLATEAU 63, NEW_BARK 64 ..
-- SILVER_CAVE 75.
local GOLD_IDS = {
SPAWN_PALLET = 52, SPAWN_VIRIDIAN = 53, SPAWN_PEWTER = 54,
SPAWN_CERULEAN = 55, SPAWN_ROCK_TUNNEL = 56, SPAWN_VERMILION = 57,
SPAWN_LAVENDER = 58, SPAWN_SAFFRON = 59, SPAWN_CELADON = 60,
SPAWN_FUCHSIA = 61, SPAWN_CINNABAR = 62, SPAWN_INDIGO = 63,
SPAWN_NEW_BARK = 64, SPAWN_CHERRYGROVE = 65, SPAWN_VIOLET = 66,
SPAWN_AZALEA = 67, SPAWN_CIANWOOD = 68, SPAWN_GOLDENROD = 69,
SPAWN_OLIVINE = 70, SPAWN_ECRUTEAK = 71, SPAWN_MAHOGANY = 72,
SPAWN_LAKE_OF_RAGE = 73, SPAWN_BLACKTHORN = 74, SPAWN_MT_SILVER = 75,
}
local function byFlag()
local out = {}
for _, row in ipairs(FieldMoves.FLYPOINTS) do out[row.spawn] = row.flag end
return out
end
-- A Gold cache carries no engineFlagOrder; the table keeps its literal ids.
FieldMoves.bindEngineFlags(nil)
local gold = byFlag()
for spawn, id in pairs(GOLD_IDS) do
T.eq(gold[spawn], id, spawn .. " keeps the pokegold id with no order")
end
-- pokecrystal constants/engine_flags.asm:25 ENGINE_MOBILE_SYSTEM at index 16
-- shifts every later id up exactly one.
local crystalOrder = {}
for _, row in ipairs(FieldMoves.FLYPOINTS) do
-- order[i] names id i-1, so the Crystal id (gold + 1) sits at gold + 2
crystalOrder[GOLD_IDS[row.spawn] + 2] = row.name
end
FieldMoves.bindEngineFlags(crystalOrder)
local crystal = byFlag()
for spawn, id in pairs(GOLD_IDS) do
T.eq(crystal[spawn], id + 1, spawn .. " rebinds one higher under Crystal")
end
-- The Kanto gate reads SPAWN_INDIGO through the rebound id: Crystal's 64,
-- which the old table misread as Gold's SPAWN_NEW_BARK.
local save = { engineFlags = { [64] = true } }
T.check(FieldMoves.hasVisitedSpawn(save, "SPAWN_INDIGO"),
"Crystal flag 64 is the INDIGO_PLATEAU flypoint")
local kanto = FieldMoves.flyPoints(save, nil, "kanto")
T.eq(#kanto, 1, "the Kanto half opens on it")
T.eq(kanto[1].spawn, "SPAWN_INDIGO", "with the plateau row itself")
-- And a rebind back to a Gold cache restores the literal ids.
FieldMoves.bindEngineFlags(nil)
local again = byFlag()
for spawn, id in pairs(GOLD_IDS) do
T.eq(again[spawn], id, spawn .. " returns to the pokegold id")
end
local goldSave = { engineFlags = { [63] = true } }
T.check(FieldMoves.hasVisitedSpawn(goldSave, "SPAWN_INDIGO"),
"Gold flag 63 is INDIGO_PLATEAU again")
T.finish("gen2 flypoint rebind bug 1836")
+87
View File
@@ -526,6 +526,93 @@ for _, id in ipairs(Screens.GEN2_IDS) do
end end
check("Gen2UnownPuzzle is a screen id", registered, true) check("Gen2UnownPuzzle is a screen id", registered, true)
-- ================================================= Crystal's flag skew (#1834)
--
-- pokecrystal constants/engine_flags.asm:25 ENGINE_MOBILE_SYSTEM shifts the
-- unlock flags to 43-46 (:57-60); pokegold keeps 42-45 (:56-59).
do
local World = require("src.world.gen2.World")
local crystalOrder = {}
for _, set in ipairs(Unown.UNLOCK_SETS) do
crystalOrder[set.flag + 2] = set.name
end
local function stubWorld(engineFlags, order)
return setmetatable({
constants = { engineFlagOrder = order },
game = { save = { engineFlags = engineFlags } },
}, { __index = World })
end
-- what a Crystal save's raw flags used to feed Unown directly: L-Z only
local rawCrystal = { [43] = true, [44] = true, [45] = true, [46] = true }
local leaked = Unown.unlockedLetters(rawCrystal)
check("raw Crystal flags leak only 15 letters", #leaked, 15)
check("and the first survivor is L", leaked[1], 12)
local view = stubWorld(rawCrystal, crystalOrder):unownUnlockFlags()
local resolved = Unown.unlockedLetters(view)
check("the resolved view unlocks all 26", #resolved, 26)
check("A included", resolved[1], 1)
-- the same distribution through wildDVs itself
math.randomseed(1)
local function roll()
return { attack = math.random(0, 15), defense = math.random(0, 15),
speed = math.random(0, 15), special = math.random(0, 15) }
end
local seenOld, seenNew = {}, {}
for _ = 1, 4000 do
seenOld[Unown.letterFromDVs(Unown.wildDVs(rawCrystal, roll))] = true
seenNew[Unown.letterFromDVs(Unown.wildDVs(view, roll))] = true
end
local countOld, countNew = 0, 0
for _ in pairs(seenOld) do countOld = countOld + 1 end
for _ in pairs(seenNew) do countNew = countNew + 1 end
check("raw flags roll only 15 letters", countOld, 15)
check("raw flags never roll A", seenOld[1], nil)
check("the resolved view rolls all 26", countNew, 26)
-- one chamber: Crystal's setflag 43 is Kabuto's A-K, not L-R
local kabuto = Unown.unlockedLetters(
stubWorld({ [43] = true }, crystalOrder):unownUnlockFlags())
check("Crystal flag 43 is A-K", #kabuto, 11)
check("starting at A", kabuto[1], 1)
-- Crystal's 42 is ENGINE_EARTHBADGE and must not unlock a chamber
check("Crystal flag 42 unlocks nothing", Unown.anyUnlocked(
stubWorld({ [42] = true }, crystalOrder):unownUnlockFlags()), false)
-- a Gold cache has no engineFlagOrder and keeps its own ids
local gold = Unown.unlockedLetters(
stubWorld({ [42] = true }, nil):unownUnlockFlags())
check("Gold flag 42 stays A-K", #gold, 11)
end
-- ============================================= the letters table's keys (#1834)
--
-- RomExtractorGen2 keys `letters` by "A".."Z"; monLetter answers a number, so
-- the anim lookup must convert or every letter animates as A.
do
local BattleState = require("src.ui.gen2.BattleState")
local SummaryMenu = require("src.ui.gen2.SummaryMenu")
local animA, animI = { sheet = "sheet-a" }, { sheet = "sheet-i" }
data.pokemon.UNOWN.anim = animA
data.pokemon.UNOWN.letters.I.anim = animI
local screen = { pokemon = data.pokemon }
check("animData picks the mon's own letter",
BattleState.animData(screen, unown), animI)
local plain = Mon.new(data, "UNOWN", 5, { dvs = Unown.dvsForLetter(2) })
check("a letter with no anim row falls back to the species'",
BattleState.animData(screen, plain), animA)
-- StatsScreen_PlaceFrontpic reads the same letter row
-- (../pokecrystal/engine/pokemon/stats_screen.asm:889-901)
local asked
local summary = { mon = unown, pokemon = data.pokemon,
picImage = function(_, sheet) asked = sheet return nil end }
SummaryMenu.startPicAnim(summary)
check("the summary menu reads the same letter row", asked, "sheet-i")
end
print(("gen2 unown: %d checks, %d failures"):format(checks, failures)) print(("gen2 unown: %d checks, %d failures"):format(checks, failures))
-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an exit -- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an exit
-- here would take the whole tier down and silently skip every suite after it. -- here would take the whole tier down and silently skip every suite after it.
+5 -2
View File
@@ -44,11 +44,14 @@ end
TileRenderer.setSpinning(false) TileRenderer.setSpinning(false)
check(not TileRenderer.spinBlurActive(), "no arrow blur frame outside a spin") check(not TileRenderer.spinBlurActive(), "no arrow blur frame outside a spin")
-- one toggle per 16-frame walked tile: wSimulatedJoypadStatesIndex drops
-- once per JoypadOverworld call (home/overworld.asm:1844-1846) and
-- spinners.asm:18-22 reads its bit 0 (#1831)
TileRenderer.setSpinning(true) TileRenderer.setSpinning(true)
local a = TileRenderer.spinBlurActive() local a = TileRenderer.spinBlurActive()
for i = 1, 8 do TileRenderer.tick() end for i = 1, 16 do TileRenderer.tick() end
local b = TileRenderer.spinBlurActive() local b = TileRenderer.spinBlurActive()
check(a ~= b, "arrow blur frame toggles every ~8 ticks while spinning") check(a ~= b, "arrow blur frame toggles every ~16 ticks while spinning")
TileRenderer.setSpinning(false) TileRenderer.setSpinning(false)
check(not TileRenderer.spinBlurActive(), "blur frame turns off once the spin ends") check(not TileRenderer.spinBlurActive(), "blur frame turns off once the spin ends")