diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 53be89c5..99aecc4c 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -981,10 +981,15 @@ M.VICTORY_ROAD_3F = { local championsRoomRivalScript = { { "face_player" }, -- 1 { "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2 - { "jump_if_true", 25 }, -- 3 past end + { "jump_if_true", 26 }, -- 3 past end { "show_text", "_ChampionsRoomRivalIntroText" }, -- 4 - { "rival_battle", "OPP_RIVAL3", 1 }, -- 5 - { "jump_if_false", 25 }, -- 6 past end + -- ChampionsRoomRivalReadyToBattleScript plays MUSIC_FINAL_BATTLE after + -- the intro text, before the battle itself (#706); pushBattle's wipe-time + -- playBattle("final") then no-ops on the same song, so the theme stays + -- continuous into the fight + { "play_music", "Music_FinalBattle" }, -- 5 + { "rival_battle", "OPP_RIVAL3", 1 }, -- 6 + { "jump_if_false", 26 }, -- 6 past end { "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7 { "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8 -- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL, diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index a0b138b8..cc96fa25 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -3540,8 +3540,16 @@ function BattleState:onFaint(battler) self:actNext(function() battler.fainted = true local Sound = require("src.core.Sound") - Sound.playCry(self.data, battler.mon.species) - Sound.play(self.data, "Faint_Fall") + if battler.isPlayer then + -- RemoveFaintedPlayerMon (core.asm:1040-1042): the player mon's + -- faint plays its ordinary species cry -- no Faint_Fall + Sound.playCry(self.data, battler.mon.species) + elseif self.kind ~= "wild" then + -- FaintEnemyPokemon (core.asm:732-771): the enemy faint plays no + -- species cry; trainer battles get SFX_FAINT_FALL, then SFX_FAINT_THUD + -- once it finishes (wild battles skip straight to the victory music) + Sound.play(self.data, "Faint_Fall") + end self.fx = self.fx or {} -- SlideDownFaintedMonPic: PIC_HEIGHT (7) slide steps, each closing with -- DelayFrames 2 (core.asm:1186-1222). The port held this one twice as @@ -3550,6 +3558,13 @@ function BattleState:onFaint(battler) end) self.nextInsert = (self.nextInsert or 0) + 1 table.insert(self.queue, self.nextInsert, { wait = Timing.FAINT_SLIDE }) + if not battler.isPlayer and self.kind ~= "wild" then + -- FaintEnemyPokemon's SFX_FAINT_THUD lands as the slide does (after + -- Faint_Fall, before EnemyMonFaintedText) + self:actNext(function() + require("src.core.Sound").play(self.data, "Faint_Thud") + end) + end if not battler.isPlayer and self.kind == "wild" then -- FaintEnemyPokemon .wild_win (core.asm:792-795): beating a wild -- mon calls EndLowHealthAlarm and starts MUSIC_DEFEATED_WILD_MON diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index c2aa3991..0f79e571 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -542,6 +542,15 @@ function RomImporter:_romAction(version) else self:choose(version) end end +-- Sanitize a string before it is interpolated into a picker shell command: +-- * "%" would be eaten as a string.format directive (#665); +-- * '"' would break the AppleScript / zenity double-quoted argument and +-- "'" the surrounding single-quoted shell string. +local function shellSafe(s) + s = tostring(s):gsub("%%", "%%%%") + return s:gsub('"', '\\"'):gsub("'", "''") +end + -- LOVE 11.5 on Android has no native file picker (love.window.showFileDialog -- is a LOVE 12 nightly-only addition) and never fires love.filedropped, so -- neither desktop path below works there. conf.lua points the Android save @@ -648,7 +657,7 @@ end local function chooseRom(promptName) promptName = promptName or "Pokemon" - local prompt = "Choose your " .. promptName .. " ROM" + local prompt = shellSafe("Choose your " .. promptName .. " ROM") local platform = love.system.getOS() if platform == "OS X" then return commandOutput( @@ -660,10 +669,16 @@ local function chooseRom(promptName) "$d=New-Object System.Windows.Forms.OpenFileDialog;", "$d.Title='" .. prompt .. "';", "$d.Filter='Game Boy ROM (*.gb;*.gbc)|*.gb;*.gbc|All files (*.*)|*.*';", - -- write the pick as UTF-8: the console's OEM codepage would mangle - -- non-ASCII names (Pokémon -> Pok\x82mon) and crash any text draw - -- that shows them (#325) - "if($d.ShowDialog() -eq 'OK'){[Console]::OutputEncoding=[Text.Encoding]::UTF8; [Console]::Write($d.FileName)}", + -- copy the pick to a plain-ASCII temp name and answer with that: + -- the console's OEM codepage would mangle a non-ASCII path + -- (Pokémon -> Pok\x82mon) and io.open on Windows needs ANSI bytes, + -- so returning the original name both crashed the notice draw and + -- could never have opened the file (#325, #665) + "if($d.ShowDialog() -eq 'OK'){", + "$t=Join-Path $env:TEMP 'pokeport_rom_pick.gb';", + "Copy-Item -LiteralPath $d.FileName -Destination $t -Force;", + "[Console]::OutputEncoding=[Text.Encoding]::UTF8;", + "[Console]::Write($t)}", }) return commandOutput( 'powershell -NoProfile -STA -Command "' .. script .. '"') @@ -682,7 +697,7 @@ end -- Returns the chosen absolute path or nil. Android uses love.system.pickFile -- ("mod") instead -- see RomImporter:chooseMod. local function chooseZip() - local prompt = Strings("Choose a mod .zip") + local prompt = shellSafe(Strings("Choose a mod .zip")) local platform = love.system.getOS() if platform == "OS X" then return commandOutput( @@ -722,7 +737,7 @@ end -- dialogs). Returns the chosen absolute path or nil. Android uses -- love.system.pickFile("sav") instead -- see RomImporter:chooseSaveImport. local function chooseSav() - local prompt = Strings("Choose a .sav save file") + local prompt = shellSafe(Strings("Choose a .sav save file")) local platform = love.system.getOS() if platform == "OS X" then return commandOutput( @@ -734,8 +749,14 @@ local function chooseSav() "$d=New-Object System.Windows.Forms.OpenFileDialog;", "$d.Title='" .. prompt .. "';", "$d.Filter='Game Boy save (*.sav)|*.sav|All files (*.*)|*.*';", - -- UTF-8, like the ROM and mod pickers (#325) - "if($d.ShowDialog() -eq 'OK'){[Console]::OutputEncoding=[Text.Encoding]::UTF8; [Console]::Write($d.FileName)}", + -- copy the pick to a plain-ASCII temp name: io.open on Windows + -- needs ANSI bytes, so a non-ASCII path (Pokémon -> Pok\x82mon) + -- could never have been opened (#325, #665) + "if($d.ShowDialog() -eq 'OK'){", + "$t=Join-Path $env:TEMP 'pokeport_sav_pick.sav';", + "Copy-Item -LiteralPath $d.FileName -Destination $t -Force;", + "[Console]::OutputEncoding=[Text.Encoding]::UTF8;", + "[Console]::Write($t)}", }) return commandOutput( 'powershell -NoProfile -STA -Command "' .. script .. '"') diff --git a/tests/parity_faint_cry_bug709.lua b/tests/parity_faint_cry_bug709.lua new file mode 100644 index 00000000..45f7e3b0 --- /dev/null +++ b/tests/parity_faint_cry_bug709.lua @@ -0,0 +1,120 @@ +-- Parity test: the faint sound sequence matches pokered, per side. +-- +-- RemoveFaintedPlayerMon (engine/battle/core.asm:1003-1045): the player +-- mon's faint plays its ordinary species cry (PlayCry) -- no Faint_Fall. +-- +-- FaintEnemyPokemon (engine/battle/core.asm:732-796): the enemy faint +-- plays NO species cry; trainer battles play SFX_FAINT_FALL, wait for it +-- to finish, then SFX_FAINT_THUD. Wild battles skip both and go +-- straight to the victory music (.wild_win). +-- +-- The port previously played the species cry AND Faint_Fall on every +-- faint, so a fainted enemy sounded its full battle cry and a fainted +-- player mon got the fall whistle the hardware never plays (#709). +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +local Font = require("src.render.Font") +Font.load(Data) +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local BattleState = require("src.battle.BattleState") +local Sound = require("src.core.Sound") + +-- record cries and sfx instead of sounding them +local cries, sfx = {}, {} +Sound.playCry = function(_, species) cries[#cries + 1] = species end +Sound.play = function(_, name) sfx[#sfx + 1] = name end + +local function freshGame() + local save = SaveData.newGame() + save.player.name = "RED" + save.player.rival = "GARY" + save.party = { Pokemon.new(Data, "FIXMON_A", 30) } + return { data = Data, save = save, + input = { wasPressed = function() return false end, + wasJustPressed = function() return false end }, + stack = { top = function() return nil end, + push = function() end, pop = function() end } } +end + +local function reset() + cries, sfx = {}, {} +end + +-- pump the queue until it drains or the faint sounds have all fired (the +-- faint's sounds are actNext rows ahead of the faint text, which needs a +-- text input stub this driver does not bother to provide) +local function pump(battle, expectedSounds) + local count = expectedSounds or 0 + local seen = 0 + for _ = 1, 60 do + local before = #sfx + local ok = pcall(battle.updateQueue, battle) + if not ok then return false end + if #sfx > before then seen = #sfx end + if seen >= count then return true end + end + return false +end + +-- player mon faint: only the species cry, no Faint_Fall +do + reset() + local game = freshGame() + local battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) + battle.participants = {} + battle.playVictoryMusic = function() end + battle:onFaint(battle.player) + pump(battle, 1) + T.eq(cries[1], "FIXMON_A", "the player mon's faint plays its species cry") + T.eq(#cries, 1, "no other cry on the player faint") + for _, name in ipairs(sfx) do + T.check(name ~= "Faint_Fall", + "the player faint never plays Faint_Fall (#709)") + end +end + +-- enemy faint, trainer battle: Faint_Fall then Faint_Thud, no species cry +do + reset() + local game = freshGame() + local battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) + battle.participants = {} + battle.playVictoryMusic = function() end + battle:onFaint(battle.enemy) + pump(battle, 2) + T.eq(#cries, 0, "the enemy faint plays no species cry") + local fall, thud = false, false + for i, name in ipairs(sfx) do + if name == "Faint_Fall" then + T.check(not fall, "Faint_Fall plays once") + fall = true + T.check(not thud, "Faint_Fall precedes Faint_Thud") + elseif name == "Faint_Thud" then + thud = true + end + end + T.check(fall and thud, "trainer enemy faint plays Faint_Fall and Faint_Thud") +end + +-- enemy faint, wild battle: no faint sfx at all (victory music only) +do + reset() + local game = freshGame() + local battle = BattleState.newWild(game, "FIXMON_B", 5) + battle.participants = {} + battle.playVictoryMusic = function() end + battle:onFaint(battle.enemy) + pump(battle) + T.eq(#cries, 0, "the wild enemy faint plays no species cry") + for _, name in ipairs(sfx) do + T.check(name ~= "Faint_Fall" and name ~= "Faint_Thud", + "the wild enemy faint plays no faint sfx (.wild_win)") + end +end + +T.finish("parity faint cry bug709")