CLOSES #788, CLOSES #795, CLOSES #796, CLOSES #797, CLOSES #805, CLOSES #826, CLOSES #833, CLOSES #835, CLOSES #837, CLOSES #844, CLOSES #845, CLOSES #846, CLOSES #847

This commit is contained in:
bryanthaboi
2026-08-05 11:09:05 -04:00
parent d18824cfae
commit f6392e8932
32 changed files with 2776 additions and 114 deletions
+28 -18
View File
@@ -1031,23 +1031,31 @@ local championsRoomRivalScript = {
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 10
-- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo
-- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in
{ "play_music", "Music_Cities1", { keep = true } }, -- 11
{ "show_text", "_ChampionsRoomOakText" }, -- 12
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 13
{ "move_npc", 2, "up", 5 }, -- 14 OakEntranceAfterVictoryMovement
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in.
-- audio/alternate_tempo.asm Music_Cities1AlternateTempo is not a plain
-- PlayMusic: it fades the current song out (wAudioFadeOutControl = 10),
-- waits 100 frames for the fade, then restarts Cities1 with channel 1
-- pointed at Music_Cities1_Ch1_AlternateTempo -- `tempo 232` where the
-- normal Music_Cities1_Ch1 opens `tempo 144`, i.e. the slower, heavier
-- reading of the town theme this scene is known for (#847).
{ "fade_music", 10 }, -- 11
{ "wait", 100 }, -- 12
{ "play_music", "Music_Cities1", { keep = true, tempo = 232 } }, -- 13
{ "show_text", "_ChampionsRoomOakText" }, -- 14
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 15
{ "move_npc", 2, "up", 5 }, -- 16 OakEntranceAfterVictoryMovement
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
{ "face_object", 1, "left" }, -- 15
{ "face_object", 2, "down" }, -- 16
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 17
{ "face_object", 1, "left" }, -- 17
{ "face_object", 2, "down" }, -- 18
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 19
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
{ "face_object", 2, "right" }, -- 18
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 19
{ "face_object", 2, "right" }, -- 20
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 21
-- OakComeWithMeScript: Oak faces down again, then exits up
{ "face_object", 2, "down" }, -- 20
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 21
{ "move_npc", 2, "up", 2 }, -- 22 OakExitChampionsRoomMovement
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 23
{ "face_object", 2, "down" }, -- 22
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23
{ "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement
-- (PAD_UP 4, PAD_LEFT 1): the player walks out after Oak instead of the
-- screen just fading on the spot (#704). The entrance walk leaves the
@@ -1057,12 +1065,14 @@ local championsRoomRivalScript = {
-- trailing UP/LEFT are dropped. Scripted steps ignore collision here just
-- as they do in the original (CollisionCheckOnLand skips its checks while
-- wSimulatedJoypadStatesIndex is non-zero), so stepping through the
-- rival's cell at (4,2) is the ported behavior, not a clip.
{ "move_player", "up", 3 }, -- 24
-- rival's cell at (4,2) is the ported behavior, not a clip. Re-reported
-- as a clip in #847 and re-checked against home/overworld.asm
-- CollisionCheckOnLand, which is still the authority: do not "fix" it.
{ "move_player", "up", 3 }, -- 26
-- hand the induction off to the HALL_OF_FAME room (consumed by its
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
{ "set_field", "pendingHallOfFame", true }, -- 25
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 26
{ "set_field", "pendingHallOfFame", true }, -- 27
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 28
}
M.CHAMPIONS_ROOM = {
+37 -9
View File
@@ -18,6 +18,12 @@
-- script). Leaders are not def_trainers entries, so engageTrainer has
-- no header.won -- checkVictoryRewards shows this chain instead of a
-- synthetic "received badge/TM" stub.
--
-- `itemDialogue` is the tail of that chain the original prints only after
-- GiveItem succeeds; `bagFull` is the gym's *TM*NoRoomText it prints
-- instead when the bag is at capacity (`jr nc, .BagFull` in
-- PewterGymScriptReceiveTM34, scripts/PewterGym.asm, and its siblings).
-- The badge lands either way -- .gymVictory runs from both paths (#797).
local function range(prefix, first, last)
local t = {}
@@ -42,71 +48,93 @@ return {
"_PewterGymBrockReceivedBoulderBadgeText",
"_PewterGymBrockBoulderBadgeInfoText",
"_PewterGymBrockWaitTakeThisText",
},
itemDialogue = {
"_PewterGymReceivedTM34Text",
"_TM34ExplanationText",
} },
},
bagFull = "_PewterGymTM34NoRoomText" },
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
item = "TM_BUBBLEBEAM",
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
dialogue = {
"_CeruleanGymMistyReceivedCascadeBadgeText",
"_CeruleanGymMistyCascadeBadgeInfoText",
"_CeruleanGymMistyReceivedTM11Text",
} },
},
itemDialogue = { "_CeruleanGymMistyReceivedTM11Text" },
bagFull = "_CeruleanGymMistyTM11NoRoomText" },
["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE",
item = "TM_THUNDERBOLT",
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
dialogue = {
"_VermilionGymLTSurgeReceivedThunderBadgeText",
"_VermilionGymLTSurgeThunderBadgeInfoText",
},
itemDialogue = {
"_VermilionGymLTSurgeReceivedTM24Text",
"_TM24ExplanationText",
} },
},
bagFull = "_VermilionGymLTSurgeTM24NoRoomText" },
["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA",
item = "TM_MEGA_DRAIN",
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
dialogue = {
"_CeladonGymErikaReceivedRainbowBadgeText",
"_CeladonGymRainbowBadgeInfoText",
},
itemDialogue = {
"_CeladonGymReceivedTM21Text",
"_TM21ExplanationText",
} },
},
bagFull = "_CeladonGymTM21NoRoomText" },
["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA",
item = "TM_TOXIC",
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
dialogue = {
"_FuchsiaGymKogaReceivedSoulBadgeText",
"_FuchsiaGymKogaSoulBadgeInfoText",
},
itemDialogue = {
"_FuchsiaGymKogaReceivedTM06Text",
"_FuchsiaGymKogaTM06ExplanationText",
} },
},
bagFull = "_FuchsiaGymKogaTM06NoRoomText" },
["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA",
item = "TM_PSYWAVE",
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
dialogue = {
"_SaffronGymSabrinaReceivedMarshBadgeText",
"_SaffronGymSabrinaMarshBadgeInfoText",
},
itemDialogue = {
"_SaffronGymSabrinaReceivedTM46Text",
"_TM46ExplanationText",
} },
},
bagFull = "_SaffronGymSabrinaTM46NoRoomText" },
["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE",
item = "TM_FIRE_BLAST",
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
dialogue = {
"_CinnabarGymBlaineReceivedVolcanoBadgeText",
"_CinnabarGymBlaineVolcanoBadgeInfoText",
},
itemDialogue = {
"_CinnabarGymBlaineReceivedTM38Text",
"_CinnabarGymBlaineTM38ExplanationText",
} },
},
bagFull = "_CinnabarGymBlaineTM38NoRoomText" },
["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI",
item = "TM_FISSURE",
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
dialogue = {
"_ViridianGymGiovanniReceivedEarthBadgeText",
"_ViridianGymGiovanniEarthBadgeInfoText",
},
itemDialogue = {
"_ViridianGymGiovanniReceivedTM27Text",
"_ViridianGymGiovanniTM27ExplanationText",
} },
},
bagFull = "_ViridianGymGiovanniTM27NoRoomText" },
-- Silph Co. Giovanni: unlocks the president's Master Ball gift.
-- SilphCo11FGiovanniStartBattleScript (scripts/SilphCo11F.asm) hands the
+55 -8
View File
@@ -1362,6 +1362,23 @@ function BattleState:sendOutText(name)
return self:romText("_EnemysWeakText", "The enemy's weak!\nGet'm! %s!", name)
end
-- The cry a mon makes as it takes the field. Yellow does not run its
-- starter Pikachu through PlayCry at all: SendOutMon branches to
-- .starterPikachu (engine/battle/core.asm:1807-1817) and voices PCM
-- PikachuCry11, the short "Pika!", or PikachuCry37 when the Pikachu is
-- asleep (IsPlayerPikachuAsleepInParty); PrintBeginningBattleText does the
-- same for the BATTLE_TYPE_PIKACHU intro (engine/battle/common_text.asm:
-- 12-19). Without a clip the bare playCry reached for clip 1, the long
-- title-screen "Pikachuuu" (#837). Every PIKACHU gets it here, the same
-- starter approximation the rest of the port makes
-- (PikachuFollower.starterInParty).
function BattleState:playEntranceCry(battler)
local mon = battler and battler.mon
if not mon then return end
require("src.core.Sound").playCry(self.data, mon.species,
mon.status == "SLP" and 37 or 11)
end
-- audio/play_battle_music.asm: gym leaders (wGymLeaderNo) get the
-- gym-leader theme, Lance does too, and the Champion (OPP_RIVAL3)
-- gets the final-battle theme
@@ -1495,7 +1512,7 @@ function BattleState:enter()
-- a different point in each battle kind, so queue it per branch
local function queueEnemyCry()
self:act(function()
require("src.core.Sound").playCry(self.data, self.enemy.mon.species)
self:playEntranceCry(self.enemy)
end)
end
-- PrintBeginningBattleText (engine/battle/common_text.asm:10-19): a wild
@@ -1606,7 +1623,7 @@ function BattleState:enter()
-- SendOutMon (core.asm:1757-1762): after the poof the mon grows
-- out of the ball (AnimateSendingOutMon at hlcoord 4,11)
self:startGrowIn(self.player)
require("src.core.Sound").playCry(self.data, self.player.mon.species)
self:playEntranceCry(self.player)
end)
self:markParticipant()
end
@@ -2355,7 +2372,7 @@ function BattleState:resolveSwitch(newMon)
self.sendingOut = false
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
self:startGrowIn(self.player)
require("src.core.Sound").playCry(self.data, self.player.mon.species)
self:playEntranceCry(self.player)
end)
end)
self:act(function()
@@ -2823,7 +2840,16 @@ function BattleState:applyHitFx(hit)
local t = hit.animType
if not t and hit.blink then t = hit.blink.isPlayer and 1 or 4 end
if hit.sfx then
require("src.core.Sound").play(self.data, hit.sfx)
local Sound = require("src.core.Sound")
-- EffectRegistry hands the row the PlayApplyingAttackSound sound WITH its
-- wFrequencyModifier byte, so it goes through the same pitch/tempo path
-- move sounds use (#826). A bare string -- an older row, or a mod that
-- built its own hit fx -- still plays unmodified.
if type(hit.sfx) == "table" then
Sound.playMove(self.data, hit.sfx)
else
Sound.play(self.data, hit.sfx)
end
end
if not t or not self:animationsOn() then return end
if t == 1 then
@@ -3894,7 +3920,7 @@ function BattleState:enemyMonFainted()
self.enemySendingOut = false
self:startGrowIn(self.enemy)
self:actNext(function()
require("src.core.Sound").playCry(self.data, self.enemy.mon.species)
self:playEntranceCry(self.enemy)
end)
end)
end)
@@ -3933,7 +3959,7 @@ function BattleState:enemyMonFainted()
self:actNext(function()
self.sendingOut = false
self:startGrowIn(self.player)
require("src.core.Sound").playCry(self.data, self.player.mon.species)
self:playEntranceCry(self.player)
end)
end)
return
@@ -4127,7 +4153,7 @@ function BattleState:openReplacementMenu()
self.sendingOut = false
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
self:startGrowIn(self.player)
require("src.core.Sound").playCry(self.data, self.player.mon.species)
self:playEntranceCry(self.player)
end)
end,
})
@@ -5116,11 +5142,32 @@ function BattleState:drawZonePass(src, sx, sy)
local shader = PaletteFX.shader()
local pals = self:sgbBattlePals()
local bgp = self:activeBgp()
-- #822: OG / OG INV / CLASSIC are forced-mono modes, so sgbPalettes() being
-- nil here makes PaletteFX.ensureZones invent a whole-screen zone and the
-- WHOLE finished frame is re-thresholded through the shade shader at blit
-- time -- which is why picImage already hands those modes raw DMG grays.
-- This pass has to leave DMG shades behind for the same reason: sendColors
-- runs the mode substitution HERE too, and the frame-level pass then
-- substitutes a second time. OG INV inverts twice and comes out upright;
-- CLASSIC's color 0 (155,188,15) has red 0.61, which falls in the shader's
-- c1 bucket, so the paper darkens one shade. Either way the battle stops
-- matching the YES/NO box an overlay state draws over it, since that box
-- only ever sees the frame-level pass. OG is the identity, which is why
-- only the other two showed it. Keep this mode set in sync with picImage /
-- PaletteFX.ensureZones / WideBattle.monoMode.
local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv"
or PaletteFX.mode == "classic"
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setShader(shader)
local shaking = sx ~= 0 or sy ~= 0
for _, z in ipairs(BATTLE_ZONES) do
PaletteFX.sendColors(shader, PaletteFX.permute(pals[z.pal], bgp))
if mono then
-- the BGP fade still runs, just in gray: the frame-level pass colors
-- whatever DMG shade this leaves behind
PaletteFX.sendShades(shader, PaletteFX.permute(PaletteFX.GRAYS, bgp))
else
PaletteFX.sendColors(shader, PaletteFX.permute(pals[z.pal], bgp))
end
local zx, zy = z[1] * 8, z[2] * 8
local zw, zh = (z[3] - z[1] + 1) * 8, (z[4] - z[2] + 1) * 8
love.graphics.setScissor(zx, zy, zw, zh)
+22 -2
View File
@@ -209,8 +209,28 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- announcement-time moveAnimRow, later hits queue fresh anim rows.
-- Thrash/rage continuations have no announcement anim -- a bare
-- hitRow carries the blink instead.
local hitSfx = info.typeMult > 10 and "Super_Effective"
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
-- PlayApplyingAttackSound (engine/battle/animations.asm, the routine after
-- PlayApplyingAttackAnimation) picks the sound off wDamageMultipliers -- 10
-- is neutral, above it super effective, below it not very -- and sets
-- wFrequencyModifier/wTempoModifier alongside it: $20/$30 damage, $e0/$ff
-- super effective, $50/$01 not very. All three programs live on the noise
-- channel (audio/sfx/{damage,super_effective,not_very_effective}.asm,
-- `channel 8`), where the frequency modifier is added to the polynomial
-- counter and so IS the pitch of the hit, while the tempo modifier is
-- skipped outright (audio/engine_2.asm Audio2_note_length: `cp CHAN8 /
-- jr z, .skip` keeps the noise channel at the default $100). Playing them
-- bare made the super effective hit a dull thud and the not very effective
-- one a bright crack, which is why they sounded swapped (#826); the tempo
-- byte is deliberately not carried, since applying it would stretch notes
-- the hardware never stretches.
local hitSfx
if info.typeMult > 10 then
hitSfx = { sound = "Super_Effective", pitch = 0xe0 }
elseif info.typeMult < 10 then
hitSfx = { sound = "Not_Very_Effective", pitch = 0x50 }
else
hitSfx = { sound = "Damage", pitch = 0x20 }
end
-- GetPlayerAnimationType / GetEnemyAnimationType (engine/battle/core.asm
-- :3159 / :5555): wAnimationType is 4 (blink the enemy pic) or 1 (shake
-- the screen vertically) for a damaging move with no added effect, and
+48 -2
View File
@@ -202,6 +202,30 @@ local function headerChannels(banks, header)
return channels
end
-- Which software channels (CHAN5-8) an sfx occupies: its header carries one
-- 3-byte descriptor per channel. Audio2_PlaySound walks exactly this list to
-- decide whether a new sfx may start at all (audio/engine_2.asm
-- .sfxChannelLoop), so Sound.playMove needs the set to reproduce that gate.
-- nil = not knowable here (a file def, or the banks are not readable yet),
-- which callers read as "no conflict".
function ChipSynth.effectChannels(data, def)
if type(def) ~= "table" then return nil end
local chip = def.chip
local specs = chip and chip.channels
if not specs then
if not def.address then return nil end
local ok, banks = pcall(engineBanks, data, chip)
if not ok then return nil end
local read
ok, read = pcall(headerChannels, banks, def)
if not ok then return nil end
specs = read
end
local channels = {}
for _, spec in ipairs(specs) do channels[#channels + 1] = spec.number end
return channels
end
local function fadeValue(nibble)
if bit.band(nibble, 8) ~= 0 then return -bit.band(nibble, 7) end
return nibble
@@ -406,7 +430,15 @@ function Channel:nextEvent()
elseif command == 0xEC then
self.duty = bit.band(self:byte(), 3)
elseif command == 0xED then
self.engine.tempo = self:byte() * 0x100 + self:byte()
local high = self:byte()
local low = self:byte()
-- a header carrying its own tempo is one of audio/alternate_tempo.asm's
-- Music_*AlternateTempo entry points, which re-point channel 1 at a
-- stub that sets the tempo and jumps into the normal body -- the body's
-- own tempo command never runs there, so ignore it here (#847)
if not self.engine.tempoLocked then
self.engine.tempo = high * 0x100 + low
end
elseif command == 0xEE then
self.engine.pan = self:byte()
elseif command == 0xEF or command == 0xF0 then
@@ -458,7 +490,15 @@ function Channel:nextEvent()
local volume = bit.rshift(packed, 4)
local fade = fadeValue(bit.band(packed, 0x0F))
if self.noise then
local parameter = self:byte()
-- Audio2_ApplyWavePatternAndFrequency adds wFrequencyModifier to the
-- frequency low byte for every channel at or past CHAN5, the noise
-- channel included (audio/engine_2.asm Audio2_ApplyFrequencyModifier).
-- On CHAN8 that byte is the polynomial counter, so the modifier moves
-- the noise pitch; it wraps at 8 bits, the carry landing in the high
-- byte that noise does not use for frequency. Dropping it left the
-- battle hit sounds at their unmodified pitches, where super effective
-- reads as the duller of the two (#826).
local parameter = bit.band(self:byte() + self.frequencyOffset, 0xFF)
return self:noiseEvent(
self:durationTicks(length), volume, fade, parameter)
end
@@ -753,6 +793,12 @@ function Engine.new(data, header, options)
noiseInstruments = {},
channels = {},
}, Engine)
-- header.tempo: the Music_*AlternateTempo override Music.play stamps onto
-- a copy of the song def (audio/alternate_tempo.asm) (#847)
if header.tempo then
engine.tempo = header.tempo
engine.tempoLocked = true
end
for _, spec in ipairs(chip and chip.channels
or headerChannels(banks, header)) do
local frameTicks = options.frameTicks
+16 -1
View File
@@ -82,6 +82,7 @@ state = {
fanfare = nil, -- fanfare SFX source; the song pauses while it plays
fanfareResume = false, -- start/resume state.source when the fanfare ends
fade = nil, -- active volume-ramp fade-out (see Music.fadeOut)
tempo = nil, -- alternate-tempo override in force for `current`
failed = {}, -- labels whose def could not be started; logged once
}
@@ -234,11 +235,23 @@ function Music.play(data, song, loop, ctx)
if not song then return end
if not love.audio then return end -- headless test stub
song = selectSong(song, ctx)
-- ctx.tempo is a Music_*AlternateTempo cue (audio/alternate_tempo.asm):
-- the same song restarted with channel 1 re-pointed at a stub whose only
-- difference is its `tempo`, so the same label at a different tempo is a
-- different cue and must not be deduped away (#847)
local tempo = ctx and ctx.tempo or nil
-- a hook may silence the cue outright, or swap in a label the dedupe
-- below has to compare against
if not song or song == state.current then return end
if not song or (song == state.current and tempo == state.tempo) then return end
local def = songDef(data, song)
if not def or state.failed[song] then return end
if tempo then
-- shallow copy: the registry def is shared, only this playback is slowed
local slowed = {}
for key, value in pairs(def) do slowed[key] = value end
slowed.tempo = tempo
def = slowed
end
local wantLoop = loop ~= false
local src, loopSrc, isChip, err = startSong(data, def, wantLoop)
if not src then
@@ -274,6 +287,7 @@ function Music.play(data, song, loop, ctx)
local previous = state.current
state.source, state.loopSource, state.chip = src, loopSrc, isChip
state.current = song
state.tempo = tempo
if Runtime.wants("music.started") then
Runtime.emit("music.started", {
song = song, previous = previous, chip = isChip,
@@ -288,6 +302,7 @@ function Music.stop()
stopSource(state.loopSource)
require("src.core.ChipAudio").stopMusic()
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
state.tempo = nil
state.chip = false
state.pendingRestore = nil
if previous and Runtime.wants("music.stopped") then
+17 -2
View File
@@ -368,11 +368,26 @@ function SaveData.saveOptions(opts, fs)
end
opts.modOptions = merged
end
local ok, err = fs.write(OPTIONS_FILENAME, SaveSerializer.encode(opts))
local encoded = SaveSerializer.encode(opts)
local ok, err = fs.write(OPTIONS_FILENAME, encoded)
if not ok then
Logger.error("options save failed: %s", tostring(err))
return nil
end
return ok and opts or nil
-- #828: settings "reset" on Android and Steam Deck with nothing in the log.
-- Every options write is a WHOLE-FILE rewrite, so a write that reports
-- success without the bytes landing (an external-storage volume that went
-- away mid-session, a read-only or full save dir) is indistinguishable from
-- "the launcher never saved". Read the file back and fail loudly instead:
-- callers already treat nil as a failed write, and the log line is what the
-- next report from those platforms needs to carry.
local wrote = fs.getInfo(OPTIONS_FILENAME) and fs.read(OPTIONS_FILENAME)
if wrote ~= encoded then
Logger.error("options save did not land (%d bytes written, %s on disk)",
#encoded, type(wrote) == "string" and tostring(#wrote) or "nothing")
return nil
end
return opts
end
function SaveData.loadOptions(fs)
+84 -15
View File
@@ -208,29 +208,91 @@ end
-- sfx table; older audio.lua builds without the variants fall back to
-- the unmodified sound.
-- anim: a moves.lua anim table { sound, pitch, tempo }.
--
-- Whether a row sound is heard at all is Audio2_PlaySound's channel gate
-- (audio/engine_2.asm .playSfx/.sfxChannelLoop): for every channel the new
-- sfx wants, a channel still busy with a LOWER sound id aborts the whole
-- request (`cp [hl] / jr z,.playChannel / jr c,.playChannel / ret`), while
-- an equal or lower id takes those channels over. A sound id is
-- (header address - SFX_Headers_1) / 3 (constants/music_constants.asm
-- music_const), so a def's header address orders ids inside one engine
-- bank. Blizzard's animation is two rows, BLIZZARD then HYDRO_PUMP
-- (data/moves/animations.asm BlizzardAnim), and SFX_BATTLE_29 (CHAN5+8) is
-- still sounding when the second row starts, so the original never plays
-- SFX_BATTLE_2A (CHAN5+6+8) at all -- unguarded, its tail is heard running
-- past the end of the animation (#844).
local lastMoveSfx -- { src, rank, engine, channels } of the last row sound
local function channelsOverlap(a, b)
if not (a and b) then return false end
for _, x in ipairs(a) do
for _, y in ipairs(b) do
if x == y then return true end
end
end
return false
end
-- would PlaySound start this def now? Taking a channel over also stops the
-- sound that held it, the way .playChannel resets the channel.
local function sfxChannelGate(data, def)
local cur = lastMoveSfx
if not cur then return true end
local ok, playing = pcall(cur.src.isPlaying, cur.src)
if not (ok and playing) then
lastMoveSfx = nil
return true
end
-- an unrankable def (file asset, or another engine's bank) has no
-- comparable sound id: leave it to the mixer, as before
if type(def) ~= "table" or not def.address or def.engine ~= cur.engine then
return true
end
local channels = require("src.core.ChipSynth").effectChannels(data, def)
if not channelsOverlap(channels, cur.channels) then return true end
if def.address > cur.rank then return false end
pcall(cur.src.stop, cur.src)
lastMoveSfx = nil
return true
end
local function noteMoveSfx(data, def, src)
if not src or type(def) ~= "table" or not def.address then
lastMoveSfx = nil
return
end
lastMoveSfx = {
src = src, rank = def.address, engine = def.engine,
channels = require("src.core.ChipSynth").effectChannels(data, def),
}
end
function Sound.playMove(data, anim)
if not anim or not anim.sound then return end
local sfx = data.audio and data.audio.sfx
if not sfx then return end
local name = anim.sound
local pitch, tempo = anim.pitch or 0, anim.tempo or 0x80
local def = sfx[name]
if not sfxChannelGate(data, def) then return end
local src
-- a chip program synthesizes the modified variant on demand; a file def
-- can only reach for a pre-rendered one
if isChipDef(sfx[name]) then
if playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo),
sfx[name], pitch, tempo) then
played("move", name)
end
return
end
if pitch ~= 0 or tempo ~= 0x80 then
if isChipDef(def) then
src = playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo),
def, pitch, tempo)
else
local key = ("%s@%02x%02x"):format(name, pitch, tempo)
if sfx[key] then
if playPath(data, key, sfx[key]) then played("move", name) end
return
if (pitch ~= 0 or tempo ~= 0x80) and sfx[key] then
src = playPath(data, key, sfx[key])
else
src = playPath(data, name, def)
end
end
if playPath(data, name, sfx[name]) then played("move", name) end
if src then
played("move", name)
noteMoveSfx(data, def, src)
end
end
-- A derived cry ({ base = "RHYDON", pitch, length }) borrows another
@@ -304,12 +366,18 @@ end
-- returns the source (nil headless) so callers that block on the cry
-- like the original's PlayCry -> WaitForSoundToFinish can poll it
function Sound.playCry(data, species)
function Sound.playCry(data, species, pikaClip)
if not love.audio then return nil end
-- Yellow voices every Pikachu cry with the PCM clips (the chip cry is
-- never used for the species there); clip 1 is the everyday "Pika!"
-- never used for the species there). Which clip is a property of the
-- call site in the original -- every caller of PlayPikachuSoundClip sets
-- its own `ldpikacry e, PikachuCryN` -- so pikaClip carries that choice
-- in; it is ignored for every other species. Clip 1 is the LONG
-- title-screen "Pikachuuu" (engine/movie/title.asm:146), kept as the
-- default only for the sites that have not been given their own clip
-- yet; battle entrances pass 11/37 (#837).
if species == "PIKACHU" then
local src = Sound.playPikaCry(data, 1)
local src = Sound.playPikaCry(data, pikaClip or 1)
if src then return src end
end
local cries = data.audio and data.audio.cries
@@ -451,6 +519,7 @@ end
-- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo
-- variants included) or all of them, so the next play re-resolves the def
function Sound.invalidate(name)
lastMoveSfx = nil -- its source is about to be dropped or stopped
local function evict(store, key)
local src = store[key]
if src then pcall(src.stop, src) end
+31
View File
@@ -997,6 +997,25 @@ local function updaterAllowed()
return true
end
-- #835: which column the launcher opens on. `tab` starts at the --game
-- shortcut's version (LaunchOptions.pendingTab) or Red; this then prefers the
-- game play() last handed off, so relaunching lands on the game that was last
-- played instead of always Red. An explicit --game still wins, and a
-- remembered version whose cache is gone or stale is ignored, since opening a
-- column with no Play button would read as the launcher losing the import.
-- Called from new() once self.ready is filled, which is what that check needs.
function RomImporter:_applyLastVersionTab()
local okLO, LO = pcall(require, "src.core.LaunchOptions")
if okLO and LO.pendingTab then return end
local okOpt, opts = pcall(function()
return require("src.core.SaveData").loadOptions()
end)
local last = okOpt and opts and opts.lastVersion
if last and GameVersion.VERSIONS[last] and self.ready[last] then
self.tab = last
end
end
-- The launcher runs each GameVersion as an independent tab. Each dropped or
-- chosen ROM is routed to its version by SHA-1, extracted into that version's
-- own cache (Red at the root, Blue under blue/, Yellow under yellow/), so all
@@ -1128,6 +1147,7 @@ function RomImporter.new(onComplete, opts)
self.romName[version] = "pokemon_" .. info.id
.. (info.id == "yellow" and ".gbc" or ".gb")
end
self:_applyLastVersionTab()
-- Android: import a save-dir .gb/.gbc that is not yet ready (USB drop or a
-- leftover SAF pick), routed by SHA-1. Already-imported carts are skipped
@@ -2220,6 +2240,17 @@ function RomImporter:play(version)
if self.workState == "working" then return end
if not self.ready[version] then return end
self._handedOff = true
-- #835: remember the game being launched so the next launcher start opens on
-- its column (_applyLastVersionTab). It rides options.lua rather than a file
-- of its own, so portable installs and POKEPORT_IDENTITY sandboxes keep it
-- with the rest of the launcher's persisted state. A failed write only
-- costs the memory of the choice, so it must never block the boot.
pcall(function()
local SaveData = require("src.core.SaveData")
local opts = SaveData.loadOptions()
opts.lastVersion = version
SaveData.saveOptions(opts)
end)
resetPointerCursor(self)
-- The game draws with raw love.graphics from here on; drop the view's
-- element tree and canvases before the handoff.
+7
View File
@@ -489,6 +489,13 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if battle then
return "failed", { notTime(data, save) }
end
-- ItemUseBicycle (engine/items/item_effects.asm) opens with
-- `cp 2 ; is the player surfing?` -> jp z, ItemUseNotTime, so the
-- BICYCLE refuses on the water with the same OAK text as the rods
-- above (#846)
if ow and ow.player and ow.player.surfing then
return "failed", { notTime(data, save) }
end
return "bicycle"
end
+16
View File
@@ -868,4 +868,20 @@ function PaletteFX.sendColors(shader, c)
shader:send("c3", { c[4][1] / 255, c[4][2] / 255, c[4][3] / 255 })
end
-- The same send with NO display-mode substitution and no shade map: the four
-- colors reach the shader exactly as given. Only for an INTERMEDIATE pass
-- whose output is re-thresholded downstream -- the classic battle's zone pass
-- under a forced-mono mode, where ensureZones' whole-screen zone already
-- substitutes once at blit time and doing it again here applies the mode
-- twice (#822). Everything that draws a final pixel wants sendColors.
function PaletteFX.sendShades(shader, c)
-- headless (no love.graphics) leaves shader() nil; sendColors reaches the
-- same no-op through effectiveColors returning nil for an absent palette
if not shader or not c then return end
shader:send("c0", { c[1][1] / 255, c[1][2] / 255, c[1][3] / 255 })
shader:send("c1", { c[2][1] / 255, c[2][2] / 255, c[2][3] / 255 })
shader:send("c2", { c[3][1] / 255, c[3][2] / 255, c[3][3] / 255 })
shader:send("c3", { c[4][1] / 255, c[4][2] / 255, c[4][3] / 255 })
end
return PaletteFX
+15 -2
View File
@@ -1075,9 +1075,13 @@ function Commands.march_in_place(ctx, objIndex, on)
end
-- play_music <songId> [opts]: switch map music now; opts.keep marks it
-- to survive the next warp (the story files' keepMusic idiom)
-- to survive the next warp (the story files' keepMusic idiom).
-- opts.tempo is the Music_*AlternateTempo override (audio/alternate_tempo.asm
-- re-points channel 1 at a stub that only changes the song's `tempo`) (#847).
function Commands.play_music(ctx, songId, opts)
require("src.core.Music").play(ctx.game.data, songId)
local tempo = opts and opts.tempo
require("src.core.Music").play(ctx.game.data, songId, nil,
tempo and { tempo = tempo } or nil)
if opts and opts.keep and ctx.overworld then
ctx.overworld.keepMusicOnce = true
end
@@ -1087,6 +1091,15 @@ function Commands.stop_music(ctx)
require("src.core.Music").stop()
end
-- fade_music [control]: FadeOutAudio (home/fade_audio.asm) -- ramp the
-- current song to silence over 7 * control frames and stop it, the way
-- Music_Cities1AlternateTempo does before it restarts Cities1 (#847).
-- Non-blocking, like the ROM's write to wAudioFadeOutControl: pair it with
-- the `wait` that stands in for the following DelayFrames.
function Commands.fade_music(ctx, control)
require("src.core.Music").fadeOut(control or 10)
end
-- play_default_music: PlayDefaultMusic -- resume the current map's own
-- theme (data.audio.mapSongs) after a cutscene override, keeping the
-- bike/surf substitution rules. Headless-safe no-op without an overworld.
+21 -10
View File
@@ -261,11 +261,30 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
Evolution.evolve(game, target, extra.evolveTo)
return
end
-- refresh counts in the list. Hoisted out of the tail below because the
-- RARE CANDY path returns early and still leaves the list on screen, so
-- both paths have to agree on what the row reads (#796).
local function refreshCounts()
for i, it in ipairs(list.items) do
if it.value == id then
local left = game.save.inventory[id]
if left then it.right = "x" .. left else table.remove(list.items, i) end
break
end
end
list.index = math.min(list.index, math.max(1, #list.items))
end
-- RARE CANDY: after the level text, the stat window, any level-up
-- moves and a level evolution follow (item_effects.asm .useRareCandy
-- runs PrintStatsBox, LearnMoveFromLevelUp and TryEvolvingMon)
if extra and extra.leveledTo and target then
list:close()
-- .useItem_partyMenu re-enters StartMenu_Item after the stat box, it
-- does not CloseStartMenu, so out of battle the bag list stays up with
-- the decremented count showing. In battle the item spends the turn
-- and the bag has to go (dead today: ItemUseVitamin refuses RARE CANDY
-- mid-battle, kept so the boundary stays explicit). #796
refreshCounts()
if battle then list:close() end
showMessages(game, payload, function()
local StatBox = require("src.battle.BattleState").StatBox
game.stack:push(StatBox.new(game, target, function()
@@ -304,15 +323,7 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
end)
return
end
-- refresh counts in the list
for i, it in ipairs(list.items) do
if it.value == id then
local left = game.save.inventory[id]
if left then it.right = "x" .. left else table.remove(list.items, i) end
break
end
end
list.index = math.min(list.index, math.max(1, #list.items))
refreshCounts()
-- HP medicine: fill the bar in the still-open picker first, then print
-- and close, the order item_effects.asm .doneHealing runs in
-- (SFX_HEAL_HP -> UpdateHPBar2 -> RedrawPartyMenu prints the message).
+99 -17
View File
@@ -1,6 +1,7 @@
-- Hall of Fame induction (engine/movie/hall_of_fame.asm): each party
-- member's front sprite scrolls onto the right side of the screen
-- (HoFShowMonOrPlayer's .ScrollPic), then HoFDisplayMonInfo draws the
-- member's back sprite sweeps across the screen and its front sprite then
-- scrolls onto the right side (HoFShowMonOrPlayer's two .ScrollPic passes,
-- #847), then HoFDisplayMonInfo draws the
-- left-side LEVEL/TYPE box, plays the cry, holds, and pops the bottom
-- "HALL OF FAME" text box before fading to the next mon. After the
-- party, the player pic scrolls in and HoFDisplayPlayerStats shows the
@@ -41,6 +42,25 @@ end
local SCROLL_SPEED = 4 -- px/frame @ 60fps
local PIC_X, PIC_Y = 12 * 8, 5 * 8
-- The BACK pic pass that runs in front of every front pic (#847).
-- HoFShowMonOrPlayer loads the back pic (predef LoadMonBackPic, or
-- RedPicBack through HoFLoadPlayerPics) into the same 7x7 window at
-- hlcoord 12,5, points the tilemap at base tile $31, and sweeps hSCX from
-- $c0 to $a0 at e = 4 -- in screen pixels the pic enters at x = 160 and
-- leaves at x = -64, 56 frames. Only then is the window re-pointed at the
-- front pic (base tile 0) with hSCY back to 0 and e = -4, so the front pic
-- starts at x = -64 rather than at its own width.
-- hSCY = $d0 during the back pass puts the window's top row at y = 88, so
-- its 7 tiles sit flush with the bottom of the screen. ScaleSpriteByTwo
-- (engine/battle/scale_sprites.asm) doubles the 32x32 back sprite into that
-- 7x7 = 56x56 buffer with the last 4 source rows and the last source column
-- dropped, so what shows is the sprite's top-left 28x28 at 2x.
local BACK_START_X, BACK_END_X = 160, -64
local BACK_Y = 88
local BACK_SCALE = 2
local BACK_CROP = 28
local FRONT_START_X = -64
-- After HoFDisplayAndRecordMonInfo: 80 DelayFrames, then the bottom
-- HALL OF FAME box for 180 DelayFrames, then GBFadeOutToWhite.
local INFO_HOLD = 80
@@ -74,6 +94,8 @@ function HallOfFame.new(game, onDone)
self.phase = "mons"
self.sprites = {} -- species -> image or false
self.spriteTrueColor = {} -- species -> full-color art flag (#637)
self.backs = {} -- species (or "@player") -> back image or false (#847)
self.backTrueColor = {}
local playerPath, playerTrueColor =
require("src.pokemon.Sprites").playerPath(
game.data, "front", { kind = "hof" })
@@ -98,20 +120,14 @@ function HallOfFame:nextMon()
local mon = self.game.save.party[self.index]
self.showHofBanner = false
self.fade = 0
if mon then
self.phase = "mons"
self.timer = INFO_HOLD
Sound.playCry(self.game.data, mon.species)
local sprite = self:spriteFor(mon.species)
local w = sprite and sprite:getWidth() or 56
self.scrollX = -w
else
-- HoFShowMonOrPlayer with wHoFMonOrPlayer = player
self.phase = "player"
self.timer = 0
local w = self.playerPic and self.playerPic:getWidth() or 56
self.scrollX = -w
end
self.backQuad = nil
-- HoFShowMonOrPlayer runs the back pic sweep for a party member and for
-- the player alike (wHoFMonOrPlayer only picks which pics get loaded), so
-- both enter through the "back" phase and the front pic follows it (#847)
self.phase = "back"
self.afterBack = mon and "mons" or "player"
self.timer = 0
self.scrollX = BACK_START_X
end
function HallOfFame:spriteFor(species)
@@ -126,6 +142,30 @@ function HallOfFame:spriteFor(species)
return cached or nil
end
-- The back pic for the pass ahead of the current front pic: the party
-- member's own back sprite (predef LoadMonBackPic), or RedPicBack once the
-- party is done (HoFLoadPlayerPics). Cached like spriteFor (#847).
function HallOfFame:backPicFor()
local mon = self.game.save.party[self.index]
local key = mon and mon.species or "@player"
local cached = self.backs[key]
if cached == nil then
local Sprites = require("src.pokemon.Sprites")
local path, trueColor
if mon then
path, trueColor = Sprites.path(self.game.data, mon.species, "back",
{ kind = "hof" })
else
path, trueColor = Sprites.playerPath(self.game.data, "back",
{ kind = "hof" })
end
cached = tryImage(path) or false
self.backs[key] = cached
self.backTrueColor[key] = cached and trueColor or false
end
return cached or nil, self.backTrueColor[key]
end
-- HoFDisplayPlayerStats' DisplayDexRating tally (also
-- OverworldController:dexRating / PokedexMenu.new's seen+owned counts)
function HallOfFame:dexSeenOwned()
@@ -154,9 +194,28 @@ function HallOfFame:update(dt)
local input = self.game.input
local skip = input:wasPressed("a")
-- .ScrollPic with d = $a0, e = 4: the back pic crosses the screen right to
-- left and is gone before the front pic starts. Both scrolls are plain
-- DelayFrame loops in the ROM, so neither takes a button (#847).
if self.phase == "back" then
self.scrollX = self.scrollX - SCROLL_SPEED
if self.scrollX <= BACK_END_X then
self.phase = self.afterBack
self.timer = self.phase == "mons" and INFO_HOLD or 0
self.scrollX = FRONT_START_X
end
return
end
if self.phase == "mons" or self.phase == "fade" then
if self.phase == "mons" and self.scrollX < PIC_X then
self.scrollX = math.min(PIC_X, self.scrollX + SCROLL_SPEED)
-- PlayCry is the tail of HoFDisplayMonInfo, which runs only after
-- HoFShowMonOrPlayer's two scrolls have both settled (#847)
if self.scrollX >= PIC_X then
local mon = self.game.save.party[self.index]
if mon then Sound.playCry(self.game.data, mon.species) end
end
return
end
if self.phase == "fade" then
@@ -277,6 +336,27 @@ function HallOfFame:drawPic(img, trueColor)
end
end
-- The back pic sweep (see the BACK_* constants): the same 7x7 window as the
-- front pic, one screen lower, cropped to the 28x28 ScaleSpriteByTwo keeps.
function HallOfFame:drawBackPic()
local img, trueColor = self:backPicFor()
if not img then return end
local w = math.min(img:getWidth(), BACK_CROP)
local h = math.min(img:getHeight(), BACK_CROP)
if not self.backQuad then
self.backQuad = love.graphics.newQuad(0, 0, w, h, img:getDimensions())
end
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, self.backQuad, self.scrollX, BACK_Y, 0,
BACK_SCALE, BACK_SCALE)
-- same whole-screen palette exemption the front pic takes (#637)
if trueColor then
require("src.render.PaletteFX").markTrueColor(self.scrollX, BACK_Y,
w * BACK_SCALE,
h * BACK_SCALE)
end
end
-- HoFDisplayPlayerStats boxes + labels (player pic already on the right)
function HallOfFame:drawPlayerStats()
local save = self.game.save
@@ -301,7 +381,9 @@ function HallOfFame:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
if self.phase == "mons" or self.phase == "fade" then
if self.phase == "back" then
self:drawBackPic()
elseif self.phase == "mons" or self.phase == "fade" then
local mon = self.game.save.party[self.index]
if mon then
self:drawPic(self:spriteFor(mon.species),
+14 -1
View File
@@ -99,7 +99,20 @@ end
function NamingScreen:confirm()
local name = table.concat(self.glyphs)
if name == "" then
name = (self.presets and self.presets[1]) or self.default or "A"
-- An empty confirm (START, or the ED cell with nothing typed) must not
-- invent a letter (#833). DisplayNamingScreen seeds wStringBuffer with
-- '@' (engine/menus/naming_screen.asm) and every caller checks that first
-- byte: AskName falls through to .declinedNickname, copying the species
-- name over the nick slot -- vanilla's "un-nicknamed", which this port
-- models as mon.nickname == nil (src/save_convert/GenSave.lua), so
-- evolution can still rename the mon. DisplayNameRaterScreen takes
-- .playerCancelled and keeps the old nick, which is why an explicit
-- opts.default still wins here. Player/rival naming
-- (oak_speech2.asm ChoosePlayerName) re-opens on '@' and never accepts an
-- empty result; the port keeps its preset fallback for that.
-- Contract for callers: "" means NO name -- BattleState:askNicknameUI and
-- Commands.give_pokemon both guard on #name > 0 before setting nickname.
name = (self.presets and self.presets[1]) or self.default or ""
end
Sound.play(self.game.data, "Press_AB")
self.game.stack:pop()
+81 -21
View File
@@ -2362,8 +2362,21 @@ function OverworldState:trySurf(fx, fy, onClose)
Game.stack:push(TextBox.new(Game, text, function()
if onClose then onClose() end
p.surfing = true
-- walking / biking / surfing is ONE state byte in the original:
-- ItemUseSurfboard (engine/items/item_effects.asm) writes 2 over
-- whatever wWalkBikeSurfState held, so mounting a surf ends the bike
-- outright -- no bike step cadence in Player:tryMove and no bike
-- theme on the water (#846). Music.playMap re-picks the override
-- with BOTH flags, which setSurfing alone cannot do: effectiveMapSong
-- (src/core/Music.lua) prefers state.onBike over state.surfing.
Game.save.onBike = false
self:syncSurfingPikachu()
require("src.core.Music").setSurfing(Game.data, true)
local Music = require("src.core.Music")
if self.map then
Music.playMap(Game.data, self.map.id, false, true)
else
Music.setSurfing(Game.data, true) -- headless harness with no map loaded
end
Game.stack:push(require("src.render.Transition").whiteFlash(Game, nil,
function() self:stepForwardOrCrossEdge(p.facing) end))
end))
@@ -3036,9 +3049,16 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
if reward.badge then
Game.save.inventory[reward.badge] = 1
end
-- GiveItem can refuse at the bag cap: the leaders' post-battle scripts
-- (`call GiveItem` / `jr nc, .BagFull` in scripts/PewterGym.asm
-- PewterGymScriptReceiveTM34 and its siblings) then skip the received-TM
-- text and print the gym's *TM*NoRoomText instead, while .gymVictory
-- still awards the badge. Going through Bag.add keeps the cap and the
-- wBagItems order honest -- the raw inventory write bypassed both (#797).
local gotItem = false
if reward.item then
local inv = Game.save.inventory
inv[reward.item] = (inv[reward.item] or 0) + 1
gotItem = require("src.inventory.Bag").add(
Game.save, reward.item, 1, Game.data)
local idef = Game.data.items[reward.item]
-- GiveItem -> CopyToStringBuffer for "{RAM:wStringBuffer}" received texts
Game.stringBuffer = idef and idef.name or reward.item
@@ -3046,7 +3066,21 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
local lines = {}
if reward.dialogue then
local text = Game.data.text or {}
for _, label in ipairs(reward.dialogue) do
-- itemDialogue is the received-TM tail the original prints only when
-- GiveItem succeeded; bagFull is the alternate line for when it did not
local chain = reward.dialogue
if reward.itemDialogue or reward.bagFull then
chain = {}
for _, label in ipairs(reward.dialogue) do chain[#chain + 1] = label end
if gotItem then
for _, label in ipairs(reward.itemDialogue or {}) do
chain[#chain + 1] = label
end
elseif reward.bagFull then
chain[#chain + 1] = reward.bagFull
end
end
for _, label in ipairs(chain) do
if text[label] and text[label] ~= "" then
table.insert(lines, text[label])
end
@@ -3057,7 +3091,7 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
or reward.badge
table.insert(lines, Strings("%s received\nthe %s!", Game.save.player.name, name))
end
if reward.item then
if reward.item and gotItem then
local name = Game.stringBuffer or reward.item
table.insert(lines, Strings("%s received\n%s!", Game.save.player.name, name))
end
@@ -3620,9 +3654,13 @@ function OverworldState:checkForcedMovement()
return true
end
elseif tile.mode == "surf" then
-- scripts/SeafoamIslandsB4F.asm writes wWalkBikeSurfState = 2 and
-- jp ForceBikeOrSurf, so a forced surf clears the bike state the
-- same way the party-menu mount does (#846)
p.surfing = true
Game.save.onBike = false
self:syncSurfingPikachu()
require("src.core.Music").setSurfing(Game.data, true)
require("src.core.Music").playMap(Game.data, self.map.id, false, true)
end
return false
end
@@ -3913,25 +3951,47 @@ function OverworldState:warpToHealPoint(onDone, opts)
-- Dig/Teleport/Escape Rope land OUTSIDE at the last Pokemon Center TOWN
-- door, like Fly (#196) -- NOT the interior heal cell a blackout returns
-- to. pret routes escape-warp and blackout both through wLastBlackoutMap
-- (both appear inside in front of the nurse), but this port has decided
-- the escape-warp destination is the town PC door. Prefer the canonical
-- Fly landing (field.flyWarps, one tile south of the PC door warp), else
-- the remembered outdoor door cell; fall back to the interior heal cell
-- only for an old save with no recorded outdoor.
local out = heal.outdoor
if out then
local fw = (Game.data.field.flyWarps or {})[out.id]
map = out.id
x = fw and fw.x or out.x
y = fw and fw.y or out.y
-- (LoadSpecialWarpData .usedFlyWarp, engine/overworld/special_warps.asm),
-- and that map is ALWAYS an outdoor one: SetLastBlackoutMap copies
-- wLastMap (engine/events/set_blackout_map.asm) and WarpFound2 only
-- writes wLastMap on outside maps (home/overworld.asm), with the landing
-- cell read from FlyWarpDataPtr. Prefer the canonical Fly landing
-- (field.flyWarps, one tile south of the PC door warp), else the
-- remembered outdoor door cell.
--
-- A heal record naming no outdoor town, or naming a map that is not
-- outdoors at all, is never a legal escape-warp destination: a .sav
-- import stamps lastHeal from wherever the cartridge was saved
-- (SaveConvert mergeDefaults), so ESCAPE ROPE was dropping the player
-- into the dungeon that save sat in, whose LAST_MAP exits then still
-- pointed at the door they had walked in through (#805). Vanilla's
-- zero-filled wLastBlackoutMap is map 0, so an unusable record falls
-- back to the boot heal town exactly as a never-healed game does.
local out = heal.outdoor or { id = heal.map, x = heal.x, y = heal.y }
local fw = (Game.data.field.flyWarps or {})[out.id]
local outX = fw and fw.x or out.x
local outY = fw and fw.y or out.y
local outDef = Game.data.maps[out.id]
if not (outDef and outX and outY
and Map.isOutside(outDef,
FieldDefaults.field(Game.data, "outsideTilesets"))) then
local zeroFill = require("src.core.SaveData")
.defaultHeal(Game.data.field.boot)
out, outX, outY = { id = zeroFill.map }, zeroFill.x, zeroFill.y
end
map, x, y = out.id, outX, outY
end
self:startWarpTo(map, x, y, "down", onDone)
-- Blackouts land at the interior heal cell, so re-point LAST_MAP exits at
-- the remembered town door. The teleport branch already lands ON that
-- outdoor map, so startWarpTo remembers it on the next exit; re-pointing
-- here would wrongly steer exits away from where the player now stands.
if heal.outdoor and not teleport then
-- the remembered town door. The teleport branch re-points at the town it
-- just landed on: PrepareForSpecialWarp (engine/overworld/special_warps.asm)
-- writes the special-warp destination straight back into wLastMap for every
-- fly/escape warp that is not a dungeon warp, so the next LAST_MAP exit
-- resolves against that town instead of the dungeon door the player walked
-- in through before using the rope (#805).
if teleport then
self:rememberOutdoor(map, x, y)
elseif heal.outdoor then
self:rememberOutdoor(heal.outdoor.id, heal.outdoor.x, heal.outdoor.y)
end
end
@@ -0,0 +1,216 @@
-- Eye check: a YES/NO box over a classic battle wears the same paper as the
-- field behind it (#822). pokered data/sgb/sgb_packets.asm BlkPacket_Battle
-- attributes all 18 rows, and home/yes_no.asm InitYesNoTextBoxParameters puts
-- the box at hlcoord 14,7 -- inside the player-HP-bar region, pal 0.
-- POKEPORT_DRIVER=tests/drivers/battle_choice_paper_bug822_test.lua POKEPORT_IDENTITY=bug822 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
-- No POKEPORT_SPEED: it scales the logic clock only, and these frames are judged as drawn.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local PaletteFX = require("src.render.PaletteFX")
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local ChoiceBox = require("src.ui.ChoiceBox")
local Theme = require("src.ui.Theme")
-- pokered data/maps/objects/Route1.asm puts the two youngsters at (5,24) and
-- (15,13) and the sign at (9,27), so the top of the road is empty grass; the
-- battle is pushed rather than walked into, the cell is only somewhere to stand.
local MAP = "ROUTE_1"
local STAND = { x = 5, y = 6, facing = "down" }
local PARTY = { { "BULBASAUR", 12 }, { "PIDGEOTTO", 18 } }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- ---- machine-checkable preconditions ------------------------------------
local opts = game.save.options or {}
local sfxVol = opts.sfxVol or 7
if sfxVol == 0 then
U.log("FAIL SFX volume is 0, so the box's A/B click is gone and there is no")
U.log(" way to tell a dead box from a live one you cannot hear. Set SFX to 7.")
end
check(("SFX volume %d, so the YES/NO box clicks when you answer it"):format(sfxVol),
sfxVol > 0)
check("the shade-remap shader compiled (no shader, no colorization at all)",
PaletteFX.shader() ~= nil)
check("PaletteFX.sendShades exists -- the raw sender the #822 fix needs",
type(PaletteFX.sendShades) == "function")
check("all 7 COLORS modes are on the ladder ("
.. table.concat(PaletteFX.MODES, ", ") .. ")", #PaletteFX.MODES == 7)
local cl = PaletteFX.CLASSIC
check(("CLASSIC paper is the light pea green %d,%d,%d and its second shade"
.. " the darker %d,%d,%d -- the pair #822 confused")
:format(cl[1][1], cl[1][2], cl[1][3], cl[2][1], cl[2][2], cl[2][3]),
cl[1][1] == 155 and cl[1][2] == 188 and cl[2][1] == 139)
check("OG's GRAYS start at pure white, so OG is the shader identity",
PaletteFX.GRAYS[1][1] == 255)
local box = Theme.choiceBox
check(("the YES/NO box sits at tile %d,%d (%dx%d) -- InitYesNoTextBoxParameters'"
.. " hlcoord 14,7"):format(box.tx, box.ty, box.tw, box.th),
box.tx == 14 and box.ty == 7)
-- ---- get into a battle with the box up ----------------------------------
game.save.party = {}
for _, slot in ipairs(PARTY) do
table.insert(game.save.party, Pokemon.new(game.data, slot[1], slot[2]))
end
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(20)
local ow = game.overworld
if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then
-- a map edit moved the road: any free neighbour will do, nothing here
-- depends on the cell beyond having somewhere legal to stand
for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
local cx, cy = STAND.x + d[1], STAND.y + d[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), cx, cy)
U.teleport(game, MAP, cx, cy, STAND.facing)
U.wait(20)
ow = game.overworld
break
end
end
end
check("the overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP)
local battle = BattleState.newWild(game, "RATTATA", 5)
battle.onFinish = function() end
ow:pushBattle(battle)
for _ = 1, 400 do
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
U.wait(1)
end
for _ = 1, 120 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(6)
end
check("the battle reached its FIGHT/PKMN/ITEM/RUN menu", battle.phase == "menu")
-- The same bare box BattleState pushes for the switch offer (BattleState.lua
-- :1291): no anchor, so it sits over the battle rather than riding a dialogue
-- box. Pushed directly because the reporter's screen is the box on top of a
-- live battle, and how it got there changes nothing about the colorization.
local choice = ChoiceBox.new(game, function() end)
game.stack:push(choice)
U.wait(10)
check("a YES/NO box is on top of the battle", game.stack:top() == choice)
-- ---- measure both papers, mode by mode ----------------------------------
-- Replays what Game:draw does for a classic battle with an overlay above it:
-- every state paints onto the one 160x144 UI canvas, then the topmost state
-- with sgbPalettes owns the screen. BattleState:sgbPalettes returns nil for
-- the classic layout, so PaletteFX.ensureZones decides whether a whole-screen
-- shade pass runs at all -- it does in OG / OG INV / CLASSIC, and does not in
-- the colorized modes. Offscreen so the sample boxes stay in clean 160x144
-- space whatever the window is doing; the U.shot next to each reading is the
-- same frame as presented.
local g = love.graphics
local shader = PaletteFX.shader()
-- The empty pocket the SGB attribute map leaves at tiles 9,4 - 10,6: below
-- the enemy HP bar (rows 0-3), right of the player mon (cols 0-8), left of
-- the enemy mon (col 11 on). Nothing is ever drawn here, so it is the field
-- paper and nothing else.
local FIELD = { 74, 36, 85, 52 }
-- Inside the YES/NO box, clear of its border tiles. The glyphs and cursor
-- live in here too, which is why the reading is the most common color rather
-- than one pixel.
local BOXI = { 120, 64, 151, 87 }
local function modal(id, r)
local counts, best, bestN = {}, nil, -1
for y = r[2], r[4] do
for x = r[1], r[3] do
local pr, pg, pb = id:getPixel(x, y)
local key = math.floor(pr * 255 + 0.5) .. "," .. math.floor(pg * 255 + 0.5)
.. "," .. math.floor(pb * 255 + 0.5)
counts[key] = (counts[key] or 0) + 1
if counts[key] > bestN then best, bestN = key, counts[key] end
end
end
return best
end
local function sample()
local prev = g.getCanvas()
local a = g.newCanvas(160, 144)
local b = g.newCanvas(160, 144)
g.setCanvas(a)
g.clear(1, 1, 1, 1) -- the battle letterbox is white (letterboxWhite)
g.setColor(1, 1, 1, 1)
battle:draw()
choice:draw()
g.setCanvas(b)
g.clear(0, 0, 0, 1)
local zones = PaletteFX.ensureZones(nil)
if zones and zones[1] then
g.setShader(shader)
PaletteFX.sendColors(shader, PaletteFX.GRAYS)
end
g.setColor(1, 1, 1, 1)
g.draw(a, 0, 0)
g.setShader()
g.setCanvas(prev)
local id = b:newImageData()
return modal(id, FIELD), modal(id, BOXI), zones ~= nil and zones[1] ~= nil
end
local mismatched = {}
for _, m in ipairs(PaletteFX.MODES) do
-- set the SAVED option too: Game:applyOptions re-reads save.options.colors,
-- so a bare setMode gets reverted underneath the next frame
game.save.options = game.save.options or {}
game.save.options.colors = m
PaletteFX.setMode(m)
U.wait(20)
local field, boxp, framePass = sample()
local label = PaletteFX.modeLabel(m)
local same = field == boxp
local known = (m == "gbc" or m == "gbc_inv")
U.log(("%-9s field %-13s box %-13s %s"):format(
label, field, boxp,
framePass and "whole-screen pass" or "no whole-screen pass"))
if m == "classic" then
check("CLASSIC field is the light pea green 155,188,15, not the darker"
.. " 139,172,15 one bucket down", field == "155,188,15")
end
if known then
-- the other half of #822, left open on purpose: with no frame-level pass
-- the overlay paints raw DMG white onto a canvas the battle has already
-- colorized, and nothing local to drawZonePass can reach it
U.log((" %s draws its overlays raw, so a mismatch here is the known"
.. " open half, not this fix failing"):format(label))
else
if not same then mismatched[#mismatched + 1] = label end
check(label .. " box paper matches the field behind it", same)
end
U.shot(game, DIR .. "/bug822_" .. m .. ".png")
end
check("no forced-mono or ADVANCED/OG RED mode left the box a different color"
.. " from the field", #mismatched == 0)
if #mismatched > 0 then
U.log(" mismatched on:", table.concat(mismatched, ", "))
end
-- ---- over to you --------------------------------------------------------
PaletteFX.setMode("classic")
game.save.options.colors = "classic"
U.wait(20)
U.log("You are looking at a YES/NO box sitting on a wild RATTATA battle in")
U.log("CLASSIC. The paper inside the box and the empty field around the mons")
U.log("should be the one same pea green, with no seam where the box begins;")
U.log("press 2 through the ladder and OG INV should go black-on-black the same")
U.log("way, while OG, OG RED and ADVANCED look exactly as they always did.")
U.log("The near miss is a box that is only slightly lighter than the field --")
U.log("that is the old one-bucket slip, not a border. SGB and SGB INV still")
U.log("show a white box over a tinted field; that half of #822 is open.")
U.log("Shots: " .. DIR .. "/bug822_*.png. A or B answers the box and it goes.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,286 @@
-- Driver for #847: slowed Cities1 (scripts/ChampionsRoom.asm:112 farcall
-- Music_Cities1AlternateTempo, audio/alternate_tempo.asm), the scripted walk
-- over the rival (home/overworld.asm CollisionCheckOnLand) and the back-pic
-- sweep (engine/movie/hall_of_fame.asm HoFShowMonOrPlayer). Never add
-- POKEPORT_SPEED here: it scales the logic clock only and audio is the test.
-- POKEPORT_DRIVER=tests/drivers/champion_alt_tempo_bug847_test.lua POKEPORT_IDENTITY=hof847 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local Sprites = require("src.pokemon.Sprites")
local Music = require("src.core.Music")
local ChipSynth = require("src.core.ChipSynth")
local Commands = require("src.script.Commands")
local HallOfFame = require("src.ui.HallOfFame")
local failures = 0
local function check(label, ok)
if not ok then failures = failures + 1 end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- audio/music/cities1.asm: Music_Cities1_Ch1 opens `tempo 144`, the
-- Music_Cities1_Ch1_AlternateTempo stub `tempo 232` (macros/scripts/audio.asm
-- emits db HIGH(x), LOW(x), so these are the literal engine values).
local NORMAL_TEMPO, ALT_TEMPO = 144, 232
local SONG = "Music_Cities1"
-- ---------------------------------------------------------------
-- party + options
-- ---------------------------------------------------------------
local SPECIES = { "CHARIZARD", "SNORLAX", "PIKACHU" }
game.save.party = {}
for i, name in ipairs(SPECIES) do
game.save.party[i] = Pokemon.new(game.data, name, 100 - (i - 1) * 27)
end
game.save.player.name = "BRYAN"
local options = game.save.options
check("sfxVol is non-zero, so the cries are audible", (options.sfxVol or 0) > 0)
check("musicVol is non-zero, so the tempo swap is audible",
(options.musicVol or 0) > 0)
-- ---------------------------------------------------------------
-- machine checks: the parts an ear cannot separate from a bad build
-- ---------------------------------------------------------------
-- read the live registry, so a mod's map_scripts contribution is inspected
local mapScripts = require("data.scripts.init")
local champ = mapScripts.get("CHAMPIONS_ROOM")
local rows = champ and champ.talk and champ.talk.TEXT_CHAMPIONSROOM_RIVAL
check("CHAMPIONS_ROOM keeps its rival script", type(rows) == "table")
rows = rows or {}
local iFade, iWait, iCue, iWalk, iWarp, cueOpts
for i, row in ipairs(rows) do
if row[1] == "fade_music" and not iFade then
iFade = i
elseif row[1] == "wait" and iFade and not iWait then
iWait = i
elseif row[1] == "play_music" and row[2] == SONG and not iCue then
iCue, cueOpts = i, row[3]
elseif row[1] == "move_player" and row[2] == "up" and not iWarp then
iWalk = i
elseif row[1] == "warp" and row[2] == "HALL_OF_FAME" then
iWarp = iWarp or i
end
end
check("the script fades the battle theme out before Cities1 (#847)",
iFade ~= nil and iCue ~= nil and iFade < iCue)
check("it waits out the fade, like the ld c, 100 / call DelayFrames",
iWait ~= nil and iWait > iFade and iWait < iCue
and (rows[iWait or 1][2] or 0) >= 100)
check("the Cities1 cue carries the alternate tempo " .. ALT_TEMPO,
type(cueOpts) == "table" and cueOpts.tempo == ALT_TEMPO)
check("it still walks the player out before the warp (#704)",
iWalk ~= nil and iWarp ~= nil and iWalk < iWarp)
check("Commands.fade_music exists for that first row",
type(Commands.fade_music) == "function")
-- the tempo has to survive the song's own `tempo` command: without the
-- override the body's 144 wins and Cities1 plays as the ordinary town theme
local def = game.data.audio and game.data.audio.songs
and game.data.audio.songs[SONG]
check(SONG .. " is in the extracted song table", type(def) == "table")
if type(def) == "table" then
local slowed = {}
for k, v in pairs(def) do slowed[k] = v end
slowed.tempo = ALT_TEMPO
local okAlt, alt = pcall(ChipSynth.newEngine, game.data, slowed,
{ allowLoops = true })
local okPlain, plain = pcall(ChipSynth.newEngine, game.data, def,
{ allowLoops = true })
check("an overridden song header starts locked at " .. ALT_TEMPO,
okAlt and alt and alt.tempo == ALT_TEMPO and alt.tempoLocked == true)
check("a plain header is left unlocked, free to take its own tempo "
.. NORMAL_TEMPO,
okPlain and plain and not plain.tempoLocked)
end
-- HoFShowMonOrPlayer loads a back pic for every party member and for the
-- player; a missing key would silently draw nothing during the sweep
for _, name in ipairs(SPECIES) do
local path = Sprites.path(game.data, name, "back", { kind = "hof" })
check(name .. " resolves a back pic for the induction",
type(path) == "string" and path ~= "")
end
local playerBack = Sprites.playerPath(game.data, "back", { kind = "hof" })
check("the player resolves RedPicBack for the closing sweep",
type(playerBack) == "string" and playerBack ~= "")
-- ---------------------------------------------------------------
-- stand where ChampionsRoomPlayerEntersScript leaves the player
-- ---------------------------------------------------------------
-- pokered data/maps/objects/ChampionsRoom.asm: CHAMPIONSROOM_RIVAL at (4,2),
-- both HALL_OF_FAME warps on row 0, and RivalEntrance_RLEMovement (up 1,
-- right 1, up 3) from warp 1 lands the player at (4,3), facing the rival.
local STAND = { x = 4, y = 3 }
U.teleport(game, "CHAMPIONS_ROOM", STAND.x, STAND.y, "up")
U.wait(20)
local ow = game.overworld
local rival
for _, npc in ipairs(ow and ow.npcs or {}) do
if npc.def and npc.def.name == "CHAMPIONSROOM_RIVAL" then rival = npc end
end
check("the rival object is on the map", rival ~= nil)
if rival and (ow.player.cellX ~= rival.cellX
or ow.player.cellY ~= rival.cellY + 1) then
-- a map edit or a mod moved the object: stand on any free walkable
-- neighbour instead of facing a wall. {dx, dy, facing} is the offset from
-- the rival to the stand cell plus the direction that looks back at him.
local sides = {
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
}
for _, s in ipairs(sides) do
local cx, cy = rival.cellX + s[1], rival.cellY + s[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log(("(%d, %d) is not free; standing on"):format(STAND.x, STAND.y),
cx, cy, "facing", s[3])
U.teleport(game, "CHAMPIONS_ROOM", cx, cy, s[3])
U.wait(10)
ow = game.overworld
for _, npc in ipairs(ow.npcs or {}) do
if npc.def and npc.def.name == "CHAMPIONSROOM_RIVAL" then
rival = npc
end
end
break
end
end
end
-- run the tail of the script, from after the rival battle, so nobody has to
-- win OPP_RIVAL3 to hear this. Only rows before that point carry jump
-- targets, so the slice needs no reindexing -- assert that.
local from
for i, row in ipairs(rows) do
if row[1] == "show_text"
and row[2] == "_ChampionsRoomRivalAfterBattleText" then
from = i
break
end
end
check("found the post-battle row to start from", from ~= nil)
local slice, jumpy = {}, false
for i = from or 1, #rows do
local row = rows[i]
if row[1] == "jump" or row[1] == "jump_if_true"
or row[1] == "jump_if_false" then
jumpy = true
end
slice[#slice + 1] = row
end
check("the tail of the script has no jump targets to reindex", not jumpy)
if failures > 0 then
U.log("stopping before the cutscene:", failures,
"check(s) failed above, so what you would hear means nothing")
while true do coroutine.yield() end
end
-- watch the cues the script actually issues: on speakers a dedupe that ate
-- the restart and a fade that never fired sound like the same nothing
local realPlay, realFade = Music.play, Music.fadeOut
local cues, faded = {}, false
Music.play = function(data, song, loop, ctx)
cues[#cues + 1] = { song = song, tempo = ctx and ctx.tempo }
return realPlay(data, song, loop, ctx)
end
Music.fadeOut = function(control)
faded = true
return realFade(control)
end
ow:queueScript(slice, { npc = rival })
-- ---------------------------------------------------------------
-- the walk out, over the rival's cell
-- ---------------------------------------------------------------
local startY = ow.player.cellY
local minY, sharedCell, walkShot = startY, false, false
local hof
for i = 1, 6000 do
local top = game.stack:top()
if getmetatable(top) == HallOfFame or (top and top.drawMonInfo) then
hof = top
break
end
local w = game.overworld
if w and w.map and w.map.id == "CHAMPIONS_ROOM" and rival then
local px, py = w.player.cellX, w.player.cellY
if py < minY then minY = py end
if px == rival.cellX and py == rival.cellY then
sharedCell = true
if not walkShot then
walkShot = U.shot(game, DIR .. "/bug847_over_rival.png")
end
end
end
if i % 6 == 0 then U.tap(game, "a") else U.wait(1) end
end
Music.play, Music.fadeOut = realPlay, realFade
check("the battle theme was faded, not cut", faded)
local altCue
for _, c in ipairs(cues) do
if c.song == SONG and c.tempo == ALT_TEMPO then altCue = c end
end
check("Cities1 was restarted at the alternate tempo, not deduped away",
altCue ~= nil)
check("the player walked out of the room before the warp (#704)",
minY < startY)
-- CollisionCheckOnLand skips its checks while wSimulatedJoypadStatesIndex is
-- non-zero, so passing through (4,2) is the original behavior, not a clip
check("the scripted walk passed through the rival's cell (#847, not a bug)",
sharedCell)
check("walk-over screenshot", walkShot)
check("the induction started", hof ~= nil)
if not hof then
while true do coroutine.yield() end
end
-- ---------------------------------------------------------------
-- the back-pic sweep ahead of the first front pic
-- ---------------------------------------------------------------
check("the induction opens on the back pic sweep, at the right edge",
hof.phase == "back" and (hof.scrollX or 0) > 96)
local sweepShot = false
for _ = 1, 400 do
if hof.phase ~= "back" then break end
if not sweepShot and (hof.scrollX or 0) <= 56 then
sweepShot = U.shot(game, DIR .. "/bug847_back_sweep.png")
end
U.wait(1)
end
check("back sweep screenshot", sweepShot)
check("the front pic phase follows the sweep, entering from the left",
hof.phase == "mons" and (hof.scrollX or 0) < 0)
for _ = 1, 200 do
if (hof.scrollX or 96) > 8 then break end
U.wait(1)
end
check("front scroll screenshot", U.shot(game, DIR .. "/bug847_front.png"))
for _ = 1, 400 do
if hof.phase == "mons" and (hof.scrollX or 0) >= 96 then break end
U.wait(1)
end
check("the front pic settles at hlcoord (12,5)", (hof.scrollX or 0) == 96)
U.log(failures == 0 and "all checks passed" or ("FAILURES: " .. failures))
U.log("input is yours now; the rest of the party and the player's own page")
U.log("follow on their own, so just watch and listen.")
U.log("after the rival's last line the battle music should fade out over")
U.log("about a second, go quiet for another second and a half, and then")
U.log("Pewter City comes back noticeably slower and heavier than it sounds")
U.log("in town -- and it stays that slow across the warp until the hall of")
U.log("fame theme takes over. For each mon a back sprite sweeps right to")
U.log("left low on the screen, then the front sprite slides in from the")
U.log("left and only cries once it stops.")
U.log("the near miss to listen for: Cities1 at its ordinary town tempo, or")
U.log("cutting in with no gap -- that is the old behavior, not the fix.")
U.log("the other near miss: a cry that fires while a sprite is still moving.")
while true do
coroutine.yield()
end
end
@@ -226,6 +226,18 @@ return function(game)
-- ---------------------------------------------------------------
-- mid scroll: .ScrollPic nudges hSCX 4px a frame, and the exemption has to
-- travel with the pic instead of sitting at its resting column (#637)
-- #847: HoFShowMonOrPlayer sweeps the BACK pic across the screen (low, at
-- y=88) before the front pic scrolls in. Catch it mid-sweep, wait the
-- sweep out, then catch the front pic partway through its own scroll.
for _ = 1, 300 do
if hof.phase ~= "back" or (hof.scrollX or 0) <= 56 then break end
U.wait(1)
end
check("back-pic sweep screenshot", U.shot(game, DIR .. "/hof847_back.png"))
for _ = 1, 300 do
if hof.phase ~= "back" then break end
U.wait(1)
end
for _ = 1, 200 do
if (hof.scrollX or PIC_X) > 8 then break end
U.wait(1)
+262
View File
@@ -0,0 +1,262 @@
-- The super effective / not very effective hit sounds played at the wrong
-- pitch, so the weak-sounding hit landed on the weakness (#826). pokered
-- PlayApplyingAttackSound (engine/battle/animations.asm) sets
-- wFrequencyModifier with the sound, and audio/engine_2.asm
-- Audio2_ApplyFrequencyModifier adds it to the noise channel's polynomial
-- counter. Ears only, so never under POKEPORT_SPEED -- the pitch is the test.
-- POKEPORT_DRIVER=tests/drivers/hit_sfx_bug826_test.lua POKEPORT_IDENTITY=bug826 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local ChipSynth = require("src.core.ChipSynth")
local TypeChart = require("src.battle.TypeChart")
local Sound = require("src.core.Sound")
-- GOLEM is ROCK/GROUND, so one attacker covers both ends of the routine
-- with no switching: WATER_GUN is 2x on each type and EMBER is 0.5x on
-- ROCK. SPLASH on the foe keeps the lead alive for as many replays as
-- the listener wants.
local FOE, FOE_LEVEL = "GOLEM", 60
local LEAD, LEAD_LEVEL = "BULBASAUR", 25
local WEAK_MOVE, STRONG_MOVE = "EMBER", "WATER_GUN"
-- PlayApplyingAttackSound's wFrequencyModifier per sound, and the
-- polynomial-counter byte of each program's first note (audio/sfx/
-- {damage,super_effective,not_very_effective}.asm) before and after it.
local SOUNDS = {
{ name = "Damage", pitch = 0x20, raw = 0x44, want = 0x64 },
{ name = "Super_Effective", pitch = 0xe0, raw = 0x34, want = 0x14 },
{ name = "Not_Very_Effective", pitch = 0x50, raw = 0x55, want = 0xa5 },
}
local pass, fail = 0, 0
local function check(label, ok)
if ok then pass = pass + 1 else fail = fail + 1 end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function hex(v) return v and ("$%02x"):format(v) or "nil" end
-- ---- data the moment depends on ----------------------------------------
local sfx = game.data.audio and game.data.audio.sfx or {}
for _, row in ipairs(SOUNDS) do
local def = sfx[row.name]
check(row.name .. " resolves to a chip program in the generated audio",
type(def) == "table" and def.address ~= nil and def.bank ~= nil)
end
local moveWeak, moveStrong = game.data.moves[WEAK_MOVE], game.data.moves[STRONG_MOVE]
local foeDef = game.data.pokemon[FOE]
check(WEAK_MOVE .. " and " .. STRONG_MOVE .. " resolve in the move table",
moveWeak ~= nil and moveStrong ~= nil)
check(FOE .. " resolves with a dual type", foeDef ~= nil and #foeDef.types == 2)
local weakMult, strongMult
if moveWeak and moveStrong and foeDef then
weakMult = TypeChart.effectiveness(moveWeak.type, foeDef.types)
strongMult = TypeChart.effectiveness(moveStrong.type, foeDef.types)
U.log(("%s on %s is x%.1f, %s is x%.1f (the x10 scale Damage.lua uses)")
:format(WEAK_MOVE, FOE, weakMult / 10, STRONG_MOVE, strongMult / 10))
end
check(WEAK_MOVE .. " is the resisted side of the pair", (weakMult or 10) < 10)
check(STRONG_MOVE .. " is the super effective side", (strongMult or 10) > 10)
local vol = game.save.options and game.save.options.sfxVol
check("sfx volume is up (" .. tostring(vol) .. "/7)", (vol or 0) > 0)
if (vol or 0) == 0 then
U.log("with sfxVol 0 both hits are silent and this run proves nothing;",
"raise it in OPTION and start over")
end
-- ---- the synth half: does the modifier reach the noise channel? ---------
-- Sample each program once at offset 0 and again at its own modifier. A
-- port that drops wFrequencyModifier reports the same byte twice, which is
-- the whole of #826: unpitched, Super_Effective ends duller than
-- Not_Very_Effective ends.
local function firstNoise(header, offset)
if not header then return nil end
local engine = ChipSynth.newEngine(game.data, header, {
sfx = true, allowLoops = false, frequencyOffset = offset,
})
for _, channel in ipairs(engine.channels) do
channel:sample()
local event = channel.event
if event and event.noiseParameter then return event.noiseParameter end
end
return nil
end
for _, row in ipairs(SOUNDS) do
local bare = firstNoise(sfx[row.name], 0)
local pitched = firstNoise(sfx[row.name], row.pitch)
check(("%s reads NR43 %s unmodified, as in the asm")
:format(row.name, hex(row.raw)), bare == row.raw)
check(("...and %s once %s is applied"):format(hex(row.want), hex(row.pitch)),
pitched == row.want)
U.log(("%s: %s -> %s, shift clock %d -> %d (higher shift = duller)")
:format(row.name, hex(bare), hex(pitched),
math.floor((bare or 0) / 16), math.floor((pitched or 0) / 16)))
end
-- ---- the battle half: what does a hit row actually carry? ---------------
-- Offscreen scratch turn, no animation timing in the way. The row has to
-- name the sound AND its modifier byte, and must not carry a tempo byte:
-- Audio2_note_length skips Audio2_SetSfxTempo on CHAN8 (`cp CHAN8 / jr z,
-- .skip`), so the hardware never retimes these three.
-- the move is handed in whole, so the party lead keeps both its slots for
-- the live battle below
local function rowSfx(moveId)
local scratch = BattleState.newWild(game, FOE, FOE_LEVEL)
scratch.onFinish = function() end
-- Damage.accuracyRoll is `rng(0, 255) < acc` (src/battle/Damage.lua:105),
-- so the roll has to be pinned LOW to guarantee a hit. Pinning it high
-- misses every time, the row never gets a .hit, and this scan reads nil.
scratch.rng = function(lo) return lo end
scratch:performMove(scratch.player, scratch.enemy, { id = moveId, pp = 20 })
for _, row in ipairs(scratch.queue) do
if row.hit and row.hit.sfx then return row.hit.sfx end
end
return nil
end
do
local lead = Pokemon.new(game.data, LEAD, LEAD_LEVEL)
lead.moves = {
{ id = WEAK_MOVE, pp = 25, maxPP = 25 },
{ id = STRONG_MOVE, pp = 25, maxPP = 25 },
}
game.save.party = { lead }
for _, case in ipairs({
{ move = STRONG_MOVE, want = "Super_Effective", pitch = 0xe0 },
{ move = WEAK_MOVE, want = "Not_Very_Effective", pitch = 0x50 },
}) do
local row = rowSfx(case.move)
check(case.move .. " queues a hit sound with its modifier",
type(row) == "table" and row.sound == case.want
and row.pitch == case.pitch)
check("...and no tempo byte, the way CHAN8 ignores one",
type(row) == "table" and row.tempo == nil)
U.log(("%s -> %s pitch %s"):format(case.move,
type(row) == "table" and tostring(row.sound) or tostring(row),
type(row) == "table" and hex(row.pitch) or "nil"))
end
end
-- ---- reach the moment ---------------------------------------------------
-- ROUTE_1 is open field (data/generated/maps.lua ROUTE_1); the stand cell
-- is read back off the loaded map, and a map edit degrades to the first
-- free cell instead of dropping the player into a wall.
U.teleport(game, "ROUTE_1", 5, 5, "down")
U.wait(10)
local map = game.overworld.map
if not map:isWalkableCell(5, 5) then
local fx, fy
for cy = 0, map.heightCells - 1 do
for cx = 0, map.widthCells - 1 do
if map:isWalkableCell(cx, cy) then fx, fy = cx, cy break end
end
if fx then break end
end
if fx then
U.log("cell (5, 5) is not walkable, standing on", fx, fy)
U.teleport(game, "ROUTE_1", fx, fy, "down")
U.wait(10)
end
end
local ow = game.overworld
check("player stands on a walkable ROUTE_1 cell",
ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY))
-- listen in on the real playback path so the log can tell "the fix is not
-- wired to the battle" from "the fix is wired but you did not like it"
local heard = {}
local realPlayMove = Sound.playMove
Sound.playMove = function(data, anim)
if type(anim) == "table" and anim.sound then
for _, row in ipairs(SOUNDS) do
if anim.sound == row.name then
heard[#heard + 1] = { sound = anim.sound, pitch = anim.pitch }
end
end
end
return realPlayMove(data, anim)
end
local function mashUntil(cond, max)
for _ = 1, max or 160 do
if cond() then return true end
U.tap(game, "a")
U.wait(4)
end
return cond()
end
local function newFight()
local battle = BattleState.newWild(game, FOE, FOE_LEVEL)
battle.onFinish = function(result) ow:afterBattle(result, battle) end
-- SPLASH so the foe's turn cannot end the run, or drown the hit under a
-- damage sound of its own
battle.enemy.mon.moves = { { id = "SPLASH", pp = 40, maxPP = 40 } }
battle.enemy.curMoves = battle.enemy.mon.moves
ow:pushBattle(battle)
U.wait(220) -- the send-out intro plays before the menu is reachable
mashUntil(function() return battle.phase == "menu" end)
return battle
end
local battle = newFight()
check("the wild " .. FOE .. " battle reached its FIGHT menu",
battle.phase == "menu")
U.shot(game, DIR .. "/bug826_menu.png")
-- slot 1 first: the resisted hit, so the pair is heard weak then strong
local function swing(slotDown, label)
local before = #heard
U.tap(game, "a") -- FIGHT
U.wait(16)
if slotDown then U.tap(game, "down"); U.wait(8) end
U.tap(game, "a")
for frame = 1, 1200 do
if battle.phase == "menu" and #battle.queue == 0 and not battle.draining then
break
end
if not battle.draining and frame % 8 == 0 then U.tap(game, "a") end
U.wait(1)
end
local row = heard[before + 1]
U.log(("%s played %s at pitch %s"):format(label,
row and row.sound or "nothing",
row and hex(row.pitch) or "nil"))
return row
end
local weakHeard = swing(false, WEAK_MOVE)
U.shot(game, DIR .. "/bug826_not_very_effective.png")
local strongHeard = swing(true, STRONG_MOVE)
U.shot(game, DIR .. "/bug826_super_effective.png")
check("the resisted hit reached the mixer as Not_Very_Effective $50",
weakHeard ~= nil and weakHeard.sound == "Not_Very_Effective"
and weakHeard.pitch == 0x50)
check("the super effective hit reached it as Super_Effective $e0",
strongHeard ~= nil and strongHeard.sound == "Super_Effective"
and strongHeard.pitch == 0xe0)
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
-- ---- hand the pad over --------------------------------------------------
Sound.playMove = realPlayMove
if battle.phase ~= "menu" then
U.log("(the menu did not come back on its own: mash A to reach FIGHT)")
end
U.log("Both hits have already sounded once. GOLEM is still standing and")
U.log("EMBER and WATER_GUN sit in slots 1 and 2, so play them back to back")
U.log("as often as you like.")
U.log("WATER_GUN, under \"It's super effective!\", should be the brighter")
U.log("and sharper of the two -- a high crack. EMBER, under \"It's not very")
U.log("effective...\", should be a low dull rumble underneath it.")
U.log("The near miss to listen for: the two are close in brightness, or the")
U.log("crack lands on EMBER and the thud on WATER_GUN. That is the modifier")
U.log("going missing again, and it is what #826 sounded like.")
U.log("The neutral hit changed too: any move that is neither, on any foe,")
U.log("is now a shade duller than it used to be, and that is correct.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,172 @@
-- Manual check that a Pikachu taking the field says the short "Pika!" (#837).
-- pokeyellow engine/battle/core.asm SendOutMon .starterPikachu (:1807-1817)
-- voices PikachuCry11, or PikachuCry37 when IsPlayerPikachuAsleepInParty; the
-- port called PlayCry bare and got clip 1, the long title "Pikachuuu"
-- (engine/movie/title.asm:146). Never add POKEPORT_SPEED here: it scales the
-- logic clock and not audio, so the cries stop lining up with what you see.
-- POKEPORT_DRIVER=tests/drivers/pika_entrance_cry_bug837_test.lua POKEPORT_IDENTITY=bug837 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Sound = require("src.core.Sound")
local GameVersion = require("src.core.GameVersion")
local BattleState = require("src.battle.BattleState")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function idle()
while true do coroutine.yield() end
end
-- Red and Blue carry no PCM clips at all (RomExtractor extractPikachuCries
-- only runs on Yellow), so playPikaCry returns nil there and every Pikachu
-- keeps its chip cry from CryData: nothing below is observable off Yellow.
local audio = game.data.audio
local clips = audio and audio.pikaCries
local onYellow = check("running Yellow", GameVersion.isYellow())
local haveClips = check("the cache carries the PCM clip set",
type(clips) == "number")
if not (onYellow and haveClips) then
U.log("Import a Yellow ROM and rerun with POKEPORT_VERSION=yellow. On Red")
U.log("and Blue there is no voiced Pikachu to get wrong.")
idle()
end
-- NUM_PIKA_CRIES is 42 (pokeyellow constants/music_constants.asm), and the
-- importer writes cry_01..cry_42.wav in PikachuCriesPointerTable order, so
-- clip 37 existing is what makes the asleep case reachable at all.
check("clip 37 fits inside the " .. tostring(clips) .. " clips extracted",
clips >= 37)
-- resolve the two asset keys for real: a missing or unreadable wav makes
-- playPikaCry return nil and the cry falls through to the chip cry, which
-- is a different wrong sound from the one this issue is about. Stopped in
-- the same frame, so neither is audible here.
local function resolves(n)
local src = Sound.playPikaCry(game.data, n)
if src then src:stop() end
return src ~= nil
end
check("pika_cries/cry_11.wav loads", resolves(11))
check("pika_cries/cry_37.wav loads", resolves(37))
local opts = game.save.options or {}
check("sfxVol is not muted (it reads " .. tostring(opts.sfxVol) .. ")",
(opts.sfxVol or 0) > 0)
check("PIKACHU VOL is not muted (it reads " .. tostring(opts.pikaVol) .. ")",
(opts.pikaVol or 0) > 0)
-- Sound.playPikaCry emits "sound.played" with name = "PIKACHU_PCM_<n>";
-- this is the same feed mods read and tests/mod_audio_tests.lua subscribes
-- to, so the number below is the clip the engine actually asked for.
local heardCries = {}
local events = game.mods and game.mods.events
if not check("the sound.played feed is live", events ~= nil and events.on ~= nil) then
idle()
end
events:on("sound.played", function(p)
if p and p.kind == "cry" then heardCries[#heardCries + 1] = p.name end
end, nil, "bug837driver")
game.save.party = {
Pokemon.new(game.data, "PIKACHU", 20),
Pokemon.new(game.data, "CHARMANDER", 20),
}
game.save.player.name = "RED"
check("PIKACHU leads the party", game.save.party[1].species == "PIKACHU")
-- Route 1 so the handoff below has tall grass in reach. The route sign
-- sits at (9, 27) (pokered data/maps/objects/Route1.asm bg_event), and the
-- cell under it is the open path you read it from.
local MAP = "ROUTE_1"
local STAND = { x = 9, y = 28, facing = "up" }
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
local ow = game.overworld
if not check("the overworld is up on " .. MAP, ow ~= nil) then idle() end
-- a map edit or a mod can wall that cell off; widen out to any free
-- walkable neighbour rather than stranding the player inside scenery
local function freeNear(map, x, y)
for r = 1, 6 do
for dy = -r, r do
for dx = -r, r do
local cx, cy = x + dx, y + dy
if map:inBounds(cx, cy) and map:isWalkableCell(cx, cy)
and not ow:npcAtCell(cx, cy) then
return cx, cy
end
end
end
end
return nil
end
if not ow.map:isWalkableCell(STAND.x, STAND.y) then
local cx, cy = freeNear(ow.map, STAND.x, STAND.y)
if cx then
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
cx, cy)
U.teleport(game, MAP, cx, cy, STAND.facing)
U.wait(10)
ow = game.overworld
end
end
check("the player is standing somewhere walkable",
ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY))
-- Push the encounter rather than walking into grass: the cry under test is
-- the player's own send-out, and a stepped encounter would put the wild
-- rolls and a second cry ahead of it. RATTATA keeps the enemy's cry a chip
-- cry, so it can never be confused with the PCM clip being checked.
local function runEntrance(asleep, label, shotPath)
for i = #heardCries, 1, -1 do heardCries[i] = nil end
game.save.party[1].status = asleep and "SLP" or nil
local wild = BattleState.newWild(game, "RATTATA", 3)
wild.onFinish = function() end
game.overworld:pushBattle(wild)
local pcm
for _ = 1, 500 do
for _, name in ipairs(heardCries) do
if name:find("PIKACHU_PCM_", 1, true) == 1 then pcm = name end
end
if pcm then break end
U.tap(game, "a")
U.wait(3)
end
U.log(label .. " recorded:", table.concat(heardCries, ", "))
if shotPath then U.shot(game, shotPath) end
return pcm, wild
end
-- asleep first, so the run ends on the everyday case and what is ringing
-- during the handoff is the clip the issue is really about
local slept = runEntrance(true, "asleep send-out",
DIR .. "/bug837_1_asleep.png")
check("an asleep PIKACHU is sent out with PCM clip 37 (PikachuCry37)",
slept == "PIKACHU_PCM_37")
U.teleport(game, MAP, ow.player.cellX, ow.player.cellY, STAND.facing)
U.wait(10)
local awake = runEntrance(false, "awake send-out",
DIR .. "/bug837_2_awake.png")
check("a healthy PIKACHU is sent out with PCM clip 11 (PikachuCry11)",
awake == "PIKACHU_PCM_11")
check("clip 1, the long title-screen cry, is not what was played",
awake ~= "PIKACHU_PCM_1")
U.log("You are in the second battle, the one with PIKACHU awake. The cry")
U.log("as it grew out of the ball should be the short bright \"Pika!\", the")
U.log("same one you hear pressing START on the Yellow title screen. The bug")
U.log("played the other title cry instead: the long drawn-out \"Pikachuuu\"")
U.log("that opens the title, roughly a second and a half of it, which is")
U.log("easy to miss as merely slow rather than wrong. Run away and walk")
U.log("into the grass north of here for as many more send-outs as you like;")
U.log("put PIKACHU to sleep and it turns into the sleepy clip 37 instead.")
U.log("Screenshots of both entrances are in " .. DIR .. ".")
idle()
end
@@ -0,0 +1,219 @@
-- Animation-row SFX must obey PlaySound's channel-occupancy gate (#844).
--
-- Blizzard's animation is two rows -- `battle_anim BLIZZARD, ...` then
-- `battle_anim HYDRO_PUMP, ...` (data/moves/animations.asm, BlizzardAnim) --
-- and PlaySubanimation issues a PlaySound for every row
-- (engine/battle/animations.asm, PlaySubanimation). The extracted data and
-- the row timing are both faithful; what the port was missing is that the
-- original never actually starts that second sound. Audio2_PlaySound's
-- .playSfx/.sfxChannelLoop (audio/engine_2.asm) walks the channels the new
-- sfx declares and, for each one already busy, does
-- `ld a,[wSoundID] / cp [hl] / jr z,.playChannel / jr c,.playChannel / ret`:
-- a channel held by a LOWER sound id aborts the whole request, while an
-- equal or lower id takes the channel over (and .playChannel resets the
-- channel, cutting the old sound off). SFX_BATTLE_29 (BLIZZARD, CHAN5+8) is
-- still sounding when the HYDRO_PUMP row starts, and SFX_BATTLE_2A wants
-- CHAN5+6+8, so on hardware it is dropped outright. Unguarded, the port
-- layered it and its watery tail outlived the animation.
--
-- Sound ids order by header address: `DEF \1 EQUS "((\2 - SFX_Headers_1) / 3)"`
-- (constants/music_constants.asm, music_const), so a def's `address` is the
-- comparable rank inside one engine bank -- which is what Sound.playMove
-- compares and what ChipSynth.effectChannels supplies the channel set for.
--
-- ROM-free: ChipAsm blobs stand in for the sfx headers, so nothing here
-- reads data/generated/.
-- luajit tests/engine/move_sfx_channel_gate_bug844.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = require("tests.love_stub")
-- ------- love.audio stub
-- tests/love_stub carries no love.audio (headless suites never play), and
-- the gate reads Source:isPlaying on the previously accepted row sound.
-- These stub sources never finish on their own, which is exactly the
-- "previous sfx is still sounding" state a mid-animation row sees.
local sources = {}
local Source = {}
Source.__index = Source
function Source:play() self.playing = true; self.plays = self.plays + 1 end
function Source:stop() self.playing = false end
function Source:isPlaying() return self.playing end
function Source:pause() self.playing = false end
function Source:setLooping(value) self.looping = value end
function Source:setVolume(value) self.volume = value end
function Source:setPitch(value) self.pitch = value end
function Source:setFilter() end
function Source:getDuration() return 1 end
love.audio = {
newSource = function(what, mode)
local src = setmetatable({
file = what, mode = mode, plays = 0, playing = false,
}, Source)
sources[#sources + 1] = src
return src
end,
}
local ChipAsm = require("src.audio.ChipAsm")
local ChipSynth = require("src.core.ChipSynth")
local Sound = require("src.core.Sound")
local Runtime = require("src.mods.Runtime")
-- ------- sfx fixtures
-- One audible note per channel. ChipAsm.sfx numbers effect channels hw+4,
-- so hw 1/2/4 assemble as CHAN5/CHAN6/CHAN8 -- the same software channels
-- the real sfx headers claim. `address` is the sound-id rank and `engine`
-- names the bank the rank is comparable within.
local function sfxDef(address, hws)
local channels = {}
for _, hw in ipairs(hws) do
local program
if hw == 4 then
program = { { noiseNote = { len = 8, volume = 15, fade = 1,
parameter = 0x11 } } }
else
program = { { squareNote = { len = 8, volume = 15, fade = 1,
frequency = 0x600 } } }
end
channels[#channels + 1] = { hw = hw, program = program }
end
local def = ChipAsm.sfx{ channels = channels }
def.address, def.engine = address, 2
return def
end
-- Battle_29 = CHAN5,8 at rank 16975; Battle_2A = CHAN5,6,8 at 16981. The
-- ranks below are the same ordering, scaled small for readability.
local defs = {
Blizzard_Sfx = sfxDef(100, { 1, 4 }), -- SFX_BATTLE_29 shape
HydroPump_Sfx = sfxDef(106, { 1, 2, 4 }), -- SFX_BATTLE_2A shape
Disable_Sfx = sfxDef(100, { 4 }), -- SFX_BATTLE_1B shape: CHAN8
Leer_Sfx = sfxDef(106, { 1, 2 }), -- SFX_BATTLE_31 shape: CHAN5,6
Loud_Sfx = sfxDef(106, { 1, 4 }), -- a high-ranked incumbent
Quiet_Sfx = sfxDef(100, { 1 }), -- a lower id that takes over
Unranked_Sfx = ChipAsm.sfx{ channels = { { hw = 1, program = {
{ squareNote = { len = 8, volume = 15, fade = 1, frequency = 0x600 } },
} } } }, -- a mod def: no header address
}
local data = { audio = { sfx = defs, cries = {}, songs = {} } }
-- the gate is only observable through what actually started, so watch the
-- Runtime event the mod SDK exposes for exactly that
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
local events = require("src.mods.Events").new()
Runtime.install(events, require("src.mods.Hooks").new())
local played = {}
events:on("sound.played", function(p) played[#played + 1] = p end, nil, "test")
local function reset()
Sound.invalidate() -- also clears the tracked row sound
for index = #sources, 1, -1 do sources[index] = nil end
for index = #played, 1, -1 do played[index] = nil end
end
local function playMove(name)
Sound.playMove(data, { sound = name, pitch = 0, tempo = 0x80 })
end
local function names()
local out = {}
for _, p in ipairs(played) do out[#out + 1] = p.name end
return table.concat(out, ",")
end
-- ------- the channel sets the gate reads
eq(table.concat(ChipSynth.effectChannels(data, defs.Blizzard_Sfx), ","),
"5,8", "effectChannels reads CHAN5+8 off the Blizzard-shaped header")
eq(table.concat(ChipSynth.effectChannels(data, defs.HydroPump_Sfx), ","),
"5,6,8", "effectChannels reads CHAN5+6+8 off the Hydro Pump-shaped header")
check(ChipSynth.effectChannels(data, "assets/beep.wav") == nil,
"a file def has no knowable channel set")
-- ------- 1. higher id + overlapping channels is dropped (the Blizzard case)
reset()
playMove("Blizzard_Sfx")
check(#sources == 1 and sources[1].playing, "the Blizzard row sound starts")
playMove("HydroPump_Sfx")
eq(#played, 1, "the second Blizzard row is dropped, not layered (" .. names() .. ")")
eq(played[1] and played[1].name, "Blizzard_Sfx",
"the sound that survives is the Blizzard row")
eq(#sources, 1, "the dropped row never even builds a source")
check(sources[1].playing, "the incumbent keeps sounding through the drop")
-- ------- 2. higher id + disjoint channels still plays (Disable/Leer)
-- The regression guard: SFX_BATTLE_1B is CHAN8 and SFX_BATTLE_31 is
-- CHAN5+6, so nothing is busy and both sounds are heard.
reset()
playMove("Disable_Sfx")
playMove("Leer_Sfx")
eq(#played, 2, "a higher id on disjoint channels is not gated (" .. names() .. ")")
eq(played[2] and played[2].name, "Leer_Sfx", "the second sound is the later row")
check(sources[1].playing and sources[2].playing,
"neither disjoint sound cuts the other off")
-- ------- 3. a lower id takes the channels over
-- .playChannel zeroes the channel state, which stops whatever held it.
reset()
playMove("Loud_Sfx")
local incumbent = sources[1]
playMove("Quiet_Sfx")
eq(#played, 2, "a lower id is allowed to start (" .. names() .. ")")
check(not incumbent.playing,
"taking CHAN5 over stops the sound that held it")
check(sources[2] and sources[2].playing, "the taking-over sound is playing")
-- ------- 4. an equal id restarts the sound
-- Repeated rows of one sound (Wrap, Metronome) must not be swallowed.
reset()
playMove("Blizzard_Sfx")
playMove("Blizzard_Sfx")
eq(#played, 2, "the same sound replayed is not gated against itself")
eq(#sources, 1, "the replay reuses the cached source")
eq(sources[1].plays, 2, "the cached source is restarted")
check(sources[1].playing, "and is sounding afterwards")
-- ------- 5. an unrankable def is left exactly as it was
-- A mod's chip sfx has no header address, so there is no comparable sound
-- id and the gate must not invent one.
reset()
playMove("Blizzard_Sfx")
playMove("Unranked_Sfx")
playMove("Unranked_Sfx")
eq(#played, 3, "unrankable defs play regardless of what is sounding ("
.. names() .. ")")
-- an unrankable def must also not become an incumbent that gates the next
-- ranked row
reset()
playMove("Unranked_Sfx")
playMove("HydroPump_Sfx")
eq(#played, 2, "an unrankable def gates nothing after it (" .. names() .. ")")
-- ------- 6. a finished sound gates nothing
reset()
playMove("Blizzard_Sfx")
sources[1].playing = false -- the incumbent ran out
playMove("HydroPump_Sfx")
eq(#played, 2, "a row sound that already ended blocks nothing")
-- ------- 7. an invalidate (hot reload / cache flush) drops the tracking
-- Sound.invalidate stops and forgets the cached sources; a stale reference
-- would gate the next row against a dead source.
reset()
playMove("Blizzard_Sfx")
Sound.invalidate()
playMove("HydroPump_Sfx")
eq(#played, 2, "invalidate clears the tracked row sound")
Runtime.install(savedEvents, savedHooks)
T.finish("move sfx channel gate (#844)")
@@ -0,0 +1,126 @@
-- Empty confirm on the naming screen (#833). DisplayNamingScreen seeds
-- wStringBuffer with '@' (engine/menus/naming_screen.asm), so a name the
-- player never typed reads back as the terminator, and every caller checks
-- that first byte: AskName falls through to .declinedNickname and copies the
-- species name over the nick slot (vanilla's "un-nicknamed", which this port
-- models as mon.nickname == nil, src/save_convert/GenSave.lua), while
-- DisplayNameRaterScreen takes .playerCancelled and keeps the old nickname.
-- Nothing in the original invents a letter, so NamingScreen:confirm must hand
-- the caller "" rather than the literal "A" when nothing was typed -- both via
-- START and via the ED cell. The two fallbacks that are load bearing stay:
-- presets[1] for player/rival naming (oak_speech2.asm ChoosePlayerName never
-- accepts an empty name) and opts.default for the Name Rater cancel.
-- luajit tests/engine/naming_empty_confirm_bug833.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
-- NamingScreen reaches for Sound at the top of the module; seeding
-- package.loaded before it loads keeps the suite ROM-free and silent.
package.loaded["src.core.Sound"] = { play = function() end }
local NamingScreen = require("src.ui.NamingScreen")
-- The three things the screen touches: a stack it pops itself off, an input
-- queue that is exactly one fixed step of edges, and a non-nil `data` for the
-- click cue.
local function newGame()
local game = { data = {} }
game.stack = {
states = {},
push = function(self, s) table.insert(self.states, s) end,
pop = function(self) return table.remove(self.states) end,
top = function(self) return self.states[#self.states] end,
}
game.input = {
queue = {},
wasPressed = function(self, btn) return self.queue[btn] or false end,
isDown = function() return false end,
}
return game
end
-- builds a pushed screen plus a `result` table the onDone writes into
local function newScreen(opts)
local game = newGame()
local result = { fired = false, name = nil }
opts = opts or {}
opts.onDone = function(n)
result.fired = true
result.name = n
end
local ns = NamingScreen.new(game, opts)
game.stack:push(ns)
return ns, game, result
end
-- one fixed step with `btn` on its edge
local function press(ns, game, btn)
game.input.queue = { [btn] = true }
ns:update(1 / 60)
game.input.queue = {}
end
-- the ED cell's coordinates on whatever grid the screen is showing
local function edCell(ns)
for r, row in ipairs(ns:grid()) do
for c, cell in ipairs(row) do
if cell == "ED" then return r, c end
end
end
return nil, nil
end
-- ---------------------------------------------------------------- START, nothing typed
-- The nickname callers (BattleState caught-mon, Commands gift/starter) push
-- the screen with only title/maxLen/onDone: no presets, no default.
local ns, game, res = newScreen({ title = "NICK?", maxLen = 10 })
press(ns, game, "start")
check(res.fired, "START confirms an untyped name")
eq(res.name, "", "START with nothing typed delivers the empty name")
check(res.name ~= "A", "an untyped confirm does not invent the letter A (#833)")
eq(#game.stack.states, 0, "confirm pops the naming screen")
-- the caller-shaped guard both nickname sites use
local mon = {}
if res.name and #res.name > 0 then mon.nickname = res.name end
check(mon.nickname == nil,
"an empty name leaves the mon un-nicknamed, so evolution can rename it")
-- ---------------------------------------------------------------- ED cell, nothing typed
ns, game, res = newScreen({ title = "NICK?", maxLen = 10 })
local edRow, edCol = edCell(ns)
eq(edRow, 5, "ED sits on row 5 of the vanilla grid (data/text/alphabets.asm)")
eq(edCol, 9, "ED is the last cell of that row")
ns.row, ns.col = edRow, edCol
press(ns, game, "a")
check(res.fired, "A on the ED cell confirms")
eq(res.name, "", "ED with nothing typed delivers the empty name too")
-- ---------------------------------------------------------------- typed names are untouched
ns, game, res = newScreen({ title = "NICK?", maxLen = 10 })
ns.row, ns.col = 1, 1 -- "A"
press(ns, game, "a")
press(ns, game, "start")
eq(res.name, "A", "a genuinely typed A still comes back as A")
-- ---------------------------------------------------------------- presets fallback (player / rival)
-- ChoosePlayerName / ChooseRivalName (engine/movie/oak_speech/oak_speech2.asm)
-- compare wStringBuffer to '@' and re-open rather than accept an empty name;
-- the port answers the same need with its presets fallback, which #833 must
-- not disturb.
ns, game, res = newScreen({ title = "YOUR NAME?", maxLen = 7, presets = { "RED", "ASH" } })
press(ns, game, "start")
eq(res.name, "RED", "an empty confirm with presets still yields presets[1]")
-- ---------------------------------------------------------------- default fallback (Name Rater)
-- DisplayNameRaterScreen jumps to .playerCancelled on '@' and keeps the
-- existing nickname; data/scripts/story4.lua passes it as opts.default.
ns, game, res = newScreen({ title = "RATTATA's name?", maxLen = 10, default = "SPLASH" })
press(ns, game, "start")
eq(res.name, "SPLASH", "an empty confirm with a default keeps the old nickname")
T.finish("naming_empty_confirm_bug833")
@@ -0,0 +1,113 @@
-- #828: launcher settings "reset" on Android and Steam Deck, with nothing in
-- the log. Every options write is a WHOLE-FILE rewrite out of the caller's
-- table (src/core/SaveData.lua saveOptions), so a filesystem that reports a
-- successful write without the bytes surviving -- an external-storage volume
-- that went away mid-session (conf.lua sets t.externalstorage on Android), a
-- read-only or full save dir -- is indistinguishable from "the launcher never
-- saved at all". saveOptions therefore reads the file back and fails loudly.
--
-- This suite pins that contract against injected filesystem stubs, the same
-- { getInfo, read, write, remove } shape tests/engine/save_slots.lua and
-- tests/engine/save_file_io_tests.lua use. It is ROM-free (T2 engine tier).
--
-- What it does NOT do: prove #828 is fixed. The launcher -> options.lua ->
-- bootGame chain already round-trips correctly on desktop, so the readback is
-- instrumentation for the two platforms that report the loss, and the real
-- verification is a platform run (see the issue). What is testable here is
-- that a silent no-op write is now reported instead of swallowed.
-- luajit tests/engine/options_write_readback_bug828.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local Logger = require("src.core.Logger")
local SaveData = require("src.core.SaveData")
local OPTIONS = "options.lua"
-- An in-memory love.filesystem stub. `mode` decides what write() does with
-- the bytes AFTER reporting success, which is the whole point of the suite:
-- "honest" -- stores them (a working save dir)
-- "drop" -- reports true, stores nothing (the volume vanished)
-- "truncate" -- reports true, stores a short prefix (a full save dir)
-- "fail" -- reports false plus an error string (the pre-existing path)
local function memfs(mode)
local files = {}
return {
files = files,
write = function(path, content)
if mode == "fail" then return false, "no space left on device" end
if mode == "drop" then return true end
if mode == "truncate" then
files[path] = tostring(content):sub(1, 16)
return true
end
files[path] = content
return true
end,
read = function(path) return files[path] end,
remove = function(path) files[path] = nil return true end,
getInfo = function(path)
if files[path] ~= nil then return { type = "file" } end
return nil
end,
}
end
-- SaveData.persistFs hands an injected fs straight back only when it differs
-- from love.filesystem, so the suite never touches the real save directory.
local function logged(pattern)
for i = #Logger.history, 1, -1 do
if Logger.history[i]:find(pattern, 1, true) then return Logger.history[i] end
end
return nil
end
-- ---- the write that lands: unchanged success contract
local fs = memfs("honest")
local saved = SaveData.saveOptions({ battleLayout = "wide" }, fs)
check(saved ~= nil, "a write that lands returns the merged options table")
eq(saved and saved.battleLayout, "wide", "the caller's key survives the merge")
eq(saved and saved.textSpeed ~= nil, true, "defaults are filled in around it")
check(fs.files[OPTIONS] ~= nil, "options.lua is written to the injected fs")
local loaded = SaveData.loadOptions(fs)
eq(loaded and loaded.battleLayout, "wide",
"loadOptions reads back what saveOptions wrote (the launcher -> game hop)")
-- ---- the write that silently does not land: the #828 failure mode
local dropMark = #Logger.history
local dropped = SaveData.saveOptions({ battleLayout = "wide" }, memfs("drop"))
eq(dropped, nil, "a write that reports success but stores nothing returns nil")
check(logged("options save did not land"),
"the vanished write is logged, so the next Android report can carry it")
check(#Logger.history > dropMark, "a log line was actually emitted")
-- ---- a partial write is just as lost, and just as loud
local truncated = SaveData.saveOptions({ battleLayout = "wide" }, memfs("truncate"))
eq(truncated, nil, "a truncated write is treated as a failed write")
check(logged("options save did not land"), "the truncated write is logged too")
-- ---- the pre-existing honest failure still behaves exactly as before
local failMark = #Logger.history
local failed = SaveData.saveOptions({ battleLayout = "wide" }, memfs("fail"))
eq(failed, nil, "a write that returns false still returns nil")
check(logged("options save failed"),
"the false-return path keeps its own distinct log line")
check(#Logger.history > failMark, "the false-return path still logs")
-- A dropped write must not be reported through the false-return message:
-- the two are different diagnoses and the platform reports need to tell
-- them apart.
local last = Logger.history[#Logger.history]
check(last and last:find("options save failed", 1, true) ~= nil,
"the last failure logged is the false-return one, not the readback one")
T.finish("options_write_readback_bug828")
+198
View File
@@ -0,0 +1,198 @@
-- A RARE CANDY used from the field bag must leave the bag open (#796).
--
-- engine/menus/start_sub_menus.asm, StartMenu_Item / .useOrTossItem sorts the
-- chosen item with IsInArray against UsableItems_CloseMenu first and
-- UsableItems_PartyMenu second. RARE_CANDY is in the party-menu array
-- (data/items/use_party.asm), so it reaches .useItem_partyMenu, which after
-- `call UseItem` -- when wActionResultOrTookBattleTurn is not $02 --
-- restores the screen and `jp StartMenu_Item`, i.e. re-enters the item list
-- instead of CloseStartMenu. StartMenu_Item reloads wBagSavedMenuItem into
-- wCurrentMenuItem before DisplayListMenuID, so the cursor comes back on the
-- row you just used: that is what lets a stack of candies be mashed through.
-- engine/items/item_effects.asm ItemUseVitamin .useRareCandy ends with
-- RedrawPartyMenu / PrintStatsBox / WaitForTextScrollButtonPress /
-- LearnMoveFromLevelUp / TryEvolvingMon and `jp RemoveUsedItem` -- it never
-- whites out and never closes the start menu. Only .useItem_closeMenu items
-- (UsableItems_CloseMenu: bike, escape rope, rods) jump to CloseStartMenu.
--
-- The port popped the bag ListMenu at the head of the leveledTo branch, which
-- also skipped the "xN" refresh below it, so the level text played over the
-- overworld and the player was dumped out of the menu per candy.
-- luajit tests/engine/rare_candy_bag_open_bug796.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
-- Lazily-required inside the use branches, so seeding package.loaded before
-- the UI modules load is enough to keep the suite silent and love-free.
package.loaded["src.core.Sound"] = {
play = function() end,
playCry = function() end,
}
-- Real TextBoxes want a Font atlas. This flow only cares that a message
-- opened, what it says, and what its onDone does.
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { textBox = true, text = text, done = done } end,
}
-- BagMenu and PartyMenu bind TextBox at require time, so they load against
-- the stub; Screens caches its factory per id and must be told to forget.
package.loaded["src.ui.BagMenu"] = nil
package.loaded["src.ui.PartyMenu"] = nil
local BagMenu = require("src.ui.BagMenu")
local PartyMenu = require("src.ui.PartyMenu")
require("src.ui.Screens").invalidate()
local Fixtures = require("tests.modkit.fixtures")
local Bag = require("src.inventory.Bag")
local Pokemon = require("src.pokemon.Pokemon")
local Data = Fixtures.fresh()
-- The fixture item table has no candy of its own; ItemEffects keys the
-- level-up branch on the id, and BagMenu only reads name/keyItem off the def.
Data.items.RARE_CANDY = {
id = "RARE_CANDY", index = 90, name = "RARE CANDY", price = 4800,
tossable = true,
}
-- The mon: FIXMON_C has an empty `evolutions` and a learnset that stops at
-- level 1, so the 5 -> 6 candy prints its line and nothing else follows it.
-- That keeps the assertions on the bag rather than on the stat box, which
-- would need real graphics.
local function freshGame(candies)
local mon = Pokemon.new(Data, "FIXMON_C", 5)
local game = {
data = Data,
save = {
party = { mon },
player = { name = "RED", id = 1 },
inventory = {},
options = {},
flags = {},
money = 0,
},
}
game.stack = {
states = {},
push = function(self, s) table.insert(self.states, s) end,
pop = function(self) return table.remove(self.states) end,
top = function(self) return self.states[#self.states] end,
}
-- one button edge per update, the way Input reports a fixed step
game.input = { pressed = nil }
function game.input:wasPressed(b) return self.pressed == b end
-- FIX POTION first so the candy is never row 1: an index that silently
-- reset to the top would otherwise pass the cursor assertion by accident
Bag.add(game.save, "FIX_POTION", 1)
Bag.add(game.save, "RARE_CANDY", candies)
return game, mon
end
local function isPicker(s) return getmetatable(s) == PartyMenu end
local function isBox(s) return type(s) == "table" and s.textBox == true end
local function inStack(stack, pred)
for _, s in ipairs(stack.states) do
if pred(s) then return true end
end
return false
end
local function rowFor(list, id)
for i, r in ipairs(list.items) do
if r.value == id then return i end
end
return nil
end
-- Open the bag, put the cursor on `id`, choose it, take USE off the
-- USE/TOSS box, then press A on the party picker. Returns the bag list.
local function useFromBag(game, battle, id)
local list = BagMenu.new(game, { battle = battle })
game.stack:push(list)
local row = rowFor(list, id)
if not row then return nil, "no " .. id .. " row in the bag" end
list.index = row
list.onChoose(list.items[row], list)
local sub = game.stack:top()
if not battle and sub and sub.items and sub.items[1]
and sub.items[1].onSelect then
game.stack:pop() -- the USE/TOSS Menu pops itself on select
sub.items[1].onSelect()
end
local picker = game.stack:top()
if not isPicker(picker) then return nil, "party picker never opened" end
game.input.pressed = "a"
picker:update(1 / 60)
game.input.pressed = nil
return list
end
-- The bug: three candies in the bag, use one in the field.
do
local game, mon = freshGame(3)
local list, why = useFromBag(game, nil, "RARE_CANDY")
if check(list ~= nil, "the bag opened and reached the picker: " .. tostring(why)) then
eq(mon.level, 6, "the candy leveled the mon 5 -> 6")
check(not inStack(game.stack, isPicker),
"the pickOnly picker popped itself before onSwitch")
check(inStack(game.stack, function(s) return s == list end),
"the bag list is STILL on the stack (.useItem_partyMenu re-enters "
.. "StartMenu_Item, it does not CloseStartMenu) (#796)")
local row = rowFor(list, "RARE_CANDY")
if check(row ~= nil, "the RARE CANDY row survived the use") then
eq(list.items[row].right, "x2", "and its count followed the inventory")
eq(list.index, row, "with the cursor left on it (wBagSavedMenuItem), "
.. "so the next candy is one A press away")
end
eq(game.save.inventory.RARE_CANDY, 2, "one candy was consumed")
local box = game.stack:top()
if check(isBox(box), "the grew-to-level line prints over the open bag") then
check(box.text:find("level 6", 1, true) ~= nil,
"and it names the new level: " .. tostring(box.text))
end
end
end
-- The last candy: the row goes away (RemoveUsedItem empties the slot) and the
-- cursor clamps to a real row -- but the list itself still must not close.
do
local game = freshGame(1)
local list = useFromBag(game, nil, "RARE_CANDY")
if check(list ~= nil, "the bag reached the picker with a single candy") then
check(inStack(game.stack, function(s) return s == list end),
"the last candy does not close the bag either (#796)")
check(rowFor(list, "RARE_CANDY") == nil, "its row was removed")
eq(game.save.inventory.RARE_CANDY, nil, "and the slot is empty")
check(list.index >= 1 and list.index <= #list.items,
"the cursor clamped to a valid row (index " .. tostring(list.index)
.. " of " .. #list.items .. ")")
end
end
-- Boundary the fix must not have moved: a candy is refused mid-battle, so the
-- field behavior above can never be mistaken for a battle regression.
-- item_effects.asm:800-803, ItemUseVitamin reads wIsInBattle and
-- `jp nz, ItemUseNotTime` before it ever falls into ItemUseMedicine, which is
-- why the port's battle-side branch is a guard rather than a live path. A
-- bare table stands in for the battle: nothing on this route reads it.
do
local game, mon = freshGame(3)
local list = useFromBag(game, { fakeBattle = true }, "RARE_CANDY")
if check(list ~= nil, "the battle bag reached the picker") then
eq(mon.level, 5, "a RARE CANDY mid-battle levels nothing (ItemUseVitamin "
.. "-> ItemUseNotTime)")
eq(game.save.inventory.RARE_CANDY, 3, "and is not consumed")
local box = game.stack:top()
if check(isBox(box), "the refusal prints") then
check(box.text:find("time to use", 1, true) ~= nil,
"with ItemUseNotTime's line: " .. tostring(box.text))
end
end
end
T.finish()
+8 -1
View File
@@ -90,7 +90,14 @@ end
-- type-4 turn can still use it
do
local _, _, rows = typeOf("BUBBLEBEAM", true)
eq(rows[1].sfx, "Damage", "the row carries the damage sound")
-- PlayApplyingAttackSound sets wFrequencyModifier alongside the sound
-- ($20 for SFX_DAMAGE), and the noise channel's polynomial counter IS
-- that modifier, so the row carries both now (#826)
eq(type(rows[1].sfx) == "table" and rows[1].sfx.sound, "Damage",
"the row carries the damage sound")
eq(rows[1].sfx.pitch, 0x20, "with its PlayApplyingAttackSound pitch byte")
eq(rows[1].sfx.tempo, nil,
"and no tempo byte: Audio2_note_length skips the sfx tempo on CHAN8")
local _, _, plain = typeOf("TACKLE", true)
check(plain[1].blink ~= nil, "a type-4 row carries the pic to blink")
end
+131
View File
@@ -0,0 +1,131 @@
-- Parity test (#805): ESCAPE ROPE / DIG / TELEPORT must land on an outdoor
-- fly-warp cell, and must re-point the LAST_MAP memory at it.
--
-- pret: ItemUseEscapeRope (engine/items/item_effects.asm) sets BIT_FLY_WARP
-- and BIT_ESCAPE_WARP, and LoadSpecialWarpData's .usedFlyWarp path
-- (engine/overworld/special_warps.asm) warps to wLastBlackoutMap with the
-- landing cell read from FlyWarpDataPtr. wLastBlackoutMap is ALWAYS an
-- outdoor map: SetLastBlackoutMap (engine/events/set_blackout_map.asm)
-- copies wLastMap, and WarpFound2 (home/overworld.asm) only writes wLastMap
-- when CheckIfInOutsideMap passes. PrepareForSpecialWarp
-- (engine/overworld/special_warps.asm) then does `ld [wLastMap], a` with
-- that destination for every fly/escape warp that is not a dungeon warp.
--
-- The port broke both halves. A .sav import stamps lastHeal from wherever
-- the cartridge was saved (src/save_convert/SaveConvert.lua mergeDefaults,
-- which records no outdoor), so a save made inside Seafoam Islands made
-- ESCAPE ROPE warp the player back into that cave; and the teleport branch
-- skipped rememberOutdoor, so the first LAST_MAP exit after the rope still
-- resolved against the dungeon door walked in through.
--
-- Self-contained; run via `luajit tests/parity_escape_rope_bug805.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity escape rope #805")
local check, eq = S.check, S.eq
require("src.render.Font").load(Data)
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local Pokemon = require("src.pokemon.Pokemon")
local Map = require("src.world.Map")
local FieldDefaults = require("src.world.FieldDefaults")
local OW = require("src.world.OverworldController")
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
Game.save.party = { Pokemon.new(Data, "SQUIRTLE", 20) }
-- The player is deep in a cave, having walked in from the outdoor door
-- cell the LAST_MAP exits still point at.
Game.stack:push(OW, "SEAFOAM_ISLANDS_B2F", 5, 5, "down")
local ow = Game.stack:top()
Game.overworld = ow
-- Capture the warp target instead of running the real Transition.
local dest
local realStart = ow.startWarpTo
ow.startWarpTo = function(self, mapId, x, y, facing, onDone, opts)
dest = { map = mapId, x = x, y = y }
self.arriveWarp = nil
self.transitioning = false
end
local outsideTilesets = FieldDefaults.field(Data, "outsideTilesets")
local function isOutside(mapId)
local def = Data.maps[mapId]
return def ~= nil and Map.isOutside(def, outsideTilesets)
end
local flyWarps = Data.field.flyWarps or {}
local bootHeal = SaveData.defaultHeal(Data.field.boot)
-- --------------------------------------------------------------- 1. healthy
-- A save healed by a nurse records the outdoor town alongside the interior
-- heal cell; the rope lands on that town's FlyWarpDataPtr cell.
Game.save.lastHeal = { map = "VIRIDIAN_POKECENTER", x = 3, y = 3,
outdoor = { id = "VIRIDIAN_CITY", x = 23, y = 27 } }
ow:rememberOutdoor("ROUTE_23", 8, 60) -- the Victory Road door walked in from
dest = nil
ow:warpToHealPoint(nil, { arrive = "teleport" })
check(flyWarps.VIRIDIAN_CITY ~= nil, "Viridian City has a fly warp cell")
eq(dest.map, "VIRIDIAN_CITY", "healthy heal record: rope lands on the town")
eq(dest.x, flyWarps.VIRIDIAN_CITY.x, "rope lands on the FlyWarpDataPtr x")
eq(dest.y, flyWarps.VIRIDIAN_CITY.y, "rope lands on the FlyWarpDataPtr y")
-- 3. PrepareForSpecialWarp: the destination becomes the new wLastMap, so a
-- LAST_MAP exit taken after the rope resolves against the town just landed
-- in, not the dungeon door from before.
eq(Game.save.lastOutdoor.id, "VIRIDIAN_CITY",
"teleport warp re-points wLastMap at the destination (#805)")
eq(Game.save.lastOutdoor.x, dest.x, "wLastMap x follows the landing cell")
eq(Game.save.lastOutdoor.y, dest.y, "wLastMap y follows the landing cell")
-- --------------------------------------------------------------- 2. imported
-- Exactly what SaveConvert stamps for a cartridge save made in a cave: the
-- player's own cell, no outdoor town. wLastBlackoutMap can never name an
-- indoor map, so this record is unusable and falls back to the boot heal
-- town (vanilla's zero-filled wLastBlackoutMap is map 0, Pallet Town).
Game.save.lastHeal = { map = "SEAFOAM_ISLANDS_B2F", x = 5, y = 5 }
ow:rememberOutdoor("ROUTE_23", 8, 60)
dest = nil
ow:warpToHealPoint(nil, { arrive = "teleport" })
check(not isOutside("SEAFOAM_ISLANDS_B2F"),
"Seafoam Islands B2F is not an outside map")
check(dest.map ~= "SEAFOAM_ISLANDS_B2F",
"imported heal record does not dump the rope back in the cave (#805)")
check(isOutside(dest.map), "escape-warp destination is always an outside map")
eq(dest.map, bootHeal.map, "unusable heal record falls back to the boot town")
eq(dest.x, bootHeal.x, "boot-town fallback keeps its landing x")
eq(dest.y, bootHeal.y, "boot-town fallback keeps its landing y")
eq(Game.save.lastOutdoor.id, bootHeal.map,
"fallback landing is remembered as wLastMap too")
-- --------------------------------------------------------------- 3. blackout
-- A blackout (no opts) still lands on the interior heal cell and re-points
-- LAST_MAP exits at the remembered town door: HandleBlackOut never sets
-- BIT_FLY_WARP, so it is not a special warp destination of its own.
Game.save.lastHeal = { map = "VIRIDIAN_POKECENTER", x = 3, y = 3,
outdoor = { id = "VIRIDIAN_CITY", x = 23, y = 27 } }
ow:rememberOutdoor("ROUTE_23", 8, 60)
dest = nil
ow:warpToHealPoint()
eq(dest.map, "VIRIDIAN_POKECENTER", "blackout still lands at the heal cell")
eq(Game.save.lastOutdoor.id, "VIRIDIAN_CITY",
"blackout re-points wLastMap at the remembered town door")
eq(Game.save.lastOutdoor.x, 23, "blackout keeps the recorded door x")
eq(Game.save.lastOutdoor.y, 27, "blackout keeps the recorded door y")
ow.startWarpTo = realStart
S.finish()
+154
View File
@@ -0,0 +1,154 @@
-- Parity test: gym leader TM award respects the bag cap (#797).
--
-- scripts/PewterGym.asm, PewterGymScriptReceiveTM34: after
-- SetEvent EVENT_BEAT_BROCK it runs `lb bc, TM_BIDE, 1` / `call GiveItem`
-- / `jr nc, .BagFull`. On success it prints TEXT_PEWTERGYM_RECEIVED_TM34
-- (and the TM34 explanation); on carry-clear it prints
-- TEXT_PEWTERGYM_TM34_NO_ROOM instead. Both paths fall through to
-- .gymVictory, so the badge lands either way and the TM is simply lost.
-- The same `jr nc, .BagFull` shape is in CeruleanGym.asm, VermilionGym.asm,
-- CeladonGym.asm, FuchsiaGym.asm, SaffronGym.asm, CinnabarGym.asm and
-- ViridianGym.asm.
--
-- The port drives this through OverworldState:checkVictoryRewards over
-- data/scripts/victories.lua (gym leaders are not def_trainers entries, so
-- src/script/Commands.lua give_item -- which has always handled a full bag
-- -- is never on this path). checkVictoryRewards used to write the TM
-- straight into save.inventory, bypassing Bag.add's BAG_ITEM_CAPACITY
-- check and producing a 21-of-20 bag while still printing "received TM".
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 gym TM bag full #797")
local check, eq = S.check, S.eq
local Bag = require("src.inventory.Bag")
local victories = require("data.scripts.victories")
-- === (1) data shape: every gym reward carries both branches ===
-- A future gym edit must not silently drop the alternate line, so the
-- table check is cheap insurance over the eight leader entries.
do
local n = 0
for key, entry in pairs(victories) do
if entry.badge then
n = n + 1
check(type(entry.itemDialogue) == "table" and #entry.itemDialogue > 0,
key .. " has an itemDialogue tail (the GiveItem-succeeded text)")
check(type(entry.bagFull) == "string",
key .. " names a bagFull text (.BagFull branch)")
local body = entry.bagFull and (Data.text or {})[entry.bagFull]
check(type(body) == "string" and body ~= "",
key .. " bagFull label resolves to extracted text")
-- the received-TM tail must not still be baked into `dialogue`,
-- or the full-bag path would print it anyway
for _, label in ipairs(entry.dialogue or {}) do
for _, tail in ipairs(entry.itemDialogue or {}) do
check(label ~= tail,
key .. " dialogue no longer repeats " .. tostring(tail))
end
end
end
end
eq(n, 8, "all eight gym leader rewards checked")
end
-- === (2) behavior: Brock with a full bag vs an empty one ===
require("src.render.Font").load(Data)
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local OW = require("src.world.OverworldController")
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init()
-- concatenates the pages of the TextBox checkVictoryRewards pushed
local function stackedDialogue()
local top = Game.stack:top()
if not (top and top.pages) then return "" end
local parts = {}
for _, page in ipairs(top.pages) do
parts[#parts + 1] = table.concat(page, "\n")
end
return table.concat(parts, "\n")
end
local function freshSave()
while Game.stack:top() do Game.stack:pop() end
Game.save = SaveData.newGame()
Game.save.flags = {}
Game.save.inventory = {}
Game.save.bagOrder = nil
Game.save.defeatedTrainers = {}
end
-- --- full bag: the TM is refused, the NoRoom line replaces the TM text ---
freshSave()
local cap = Bag.capacity(Data)
-- Bag.slots only counts non-badge ids, so distinct filler ids fill it
for i = 1, cap do Game.save.inventory["FILLER" .. i] = 1 end
eq(Bag.slots(Game.save), cap, "bag starts at BAG_ITEM_CAPACITY")
Game.stack:push(OW, "PEWTER_GYM", 4, 13, "up")
local ow = Game.stack:top()
ow:checkVictoryRewards("OPP_BROCK", 1)
local fullText = stackedDialogue()
check(Game.save.inventory.TM_BIDE == nil,
"full bag: TM_BIDE is refused (GiveItem carry clear)")
eq(Bag.slots(Game.save), cap,
"full bag: no 21st slot appears (the reporter's symptom)")
check(Game.save.inventory.BOULDERBADGE == 1,
"full bag: .gymVictory still awards BOULDERBADGE")
check(Game.save.flags.EVENT_BEAT_BROCK,
"full bag: EVENT_BEAT_BROCK is still set")
check(fullText:find("room for this", 1, true) ~= nil,
"full bag: dialogue prints _PewterGymTM34NoRoomText")
check(fullText:find("BIDE", 1, true) == nil,
"full bag: the TM34 explanation is skipped")
check(fullText:find("FLASH", 1, true) ~= nil,
"full bag: the BoulderBadge speech still runs")
-- --- empty bag: the success tail still appends and the TM lands ---
freshSave()
Game.stack:push(OW, "PEWTER_GYM", 4, 13, "up")
ow = Game.stack:top()
ow:checkVictoryRewards("OPP_BROCK", 1)
local okText = stackedDialogue()
check(Game.save.inventory.TM_BIDE == 1,
"empty bag: TM_BIDE lands in the bag")
local order = Bag.order(Game.save)
check(order[1] == "TM_BIDE",
"empty bag: Bag.add kept the wBagItems order (bagOrder) honest")
check(okText:find("BIDE", 1, true) ~= nil,
"empty bag: itemDialogue (TM34 explanation) still appends")
check(okText:find("room for this", 1, true) == nil,
"empty bag: the NoRoom line is not printed")
-- --- one more leader, to prove the split is not Pewter-only ---
freshSave()
for i = 1, cap do Game.save.inventory["FILLER" .. i] = 1 end
Game.stack:push(OW, "CERULEAN_GYM", 4, 10, "up")
ow = Game.stack:top()
ow:checkVictoryRewards("OPP_MISTY", 1)
local mistyText = stackedDialogue()
check(Game.save.inventory.TM_BUBBLEBEAM == nil,
"Misty full bag: TM_BUBBLEBEAM is refused")
check(Game.save.inventory.CASCADEBADGE == 1,
"Misty full bag: CASCADEBADGE still awarded")
eq(Bag.slots(Game.save), cap, "Misty full bag: still at capacity")
check(mistyText:find((Data.text or {})._CeruleanGymMistyTM11NoRoomText
:match("^[^\n]+") or "\1", 1, true) ~= nil,
"Misty full bag: dialogue prints _CeruleanGymMistyTM11NoRoomText")
while Game.stack:top() do Game.stack:pop() end
S.finish()
+9 -5
View File
@@ -116,16 +116,20 @@ local HallOfFame = require("src.ui.HallOfFame")
check(getmetatable(stack2:top()) == HallOfFame, "induction showcase pushed")
-- Gen1 layout (issue #102): pic rests at hlcoord (12,5); mon phase starts
-- with the LEVEL/TYPE info box (not a top "HALL OF FAME" banner alone)
-- with the LEVEL/TYPE info box (not a top "HALL OF FAME" banner alone).
-- HoFShowMonOrPlayer sweeps the BACK pic across the screen first (hSCX
-- $c0 -> $a0) and only then scrolls the front pic in (#847), so the
-- induction opens on the back pass.
local hofUi = stack2:top()
eq(hofUi.phase, "mons", "induction opens on the mon showcase phase")
eq(hofUi.scrollX < 12 * 8, true, "front pic starts off-screen left of (12,5)")
-- drive past the scroll so the info box is armed
eq(hofUi.phase, "back", "induction opens on the back pic sweep (#847)")
eq(hofUi.scrollX, 160, "back pic enters at the right edge (hSCX = $c0)")
-- drive the back sweep and the front scroll so the info box is armed
local scrollGuard = 0
while hofUi.scrollX < 12 * 8 and scrollGuard < 200 do
while (hofUi.phase == "back" or hofUi.scrollX < 12 * 8) and scrollGuard < 400 do
scrollGuard = scrollGuard + 1
hofUi:update(1 / 60)
end
eq(hofUi.phase, "mons", "the front pic phase follows the back sweep (#847)")
eq(hofUi.scrollX, 12 * 8, "front pic settles at hlcoord (12,5)")
eq(hofUi.showHofBanner, false, "bottom HALL OF FAME banner waits for the 80-frame hold")
check(hofUi.timer == 80 or hofUi.timer < 80,
+187
View File
@@ -0,0 +1,187 @@
-- Parity test: getting on SURF ends the bike (#846).
--
-- pokered keeps walking / biking / surfing in ONE state byte,
-- wWalkBikeSurfState. ItemUseSurfboard (engine/items/item_effects.asm)
-- copies the old state aside, refuses when it is already 2, and on a
-- successful mount does `ld a, 2 / ld [wWalkBikeSurfState], a ; change
-- player state to surfing` followed by PlayDefaultMusic -- the bike state
-- is overwritten, so no bike can survive a surf: not its 8-frame step
-- cadence, not its theme. The port splits that byte into two independent
-- flags (Game.save.onBike and player.surfing) and nothing used to clear
-- the first when the second went up, so a player who surfed off the bike
-- paddled at bike speed with Music_BikeRiding still playing.
--
-- The mirror direction is explicit in the same asm file: ItemUseBicycle
-- opens `ld a, [wWalkBikeSurfState] / cp 2 ; is the player surfing? /
-- jp z, ItemUseNotTime`, so the bag cannot re-raise the bike on water.
--
-- Self-contained; run via `luajit tests/parity_surf_clears_bike_bug846.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity surf clears bike")
local check, eq = S.check, S.eq
require("src.render.Font").load(Data)
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local ItemEffects = require("src.inventory.ItemEffects")
local Music = require("src.core.Music")
local Pokemon = require("src.pokemon.Pokemon")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local OW = require("src.world.OverworldController")
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
Game.overworld = OW
-- tests/parity_bills_pc.lua swaps the OverworldController chunk's TextBox
-- upvalue for a stub and never puts it back, and run_tests.lua runs every
-- parity suite from one file: point it back at the real module so trySurf
-- pushes a real box here (same guard as parity_field_move_layering.lua).
local function setUpvalue(fn, name, val)
local i = 1
while true do
local n = debug.getupvalue(fn, i)
if not n then return false end
if n == name then debug.setupvalue(fn, i, val); return true end
i = i + 1
end
end
setUpvalue(OW.trySurf, "TextBox", require("src.render.TextBox"))
local function frame(btns)
Input.pressed = {}
for _, b in ipairs(btns or {}) do Input.pressed[b] = true; Input.state[b] = true end
StateStack:update(1 / 60)
for _, b in ipairs(btns or {}) do Input.state[b] = false end
end
local function popAll() while Game.stack:top() do Game.stack:pop() end end
local function pushOW(mapId, x, y, facing)
popAll()
Game.stack:push(OW, mapId, x, y, facing)
return Game.stack:top()
end
local function mkMon(species, ...)
local m = Pokemon.new(Data, species, 20)
m.moves = {}
for _, id in ipairs({ ... }) do m.moves[#m.moves + 1] = { id = id, pp = 15 } end
return m
end
-- Music.play no-ops on the headless audio stub, so the song the bike/surf
-- override actually resolves to is only observable by intercepting it
-- (the Music.playMap stub pattern in parity_seam_walk_anim.lua)
local playedSong
local realPlay = Music.play
Music.play = function(_, song) playedSong = song end
local bikeSong = Music.special(Data, "bike")
local surfSong = Music.special(Data, "surf")
check(bikeSong ~= nil and surfSong ~= nil and bikeSong ~= surfSong,
"the bike and surf themes are two distinct songs")
-- =====================================================================
-- control: on land, onBike really does buy the halved step cadence, so
-- the assertion after the mount below is not vacuous
-- =====================================================================
local ow = pushOW("PALLET_TOWN", 5, 6, "up")
local p = ow.player
eq(p.stepFrames, 16, "a walking step is 16 frames")
eq(p.bikeStepFrames, 8, "the bicycle doubles walking speed (8 frames)")
Game.save.onBike = true
p.turnTimer = 0
p.stepFramesCur = nil
eq(p:tryMove("up", ow.map, ow.entities), "moved", "riding north out of the spawn cell")
eq(p.stepFramesCur, p.bikeStepFrames, "onBike hands out the bike cadence on land")
-- =====================================================================
-- the mount: bike state is gone the moment the got-on text closes, and
-- the first step onto the water is a WALK-length step, not a bike one
-- =====================================================================
ow = pushOW("PALLET_TOWN", 4, 13, "down")
p = ow.player
p.surfing = false
Game.save.onBike = true
Game.save.party = { mkMon("SQUIRTLE", "SURF") }
-- HM03's badge gate (FieldDefaults hmBadges): without it partyKnows("SURF")
-- refuses and trySurf never prints anything
Game.save.inventory.SOULBADGE = true
check(ow:partyKnows("SURF") ~= nil, "the party can use SURF here")
check(ow.map:isWaterCell(4, 14), "Pallet's south shore faces water at (4,14)")
-- what is playing when the mount starts: the bike override over the
-- outdoor Pallet theme
Music.playMap(Data, "PALLET_TOWN", true, false)
eq(playedSong, bikeSong, "the bike theme plays while riding through Pallet")
p.stepFramesCur = nil
ow:trySurf(4, 14, nil)
local box = Game.stack:top()
check(box ~= nil and box.pages ~= nil, "SURF prints _SurfingGotOnText")
local guard = 0
while Game.stack:top() == box and guard < 400 do
guard = guard + 1
frame({ "a" })
end
check(Game.stack:top() ~= box, "the got-on text closes")
eq(p.surfing, true, "the mount raises the surf state")
eq(Game.save.onBike, false,
"ItemUseSurfboard writes surfing OVER the bike state, so the bike ends (#846)")
eq(playedSong, surfSong,
"PlayDefaultMusic after the mount picks the surf theme, not the bike theme")
-- the blink carries the mount forward onto the water; let that scripted
-- step land (it is queued through scriptMove, which drives the entity
-- directly and never touches Player:tryMove)
guard = 0
while (Game.stack:top() ~= ow or p.moving or #ow.scriptMoves > 0) and guard < 240 do
guard = guard + 1
frame({})
end
eq(Game.stack:top(), ow, "the mount ends back on the map")
eq(p.cellY, 14, "the mount steps forward onto the water")
-- the symptom in #846: the first paddled step the player takes. It runs
-- through Player:tryMove, which reads Game.save.onBike for its step
-- length -- a stale bike flag paddles at 8 frames per cell.
check(ow.map:isWaterCell(4, 15), "the next cell south is water too")
p.turnTimer = 0
p.stepFramesCur = nil
eq(p:tryMove("down", ow.map, ow.entities), "moved", "paddling south from (4,14)")
eq(p.stepFramesCur, p.stepFrames,
"the paddled step uses the walk cadence, the exact symptom in #846")
check(p.stepFramesCur ~= p.bikeStepFrames, "no bike cadence survives onto the water")
-- =====================================================================
-- the mirror hole: the bag cannot put the bike back on under a surfer
-- (ItemUseBicycle's `cp 2` -> jp z, ItemUseNotTime), or the bug returns
-- by another route
-- =====================================================================
local save = SaveData.newGame()
local surfingOw = { player = { surfing = true } }
local result, msgs = ItemEffects.use(Data, save, "BICYCLE", nil, false, nil, surfingOw)
eq(result, "failed", "the BICYCLE is refused while surfing")
check(result ~= "bicycle", "a surfing BICYCLE never reaches the mount path")
check(msgs and msgs[1] and msgs[1]:find("isn't the", 1, true) ~= nil,
"the surfing BICYCLE refusal uses the OAK 'not the time' text")
local landOw = { player = { surfing = false } }
eq((ItemEffects.use(Data, save, "BICYCLE", nil, false, nil, landOw)), "bicycle",
"the BICYCLE still mounts normally on land")
Music.play = realPlay
popAll()
S.finish()
+89
View File
@@ -0,0 +1,89 @@
-- #835: the launcher must open on the game that was played last, instead of
-- always opening on Red. Two halves, both asserted here: RomImporter:play
-- writes the chosen version to options.lua, and RomImporter:_applyLastVersionTab
-- reads it back when the constructor has finished filling self.ready.
-- Self-contained: `luajit tests/rom_importer_last_version_test.lua`; also
-- dofile'd by tests/run_tests.lua.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("rom importer last version")
local eq = S.eq
love.mouse.isCursorSupported = function() return false end
local SaveData = require("src.core.SaveData")
local LaunchOptions = require("src.core.LaunchOptions")
local RomImporter = require("src.import.RomImporter")
-- The options round trip must not touch the developer's real save directory.
-- SaveData.persistFs consults SaveData.portableFs() before love.filesystem
-- (src/core/SaveData.lua:203-208), so overriding that one hook reroutes both
-- loadOptions and saveOptions onto this in-memory volume. saveOptions reads
-- the file back after writing (#828), so read/write have to be truthful.
-- The override is process-global and tests/run_tests.lua dofiles every suite
-- into one process, so it MUST be put back before this file returns: leaving
-- it in place reroutes every later suite's save I/O into `disk` (parity_hof
-- reads its own save back and fails 6 assertions if this leaks).
local realPortableFs = SaveData.portableFs
local disk = {}
SaveData.portableFs = function()
return {
getInfo = function(name) return disk[name] and { type = "file" } or nil end,
read = function(name) return disk[name] or nil, "no file: " .. name end,
write = function(name, data) disk[name] = data return true end,
remove = function(name) disk[name] = nil end,
}
end
local function newImporter(fields)
local ri = setmetatable(fields, RomImporter)
return ri
end
-- ---- write side: play() records the version it hands off to boot
local booted = nil
local ri = newImporter({
android = true, -- skips the cursor restore; see #114 suite
workState = nil,
tab = "red",
ready = { yellow = true },
onComplete = function(version) booted = version end,
})
ri:play("yellow")
eq(booted, "yellow", "play boots the chosen version")
eq(SaveData.loadOptions().lastVersion, "yellow", "play remembers the version played")
-- ---- read side: a fresh launcher opens on that column
local ri2 = newImporter({ tab = "red", ready = { red = true, yellow = true } })
ri2:_applyLastVersionTab()
eq(ri2.tab, "yellow", "launcher opens on the last played version")
-- A remembered version whose cache is gone or stale must not open a column
-- with no Play button in it.
local ri3 = newImporter({ tab = "red", ready = { red = true, yellow = false } })
ri3:_applyLastVersionTab()
eq(ri3.tab, "red", "an unready remembered version leaves the tab alone")
-- An explicit --game shortcut (main.lua sets LaunchOptions.pendingTab) wins
-- over the remembered version.
LaunchOptions.pendingTab = "blue"
local ri4 = newImporter({ tab = "blue", ready = { red = true, yellow = true } })
ri4:_applyLastVersionTab()
eq(ri4.tab, "blue", "an explicit --game tab beats the remembered version")
LaunchOptions.pendingTab = nil -- module is a singleton: do not leak this
-- A junk value in options.lua (hand-edited file, a build that knew other
-- versions) must not select a tab that does not exist.
local opts = SaveData.loadOptions()
opts.lastVersion = "gold"
SaveData.saveOptions(opts)
local ri5 = newImporter({ tab = "red", ready = { red = true, yellow = true } })
ri5:_applyLastVersionTab()
eq(ri5.tab, "red", "an unknown remembered version leaves the tab alone")
SaveData.portableFs = realPortableFs
S.finish()
+3
View File
@@ -3400,6 +3400,9 @@ runSuites({ "tests/input_hold_test.lua" })
-- ---------------------------------------------- launcher cursor (#114)
runSuites({ "tests/rom_importer_cursor_test.lua" })
-- ---------------------------------------------- launcher last played tab (#835)
runSuites({ "tests/rom_importer_last_version_test.lua" })
-- ---------------------------------------------- Android second ROM pick (#167)
runSuites({ "tests/rom_importer_android_pick_test.lua" })