From f6392e8932e2f54ce83b489ca779fbe420c01fe3 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 5 Aug 2026 11:09:05 -0400 Subject: [PATCH] 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 --- data/scripts/story.lua | 46 +-- data/scripts/victories.lua | 46 ++- src/battle/BattleState.lua | 63 +++- src/battle/EffectRegistry.lua | 24 +- src/core/ChipSynth.lua | 50 ++- src/core/Music.lua | 17 +- src/core/SaveData.lua | 19 +- src/core/Sound.lua | 99 +++++- src/import/RomImporter.lua | 31 ++ src/inventory/ItemEffects.lua | 7 + src/render/PaletteFX.lua | 16 + src/script/Commands.lua | 17 +- src/ui/BagMenu.lua | 31 +- src/ui/HallOfFame.lua | 116 +++++-- src/ui/NamingScreen.lua | 15 +- src/world/OverworldController.lua | 102 +++++-- .../battle_choice_paper_bug822_test.lua | 216 +++++++++++++ .../champion_alt_tempo_bug847_test.lua | 286 ++++++++++++++++++ tests/drivers/hall_of_fame_bug704_test.lua | 12 + tests/drivers/hit_sfx_bug826_test.lua | 262 ++++++++++++++++ .../drivers/pika_entrance_cry_bug837_test.lua | 172 +++++++++++ tests/engine/move_sfx_channel_gate_bug844.lua | 219 ++++++++++++++ tests/engine/naming_empty_confirm_bug833.lua | 126 ++++++++ .../engine/options_write_readback_bug828.lua | 113 +++++++ tests/engine/rare_candy_bag_open_bug796.lua | 198 ++++++++++++ tests/parity_applying_attack_anim.lua | 9 +- tests/parity_escape_rope_bug805.lua | 131 ++++++++ tests/parity_gym_tm_bag_full_bug797.lua | 154 ++++++++++ tests/parity_hof.lua | 14 +- tests/parity_surf_clears_bike_bug846.lua | 187 ++++++++++++ tests/rom_importer_last_version_test.lua | 89 ++++++ tests/run_tests.lua | 3 + 32 files changed, 2776 insertions(+), 114 deletions(-) create mode 100644 tests/drivers/battle_choice_paper_bug822_test.lua create mode 100644 tests/drivers/champion_alt_tempo_bug847_test.lua create mode 100644 tests/drivers/hit_sfx_bug826_test.lua create mode 100644 tests/drivers/pika_entrance_cry_bug837_test.lua create mode 100644 tests/engine/move_sfx_channel_gate_bug844.lua create mode 100644 tests/engine/naming_empty_confirm_bug833.lua create mode 100644 tests/engine/options_write_readback_bug828.lua create mode 100644 tests/engine/rare_candy_bag_open_bug796.lua create mode 100644 tests/parity_escape_rope_bug805.lua create mode 100644 tests/parity_gym_tm_bag_full_bug797.lua create mode 100644 tests/parity_surf_clears_bike_bug846.lua create mode 100644 tests/rom_importer_last_version_test.lua diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 8bcabcd0..cc540257 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -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 = { diff --git a/data/scripts/victories.lua b/data/scripts/victories.lua index b1aeaa9f..8a05264c 100644 --- a/data/scripts/victories.lua +++ b/data/scripts/victories.lua @@ -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 diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index f225fcf0..c0aaf737 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -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) diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index de443a72..05c219d9 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -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 diff --git a/src/core/ChipSynth.lua b/src/core/ChipSynth.lua index dfbc5cc1..1ca31740 100644 --- a/src/core/ChipSynth.lua +++ b/src/core/ChipSynth.lua @@ -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 diff --git a/src/core/Music.lua b/src/core/Music.lua index b5d54908..be6ba0e1 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -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 diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 6ce20c58..e05ac52b 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -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) diff --git a/src/core/Sound.lua b/src/core/Sound.lua index e0427380..f3fd35b0 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -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 diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 0ecbfc2a..c44f2fd1 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -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. diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index f7c1b9b3..81a5362a 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -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 diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 9de4f271..ea654d9d 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -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 diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 9d815504..20792244 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -1075,9 +1075,13 @@ function Commands.march_in_place(ctx, objIndex, on) end -- play_music [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. diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index fc9539c4..e522533f 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -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). diff --git a/src/ui/HallOfFame.lua b/src/ui/HallOfFame.lua index 610df29f..848fde8e 100644 --- a/src/ui/HallOfFame.lua +++ b/src/ui/HallOfFame.lua @@ -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), diff --git a/src/ui/NamingScreen.lua b/src/ui/NamingScreen.lua index 6073ca44..c35f4b9d 100644 --- a/src/ui/NamingScreen.lua +++ b/src/ui/NamingScreen.lua @@ -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() diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 85a7a5e1..10efbea7 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -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 diff --git a/tests/drivers/battle_choice_paper_bug822_test.lua b/tests/drivers/battle_choice_paper_bug822_test.lua new file mode 100644 index 00000000..77c6897a --- /dev/null +++ b/tests/drivers/battle_choice_paper_bug822_test.lua @@ -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 diff --git a/tests/drivers/champion_alt_tempo_bug847_test.lua b/tests/drivers/champion_alt_tempo_bug847_test.lua new file mode 100644 index 00000000..bf22c76b --- /dev/null +++ b/tests/drivers/champion_alt_tempo_bug847_test.lua @@ -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 diff --git a/tests/drivers/hall_of_fame_bug704_test.lua b/tests/drivers/hall_of_fame_bug704_test.lua index 765c4fc2..d46dcabe 100644 --- a/tests/drivers/hall_of_fame_bug704_test.lua +++ b/tests/drivers/hall_of_fame_bug704_test.lua @@ -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) diff --git a/tests/drivers/hit_sfx_bug826_test.lua b/tests/drivers/hit_sfx_bug826_test.lua new file mode 100644 index 00000000..fc99603d --- /dev/null +++ b/tests/drivers/hit_sfx_bug826_test.lua @@ -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 diff --git a/tests/drivers/pika_entrance_cry_bug837_test.lua b/tests/drivers/pika_entrance_cry_bug837_test.lua new file mode 100644 index 00000000..e98e7d20 --- /dev/null +++ b/tests/drivers/pika_entrance_cry_bug837_test.lua @@ -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_"; + -- 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 diff --git a/tests/engine/move_sfx_channel_gate_bug844.lua b/tests/engine/move_sfx_channel_gate_bug844.lua new file mode 100644 index 00000000..b7666e43 --- /dev/null +++ b/tests/engine/move_sfx_channel_gate_bug844.lua @@ -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)") diff --git a/tests/engine/naming_empty_confirm_bug833.lua b/tests/engine/naming_empty_confirm_bug833.lua new file mode 100644 index 00000000..a184b62a --- /dev/null +++ b/tests/engine/naming_empty_confirm_bug833.lua @@ -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") diff --git a/tests/engine/options_write_readback_bug828.lua b/tests/engine/options_write_readback_bug828.lua new file mode 100644 index 00000000..3d68fac2 --- /dev/null +++ b/tests/engine/options_write_readback_bug828.lua @@ -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") diff --git a/tests/engine/rare_candy_bag_open_bug796.lua b/tests/engine/rare_candy_bag_open_bug796.lua new file mode 100644 index 00000000..6aee10cf --- /dev/null +++ b/tests/engine/rare_candy_bag_open_bug796.lua @@ -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() diff --git a/tests/parity_applying_attack_anim.lua b/tests/parity_applying_attack_anim.lua index fc11dd56..4cb22bed 100644 --- a/tests/parity_applying_attack_anim.lua +++ b/tests/parity_applying_attack_anim.lua @@ -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 diff --git a/tests/parity_escape_rope_bug805.lua b/tests/parity_escape_rope_bug805.lua new file mode 100644 index 00000000..f1025b1c --- /dev/null +++ b/tests/parity_escape_rope_bug805.lua @@ -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() diff --git a/tests/parity_gym_tm_bag_full_bug797.lua b/tests/parity_gym_tm_bag_full_bug797.lua new file mode 100644 index 00000000..e0101d13 --- /dev/null +++ b/tests/parity_gym_tm_bag_full_bug797.lua @@ -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() diff --git a/tests/parity_hof.lua b/tests/parity_hof.lua index 0f2b6d41..0f427eea 100644 --- a/tests/parity_hof.lua +++ b/tests/parity_hof.lua @@ -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, diff --git a/tests/parity_surf_clears_bike_bug846.lua b/tests/parity_surf_clears_bike_bug846.lua new file mode 100644 index 00000000..8b8e1ea0 --- /dev/null +++ b/tests/parity_surf_clears_bike_bug846.lua @@ -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() diff --git a/tests/rom_importer_last_version_test.lua b/tests/rom_importer_last_version_test.lua new file mode 100644 index 00000000..07e3543d --- /dev/null +++ b/tests/rom_importer_last_version_test.lua @@ -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() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index b09a9182..a5d5517d 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -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" })