Merge pull request #2421 from 1Jamie/grandpas-garage

fix(game3): S.S. Anne cutscene, Old Man tutorial, battle EXP participants, trainer sight, party UI & LeafGreen palette
This commit is contained in:
bryanthaboi
2026-09-22 20:12:00 -04:00
committed by GitHub
36 changed files with 1249 additions and 83 deletions
+1 -1
View File
@@ -141,7 +141,7 @@ GameVersion.VERSIONS = {
},
manifest = "tools/rom_manifest_leafgreen.json",
cachePrefix = "leafgreen/", saveSuffix = "_leafgreen",
generation = 3, engine = "game3", cartShape = "gba", cartShell = "#80bc28",
generation = 3, engine = "game3", cartShape = "gba", cartShell = "#26a24e",
cartLabel = "assets/labels/leafgreen.png",
},
}
+6 -1
View File
@@ -518,7 +518,12 @@ function Audio.resumeBgm()
Audio._bgmPaused = false
if Audio._cmdCh then Audio._cmdCh:push({ cmd = "resume" }) end
if Audio._bgmSource then
pcall(function() Audio._bgmSource:setVolume(bgm_gain()) end)
pcall(function()
Audio._bgmSource:setVolume(bgm_gain())
if not Audio._bgmSource:isPlaying() then
Audio._bgmSource:play()
end
end)
end
Audio.pumpBgm()
end
+18 -4
View File
@@ -105,17 +105,28 @@ function CatchSeq.begin(st, itemId, caught, shakes, opts)
local ename = (emon and ((emon.nickname ~= "" and emon.nickname) or emon.name))
or Pokemon.name(st and st.enemy and st.enemy.species) or "POKéMON"
-- pokefirered/src/battle_message.c: STRINGID_OLDMANUSEDITEM vs STRINGID_PLAYERUSEDITEM
local throwMsg
if st and st.oldManTutorial then
throwMsg = Strings("OLD MAN used\n%s!", ballName)
else
throwMsg = Strings("%s used\nthe %s!", playerName, ballName)
end
-- pokefirered/data/battle_scripts_2.s:124
local DODGE = Strings("It dodged the thrown BALL!\nThis POKéMON can't be caught!")
if opts.ghostDodge then CatchSeq._result = "fail_catch" end
if CatchSeq._headless then
if CatchSeq._pushMsg then
CatchSeq._pushMsg(Strings("%s used\nthe %s!", playerName, ballName))
CatchSeq._pushMsg(throwMsg)
end
if opts.ghostDodge then
if CatchSeq._pushMsg then CatchSeq._pushMsg(DODGE) end
elseif caught then
local res = Catching.storeCaught(session, st and st.enemy, itemId)
local res = nil
if not (st and st.oldManTutorial) then
res = Catching.storeCaught(session, st and st.enemy, itemId)
end
CatchSeq._catchResult = res
if CatchSeq._pushMsg then
CatchSeq._pushMsg(Strings("Gotcha!\n%s was caught!", ename))
@@ -150,7 +161,7 @@ function CatchSeq.begin(st, itemId, caught, shakes, opts)
steps[#steps + 1] = { kind = kind, data = data or {} }
end
add("msg", { text = Strings("%s used\nthe %s!", playerName, ballName), wait = opts.ghostDodge and 0 or nil })
add("msg", { text = throwMsg, wait = opts.ghostDodge and 0 or nil })
-- pokefirered/src/battle_script_commands.c:9590
add("throw", {
@@ -761,7 +772,10 @@ local function run_step(step)
end
if kind == "capture_success" then
local res = Catching.storeCaught(CatchSeq._session, CatchSeq._st and CatchSeq._st.enemy, d.ballId)
local res = nil
if not (CatchSeq._st and CatchSeq._st.oldManTutorial) then
res = Catching.storeCaught(CatchSeq._session, CatchSeq._st and CatchSeq._st.enemy, d.ballId)
end
CatchSeq._catchResult = res
local ename = d.ename or "POKéMON"
-- pokefirered/src/battle_message.c:475
+3
View File
@@ -183,6 +183,9 @@ end
-- Returns: caught (bool), shakes (0..4).
-- pokefirered/src/battle_script_commands.c:9463
function Catching.tryCatch(itemId, foeBattler, st, session, rng)
if st and st.oldManTutorial then
return true, 4
end
local caught, shakes
if ModRuntime.wantsHook("catch.rate") then
local G3 = require("src.mods.Gen3Compat")
+4 -2
View File
@@ -1895,7 +1895,9 @@ function Engine.performSwitch(st, adapter, side, slot, opts)
if not old or not party or not party[slot] then return nil end
local oldSlot = old.partyIndex
if side == "player" and not st.double then
State.trackParticipant(st, st.enemy, oldSlot or 1)
if not opts.isShift and opts.reason ~= "shift" then
State.trackParticipant(st, st.enemy, oldSlot or 1)
end
end
Engine.switchOutEffects(st, adapter, old)
State.syncBattlerToParty(old, party)
@@ -1949,7 +1951,7 @@ function Engine.performSwitch(st, adapter, side, slot, opts)
State.trackParticipant(st, st.enemy, slot)
elseif st.player then
-- pokefirered/src/battle_util.c:254
State.trackParticipant(st, nb, st.player.partyIndex)
State.opponentSwitchInResetSentPokes(st, nb)
end
if foe then
if foe.expSeedSource == old then foe.expSeedSource = nb end
+1 -1
View File
@@ -250,7 +250,7 @@ function Experience.awardFoe(st, foeBattler, opts)
local sentIn = {}
if opts.partyIndices then
for _, pi in ipairs(opts.partyIndices) do sentIn[pi] = true end
elseif st.double and foeBattler.participants then
elseif foeBattler.participants and next(foeBattler.participants) then
-- pokefirered/src/battle_script_commands.c:3123
for pi in pairs(foeBattler.participants) do sentIn[pi] = true end
elseif st.player and st.player.mon and (tonumber(st.player.mon.hp) or 0) > 0 then
+22 -10
View File
@@ -180,6 +180,8 @@ local function foe_mon_from(foe)
end
local mon = {
species = species,
name = foe.name or foe.nickname,
nickname = foe.nickname,
level = foe.level or 5,
hp = foe.hp,
maxHp = foe.maxHp,
@@ -444,6 +446,7 @@ function Battle.start(opts)
-- pokefirered/src/battle_script_commands.c:4520
if session and session.dex and foeMon and (foeMon.species or foeMon.speciesId)
and not st.link
and not st.oldManTutorial
and not (st.ghostBattle and not st.ghostUnveiled) then
local Dex = require("src.core.game3.dex")
Dex.setSeen(session.dex, foeMon.species or foeMon.speciesId)
@@ -650,7 +653,7 @@ function Battle.start(opts)
Ui.push(strings.sentOut)
end
-- pokefirered/src/battle_main.c:2801
if not st.double and not st.safari then
if not st.double and not st.safari and not st.oldManTutorial then
Ui.push(Strings("Go! %s!", State.displayName(st.player)))
end
-- pokefirered/src/battle_controller_oak_old_man.c:626
@@ -885,6 +888,13 @@ end
Battle._refuseLinkItem = refuse_link_item
local function auto_player_action(st)
if st and st.oldManTutorial then
return { kind = "bag", itemId = 4, user = "player" }
end
return Commands.playerAction(st, 1, 1)
end
local function begin_turn_with(playerAct)
local st = Battle._st
if st and st.double then return D.startSelection() end
@@ -957,7 +967,7 @@ local function send_out_enemy_next(nextEnemyIdx)
local onDone = function()
Battle._phase = "command"
if Battle._auto then
begin_turn_with(Commands.playerAction(st, 1, 1))
begin_turn_with(auto_player_action(st))
else
Ui.openMenu()
end
@@ -1023,7 +1033,7 @@ local function handle_player_faint(opts)
onDone = function()
Battle._phase = "command"
if Battle._auto then
begin_turn_with(Commands.playerAction(st, 1, 1))
begin_turn_with(auto_player_action(st))
else
Ui.openMenu()
end
@@ -1590,14 +1600,16 @@ local function step_action()
return
end
local Bag = require("src.core.game3.bag")
if not bag or not Bag.has(bag, meta.itemId, 1) then
if not st.oldManTutorial and (not bag or not Bag.has(bag, meta.itemId, 1)) then
Ui.push(Strings("You don't have that item."))
Battle._actions = {}
Battle._phase = "command"
Ui.openMenu()
return
end
Bag.remove(bag, meta.itemId, 1)
if not st.oldManTutorial then
Bag.remove(bag, meta.itemId, 1)
end
local rng = ad and ad.rng and ad:rng() or st.rng
local caught, shakes = Catching.tryCatch(meta.itemId, st.enemy, st, session, rng)
local pushFn = function(text) Ui.push(text) end
@@ -2605,7 +2617,7 @@ local function finish_catch_flow(catchRes, ename, nicknamed)
end
local function start_post_catch_flow(catchRes)
if Battle._headless then
if Battle._headless or (Battle._st and Battle._st.oldManTutorial) then
Battle._actions = {}
Battle._pendingEnd = "catch"
Battle._phase = "ending"
@@ -2808,7 +2820,7 @@ function Battle.update(dt, game)
if begin_start_effects() then return end
Battle._phase = "command"
if Battle._auto then
begin_turn_with(Commands.playerAction(Battle._st, 1, 1))
begin_turn_with(auto_player_action(Battle._st))
else
Ui.openMenu()
end
@@ -2822,7 +2834,7 @@ function Battle.update(dt, game)
if AnimSeq.update() then
Battle._phase = "command"
if Battle._auto then
begin_turn_with(Commands.playerAction(Battle._st, 1, 1))
begin_turn_with(auto_player_action(Battle._st))
else
Ui.openMenu()
end
@@ -3030,7 +3042,7 @@ function Battle.update(dt, game)
end
Battle._phase = "command"
if Battle._auto then
begin_turn_with(Commands.playerAction(Battle._st, 1, 1))
begin_turn_with(auto_player_action(Battle._st))
else
Ui.openMenu()
end
@@ -3074,7 +3086,7 @@ function Battle.update(dt, game)
end
if Battle._phase == "command" and Battle._auto then
begin_turn_with(Commands.playerAction(Battle._st, 1, 1))
begin_turn_with(auto_player_action(Battle._st))
end
end
+11 -4
View File
@@ -183,6 +183,7 @@ local function build_wild(st, opts)
end
local ename = State.displayName(st.enemy)
local pname = State.displayName(st.player)
local playerGender = (st.oldManTutorial and 5) or opts.playerGender or 0
add("fade", { mode = "FROM_BLACK", instant = true })
-- pret: player back sprite slides in with the BG intro even in wild battles
-- (BattleIntroDrawTrainersOrMonsSprites → EmitDrawTrainerPic for PLAYER_LEFT).
@@ -198,7 +199,7 @@ local function build_wild(st, opts)
enemyMonTo = 0,
slideFrames = 120,
darken = 10 / 16,
gender = opts.playerGender or 0,
gender = playerGender,
})
add("cry", { side = "enemy" }) add("undarken", { side = "enemy", frames = 10 })
add("healthbox", { side = "enemy", frames = 23, from = -115 })
@@ -222,6 +223,13 @@ local function build_wild(st, opts)
add("wait", { frames = 3 })
return steps
end
if st.oldManTutorial then
-- pokefirered/src/battle_controller_oak_old_man.c
-- In Oak/Old Man tutorial, the player's Pokémon is not sent out and there is no player healthbox.
-- The Old Man backsprite stays at (0, 0) and the battle transitions straight to action selection.
add("wait", { frames = 3 })
return steps
end
-- pokefirered/src/battle_message.c:399
add("msg", { text = Strings("Go! %s!", pname), linger = true })
add("player_throw", {})
@@ -349,11 +357,10 @@ function IntroSeq.begin(st, opts)
end
end
-- Park terrain and sliding sprites off-screen immediately so the first
-- rendered frame (and fade-in) starts with them in initial slide positions.
local playerGender = (st.oldManTutorial and 5) or opts.playerGender or 0
s.bgSlide = { enemyOx = -240, playerOx = 240 }
s.trainer.player.visible = true
s.trainer.player.gender = opts.playerGender or 0
s.trainer.player.gender = playerGender
s.trainer.player.ox = 240
s.trainer.player.frame = 0
+13 -1
View File
@@ -326,7 +326,7 @@ function State.resetSentPokes(st)
local sent = {}
for _, id in ipairs({ 0, 2 }) do
local b = State.battler(st, id)
if b and b.partyIndex then sent[#sent + 1] = b.partyIndex end
if b and b.partyIndex and not State.isAbsent(st, id) then sent[#sent + 1] = b.partyIndex end
end
for _, id in ipairs({ 1, 3 }) do
local foe = State.battler(st, id)
@@ -335,6 +335,12 @@ function State.resetSentPokes(st)
for _, pi in ipairs(sent) do foe.participants[pi] = true end
end
end
if st.enemy and not st.enemy.participants then
st.enemy.participants = {}
if st.player and st.player.partyIndex and not State.isAbsent(st, 0) then
st.enemy.participants[st.player.partyIndex] = true
end
end
end
-- pokefirered/src/battle_util.c:254
@@ -347,6 +353,9 @@ function State.opponentSwitchInResetSentPokes(st, foeBattler)
foeBattler.participants[b.partyIndex] = true
end
end
if not st.double and st.player and not State.isAbsent(st, 0) and st.player.partyIndex then
foeBattler.participants[st.player.partyIndex] = true
end
end
-- pokefirered/src/battle_util.c:273
@@ -359,6 +368,9 @@ function State.updateSentPokes(st, battler)
local foe = State.battler(st, id)
if foe then State.trackParticipant(st, foe, battler.partyIndex) end
end
if not st.double and st.enemy then
State.trackParticipant(st, st.enemy, battler.partyIndex)
end
end
function State.displayName(battler)
+15 -8
View File
@@ -273,7 +273,8 @@ function SwitchSeq.beginSendOut(st, side, newSlot, opts)
SwitchSeq._pushMsg(Strings("Go! %s!", State.displayName(st.player)))
end
else
st.enemy = State.makeBattler(st.foeParty[newSlot], "enemy", { partyIndex = newSlot, st = st })
st.enemy = State.makeBattler(st.foeParty[newSlot], "enemy", { partyIndex = newSlot, st = st, state = st })
State.opponentSwitchInResetSentPokes(st, st.enemy)
Anim.syncDisplayFromState(st)
if SwitchSeq._pushMsg then
local tname = (st.trainerClassName and st.trainerClassName ~= "")
@@ -422,7 +423,6 @@ function SwitchSeq.beginShiftSwitch(st, playerSlot, enemySlot, opts)
if SwitchSeq._headless then
if SwitchSeq._pushMsg then SwitchSeq._pushMsg(withdrawMsg) end
switch_out_effects(st, oldBattler)
State.trackParticipant(st, st.enemy, oldBattler and oldBattler.partyIndex or 1)
State.syncBattlerToParty(st.player, st.playerParty)
State.wipeVolatilesAndStages(st.player)
st.enemy = State.makeBattler(st.foeParty[enemySlot], "enemy", { partyIndex = enemySlot, st = st })
@@ -430,7 +430,8 @@ function SwitchSeq.beginShiftSwitch(st, playerSlot, enemySlot, opts)
and (st.trainerClassName .. " " .. (st.trainerName or ""))
or (st.trainerName or "TRAINER")
if SwitchSeq._pushMsg then SwitchSeq._pushMsg(Strings("%s sent\nout %s!", tname, State.displayName(st.enemy))) end
st.player = State.makeBattler(st.playerParty[playerSlot], "player", { partyIndex = playerSlot, st = st })
st.player = State.makeBattler(st.playerParty[playerSlot], "player", { partyIndex = playerSlot, st = st, state = st })
State.opponentSwitchInResetSentPokes(st, st.enemy)
State.trackParticipant(st, st.enemy, playerSlot)
Anim.syncDisplayFromState(st)
if SwitchSeq._pushMsg then SwitchSeq._pushMsg(Strings("Go! %s!", State.displayName(st.player))) end
@@ -444,14 +445,14 @@ function SwitchSeq.beginShiftSwitch(st, playerSlot, enemySlot, opts)
{ kind = "msg", data = { text = withdrawMsg } },
{ kind = "withdraw", data = { side = "player" } },
-- 2. Enemy sendout first (retail FRLG order)
{ kind = "swap_data", data = { side = "enemy", newSlot = enemySlot } },
{ kind = "swap_data", data = { side = "enemy", newSlot = enemySlot, isShift = true, reason = "shift" } },
{ kind = "msg_sendout", data = { side = "enemy" } },
{ kind = "sendout_enemy", data = { slot = enemySlot } },
{ kind = "shiny_check", data = { side = "enemy" } },
{ kind = "cry", data = { side = "enemy" } },
{ kind = "healthbox", data = { side = "enemy" } },
-- 3. Player sendout second
{ kind = "swap_data", data = { side = "player", newSlot = playerSlot } },
{ kind = "swap_data", data = { side = "player", newSlot = playerSlot, isShift = true, reason = "shift" } },
{ kind = "msg_sendout", data = { side = "player" } },
{ kind = "sendout_player", data = { slot = playerSlot } },
{ kind = "shiny_check", data = { side = "player" } },
@@ -558,7 +559,7 @@ local function run_step(step)
if Engine and Engine.performSwitch and battle_adapter() and step_battler(st, d) and party and party[newSlot] then
capture_events(function(ad)
Engine.performSwitch(st, ad, d.id ~= nil and d.id or side, newSlot,
{ batonPass = d.batonPass, reason = d.reason or "switch" })
{ batonPass = d.batonPass, reason = d.reason or "switch", isShift = d.isShift })
end)
Anim.syncDisplayFromState(st)
advance()
@@ -567,7 +568,9 @@ local function run_step(step)
switch_out_effects(st, st and st[side])
if side == "player" then
if st and st.player then
State.trackParticipant(st, st.enemy, st.player.partyIndex or 1)
if not d.isShift and d.reason ~= "shift" then
State.trackParticipant(st, st.enemy, st.player.partyIndex or 1)
end
State.syncBattlerToParty(st.player, st.playerParty)
State.wipeVolatilesAndStages(st.player, { batonPass = d.batonPass })
end
@@ -578,7 +581,11 @@ local function run_step(step)
State.syncBattlerToParty(st.enemy, st.foeParty)
State.wipeVolatilesAndStages(st.enemy)
end
st.enemy = State.makeBattler(st.foeParty[newSlot], "enemy", { partyIndex = newSlot, st = st })
st.enemy = State.makeBattler(st.foeParty[newSlot], "enemy", { partyIndex = newSlot, st = st, state = st })
State.opponentSwitchInResetSentPokes(st, st.enemy)
if d.isShift or d.reason == "shift" then
st.enemy.participants = {}
end
end
Anim.syncDisplayFromState(st)
advance()
+45 -8
View File
@@ -204,6 +204,8 @@ function Ui.reset(opts)
Ui._partnerAction = nil
Ui._oak = nil
Ui._oakTexts = nil
Ui._oldManTimer = nil
Ui._oldManSubstate = nil
if Message and Message.isHeld and Message.isHeld() then Message.close() end
if not Ui._headless then
local okC, errC = pcall(BattleChrome.install, nil)
@@ -566,6 +568,14 @@ function Ui.openMenu(battlerId, opts)
Ui._menuIndex = 1
end
Ui._pendingCommand = nil
if Ui._st and Ui._st.oldManTutorial then
Ui._oldManTimer = 0
Ui._oldManSubstate = 0
if Ui._headless then
Ui._pendingCommand = { kind = "bag", itemId = 4, user = "player" }
Ui._mode = "none"
end
end
if Message and Message.open then
Message.open = false
end
@@ -1076,6 +1086,27 @@ function Ui.tick()
end
tick_bounces()
tick_target()
if Ui._st and Ui._st.oldManTutorial and Ui._mode == "menu" and not Ui._headless then
-- pokefirered/src/battle_controller_oak_old_man.c: SimulateInputChooseAction
if Ui._oldManSubstate == 0 then
Ui._oldManTimer = (Ui._oldManTimer or 0) + 1
if Ui._oldManTimer >= 64 then
play_select()
Ui._menuIndex = 2 -- BAG
Ui._oldManTimer = 0
Ui._oldManSubstate = 1
end
elseif Ui._oldManSubstate == 1 then
Ui._oldManTimer = (Ui._oldManTimer or 0) + 1
if Ui._oldManTimer >= 64 then
play_select()
Ui._pendingCommand = { kind = "bag", itemId = 4, user = "player" }
Ui._mode = "none"
Ui._oldManSubstate = nil
Ui._oldManTimer = nil
end
end
end
elseif m ~= "bag" and m ~= "party" then
end_all_bounces()
end
@@ -1363,6 +1394,10 @@ function Ui.handleInput(input)
end
if not Ui.waitingForCommand() then return false end
if Ui._st and Ui._st.oldManTutorial then
-- Old Man tutorial script controls the actions automatically
return true
end
if is_double() then return handle_double_input(input) end
if Ui._mode == "menu" then
local idx, moved = grid_nav(Ui._menuIndex, input, 4)
@@ -1789,6 +1824,9 @@ local function draw_action_menu(st)
local pname = (st.playerName ~= nil and st.playerName ~= "" and st.playerName) or "RED"
draw_prompt_text(Strings("What will %s\nthrow?", pname), 10, 122)
labels = { Strings("BALL"), Strings("BAIT"), Strings("ROCK"), Strings("RUN") }
elseif st and st.oldManTutorial then
-- pokefirered/src/battle_message.c: gText_WhatWillOldManDo
draw_prompt_text(Strings("What will\nOLD MAN do?"), 10, 122)
else
draw_prompt_text(Strings("What will\n%s do?", name), 10, 122)
end
@@ -1864,17 +1902,16 @@ local function draw_player_trainer(stage)
local TrainerPic = require("src.core.game3.trainer_pic")
local tp = stage.trainer.player
if tp and tp.visible then
local entry = TrainerPic.back(tp.gender or 0)
local gender = tp.gender or 0
local entry = TrainerPic.back(gender)
if entry and entry.image then
local frame = math.max(0, math.min(4, tonumber(tp.frame) or 0))
local q = stage._backQuad
if not q and love and love.graphics then
-- quads cached on stage weakly; recreate each frame is fine for one sprite
end
local key = "back_" .. tostring(frame)
local maxFrame = math.max(0, (entry.frames or 5) - 1)
local frame = math.max(0, math.min(maxFrame, tonumber(tp.frame) or 0))
local key = "back_" .. tostring(gender) .. "_" .. tostring(frame)
Ui._trainerQuads = Ui._trainerQuads or {}
if not Ui._trainerQuads[key] then
Ui._trainerQuads[key] = love.graphics.newQuad(0, frame * 64, 64, 64, 64, 320)
local imgH = entry.h or (entry.frames and entry.frames * 64) or 320
Ui._trainerQuads[key] = love.graphics.newQuad(0, frame * 64, 64, 64, entry.w or 64, imgH)
end
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(
+12
View File
@@ -1171,6 +1171,14 @@ function FieldView.draw(game, canvasW, canvasH, opts)
end
end
-- S.S. Anne wake (pret oam.priority = 2, subpriority = 0xFF: under boat hull).
if not opts.actorsOnly then
local okSS, SSAnne = pcall(require, "src.core.game3.ss_anne_cutscene")
if okSS and SSAnne and SSAnne.drawWake then
SSAnne.drawWake(camX, camY)
end
end
-- Collect Game3 actors partitioned by OAM priority.
local underActors, overActors = nil, nil
if not opts.skipActors then
@@ -1226,6 +1234,10 @@ function FieldView.draw(game, canvasW, canvasH, opts)
FieldEffects.drawOverlay(camX, camY)
love.graphics.pop()
end
local okSS, SSAnne = pcall(require, "src.core.game3.ss_anne_cutscene")
if okSS and SSAnne and SSAnne.drawSmoke then
SSAnne.drawSmoke(camX, camY)
end
end
drawFlashMask(canvasW, canvasH)
+14 -4
View File
@@ -426,7 +426,7 @@ function Player.snapshotSlot(slot, at)
end
-- pokefirered/src/m4a.c:668
function Player.stopAt(slot, snaps, at, abs)
function Player.stopAt(slot, snaps, at, abs, pack, cache)
if not slot then return abs end
at = tonumber(at)
local snap
@@ -453,10 +453,20 @@ function Player.stopAt(slot, snaps, at, abs)
if snaps[i].at > at then table.remove(snaps, i) end
end
abs = at
elseif at and slot.songId and (abs == nil or at < abs) and (pack or slot.pack) and (cache or slot.cache) then
Player.start(pack or slot.pack, cache or slot.cache, slot, slot.songId, { forceSeq = true })
if snaps then
for i = #snaps, 1, -1 do table.remove(snaps, i) end
end
local left = at
local q = Player.mixQuantum()
while left > 0 do
local n = math.min(q, left)
Player.renderBuffered(slot, n, { raw = true })
left = left - n
end
abs = at
end
-- pokefirered/src/m4a_1.s:1469
if slot.seq then slot.seq.voices = {} end
slot.voices = {}
slot.hpfState = { l = 0, r = 0 }
slot.reverbState = Mix.newReverb(slot.reverb or 0)
return abs
+2 -2
View File
@@ -117,9 +117,9 @@ local function apply_cmd(msg)
snaps = {}
elseif msg.cmd == "stopAt" then
bgm.epoch = msg.epoch
bgm.abs = Player.stopAt(bgm, snaps, msg.at, bgm.abs) or bgm.abs
bgm.abs = Player.stopAt(bgm, snaps, msg.at, bgm.abs, pack, cache) or bgm.abs
Mix._hpfCapL, Mix._hpfCapR = 0, 0
bgm.muted = false
bgm.muted = true
elseif msg.cmd == "pause" then
bgm.muted = true
elseif msg.cmd == "resume" then
+19 -5
View File
@@ -259,6 +259,8 @@ local function rememberPerm(mapId, localId, fields)
end
end
Objects.rememberPerm = rememberPerm
local function applyPerm(eo, mapId)
local bucket = Objects._perm[mapId]
local row = bucket and bucket[eo.localId]
@@ -603,7 +605,7 @@ local function beginStep(eo, tx, ty)
eo.animClock = 0
end
local function finishStep(eo)
local function finishStep(eo, game)
eo.cellX = eo.targetX
eo.cellY = eo.targetY
eo.px = eo.cellX * CELL
@@ -619,9 +621,15 @@ local function finishStep(eo)
if curElev and curElev ~= 0 and curElev ~= 15 then
eo.elevation = curElev
end
if eo.sight and eo.sight > 0 and not eo.scriptBusy and not eo.frozen then
local okTs, TrainerSight = pcall(require, "src.core.game3.trainer_sight")
if okTs and TrainerSight and TrainerSight.check then
TrainerSight.check(game, eo)
end
end
end
local function tickMotion(eo)
local function tickMotion(eo, game)
if not eo.moving then return false end
eo.progress = eo.progress + 1
eo.animClock = eo.animClock + 1
@@ -632,7 +640,7 @@ local function tickMotion(eo)
eo.px = eo.cellX * CELL + dx * CELL * t
eo.py = eo.cellY * CELL + dy * CELL * t
if eo.progress >= frames then
finishStep(eo)
finishStep(eo, game)
return true
end
return false
@@ -994,6 +1002,12 @@ local function idleTick(eo, game, ctx)
beginStep(eo, tx, ty)
else
eo.facing = dir -- turn toward blocked anyway
if not ctx and eo.sight and eo.sight > 0 then
local okTs, TrainerSight = pcall(require, "src.core.game3.trainer_sight")
if okTs and TrainerSight and TrainerSight.check then
TrainerSight.check(game, eo)
end
end
end
eo.idleTimer = 40 + Rng.compat(0, 49)
end
@@ -1011,7 +1025,7 @@ function Objects.update(game)
eo.bowFrames = eo.bowFrames - 1
if eo.bowFrames <= 0 then eo.bowFrames = nil end
end
tickMotion(eo)
tickMotion(eo, game)
idleTick(eo, game)
end
end
@@ -1039,7 +1053,7 @@ function Objects.tickPool(pool, game, ctx)
eo.bowFrames = eo.bowFrames - 1
if eo.bowFrames <= 0 then eo.bowFrames = nil end
end
tickMotion(eo)
tickMotion(eo, game)
idleTick(eo, game, ctx)
end
end
+8 -1
View File
@@ -77,7 +77,14 @@ local function read_from_disk()
end
function Multichoice.tryLoadCache()
local readers = { read_from_dataset, read_from_cachefs, read_from_love, read_from_disk }
local okE, Extract = pcall(require, "src.import.gba.extract_island1")
local customRoot = okE and Extract and Extract.CACHE_ROOT and Extract.CACHE_ROOT ~= "data/generated/gba"
local readers
if customRoot then
readers = { read_from_disk }
else
readers = { read_from_disk, read_from_dataset, read_from_cachefs, read_from_love }
end
for _, reader in ipairs(readers) do
local data = parse_lists(reader())
if data then
@@ -149,12 +149,6 @@ Cutscene.HANDLERS = {
return false
end,
-- pokefirered/src/ss_anne.c:82, data/maps/SSAnne_Exterior/scripts.inc:21
[Std.SPECIAL.DoSSAnneDepartureCutscene] = function(ctx, adapters)
playSe(adapters, SE_SS_ANNE_HORN)
return false
end,
-- pokefirered/src/field_specials.c:2133, data/scripts/pokemon_league.inc:63
[Std.SPECIAL.DoPokemonLeagueLightingEffect] = function()
return false
@@ -205,6 +199,13 @@ Cutscene.HANDLERS = {
if ctx then ctx.museumFossilPic = nil end
return false
end,
-- pokefirered/src/ss_anne.c:82 DoSSAnneDepartureCutscene
[Std.SPECIAL.DoSSAnneDepartureCutscene] = function(ctx, adapters)
local SSAnne = require("src.core.game3.ss_anne_cutscene")
local Natives = require("src.core.game3.scripting.natives")
Natives.awaitState(ctx, SSAnne.start(ctx, adapters))
return false
end,
}
return Cutscene
+31 -6
View File
@@ -1622,12 +1622,37 @@ local function dispatch(vm, row)
})
end
if isRematch then
-- pokefirered/src/battle_setup.c:848
local Objects = package.loaded["src.core.game3.objects"]
local eo = Objects and Objects.find and Objects.find(lastTalked)
if eo and Objects.setTrainerMovementType and not (Objects.isPlayer and Objects.isPlayer(lastTalked)) then
Objects.setTrainerMovementType(eo, VsSeeker.faceTypeFor(eo.facing))
-- pokefirered/src/battle_setup.c:848 SetUpTrainerMovement
local Objects = package.loaded["src.core.game3.objects"]
local eo = Objects and Objects.find and Objects.find(lastTalked)
if eo and not (Objects.isPlayer and Objects.isPlayer(lastTalked)) then
local faceMt = ({ down = 0x08, up = 0x07, left = 0x09, right = 0x0A })[eo.facing] or 0x08
if Objects.setTrainerMovementType then
Objects.setTrainerMovementType(eo, faceMt)
else
eo.movementType = faceMt
eo.movement = "STAY"
eo.range = (eo.facing or "down"):upper()
end
if Objects.overrideTemplateMovementType then
Objects.overrideTemplateMovementType(eo.localId, faceMt)
end
eo.homeX = eo.cellX
eo.homeY = eo.cellY
if eo.def then
eo.def.movementType = faceMt
eo.def.movement = "STAY"
eo.def.x = eo.cellX
eo.def.y = eo.cellY
eo.def.range = (eo.facing or "down"):upper()
end
if Objects.rememberPerm and Objects._mapId then
Objects.rememberPerm(Objects._mapId, eo.localId, {
x = eo.cellX,
y = eo.cellY,
movementType = faceMt,
facing = eo.facing,
})
end
end
+1
View File
@@ -43,6 +43,7 @@ Std.SPECIAL = {
BackupHelpContext = 0x17E,
RestoreHelpContext = 0x17F,
SetHelpContextForMap = 0x190,
DoSSAnneDepartureCutscene = 0x191, -- pokefirered/data/specials.inc:412
HelpSystem_Disable = 0x198,
HelpSystem_Enable = 0x199,
StartMarowakBattle = 0x156, -- pokefirered/data/specials.inc:353
+312
View File
@@ -0,0 +1,312 @@
-- S.S. Anne departure cutscene matching pret pokefirered (src/ss_anne.c).
-- Manages the ship's horn sound effects, wake trailing animation, smoke puffs,
-- leftward sailing motion, and script coordination.
local SSAnneCutscene = {}
-- Audio constants (pokefirered/include/constants/songs.h:249)
local SE_SS_ANNE_HORN = 249
-- Timing constants matching pokefirered/src/ss_anne.c
local INIT_FRAMES = 50 -- Task_SSAnneInit countdown
local SMOKE_INTERVAL = 70 -- Task_SSAnneRun smoke puff period
local SLIDE_SPEED_DIV = 5 -- 1 pixel movement every 5 frames (x = data[2] / 5)
local TRAVEL_DISTANCE = 216 -- pixels to travel until boat is fully off-screen
local FINISH_FRAMES = 40 -- Task_SSAnneFinish delay after exit horn
SSAnneCutscene._active = false
SSAnneCutscene._phase = "idle" -- "init" | "run" | "finish" | "done"
SSAnneCutscene._initTimer = 0
SSAnneCutscene._runTimer1 = 0 -- smoke timer
SSAnneCutscene._runTimer2 = 0 -- motion timer
SSAnneCutscene._finishTimer = 0
SSAnneCutscene._boatOffset = 0
SSAnneCutscene._wake = nil
SSAnneCutscene._smokes = {}
SSAnneCutscene._wakeImage = nil
SSAnneCutscene._wakeQuads = nil
SSAnneCutscene._smokeImage = nil
SSAnneCutscene._smokeQuads = nil
local function playSe(id)
local ad = SSAnneCutscene._adapters
if ad and ad.playSe then
pcall(ad.playSe, id)
return
end
local okA, Audio = pcall(require, "src.core.game3.audio")
if okA and Audio and Audio.playSe then
pcall(Audio.playSe, id)
end
end
local function makeTransparentImage(path)
if not (love and love.image and love.image.newImageData and love.graphics and love.graphics.newImage) then
return nil
end
local okData, imgData = pcall(love.image.newImageData, path)
if not (okData and imgData) then return nil end
local w, h = imgData:getWidth(), imgData:getHeight()
for y = 0, h - 1 do
for x = 0, w - 1 do
local r, g, b, _ = imgData:getPixel(x, y)
local r8 = math.floor(r * 255 + 0.5)
local g8 = math.floor(g * 255 + 0.5)
local b8 = math.floor(b * 255 + 0.5)
-- Key out GBA background blue palette entry (57, 115, 180)
if (r8 >= 45 and r8 <= 68) and (g8 >= 105 and g8 <= 130) and (b8 >= 165 and b8 <= 195) then
imgData:setPixel(x, y, 0, 0, 0, 0)
end
end
end
local img = love.graphics.newImage(imgData)
if img.setFilter then img:setFilter("nearest", "nearest") end
return img
end
local function loadGfx()
if not (love and love.graphics and love.graphics.newQuad) then
return
end
if not SSAnneCutscene._wakeImage then
local paths = {
"pokefirered/graphics/ss_anne/wake_transparent.png",
"pokefirered/graphics/ss_anne/wake.png",
"graphics/ss_anne/wake.png",
}
for _, p in ipairs(paths) do
local img = makeTransparentImage(p)
if img then
SSAnneCutscene._wakeImage = img
-- 16x64 image with two 16x32 frames
SSAnneCutscene._wakeQuads = {
[0] = love.graphics.newQuad(0, 0, 16, 32, 16, 64),
[1] = love.graphics.newQuad(0, 32, 16, 32, 16, 64),
}
break
end
end
end
if not SSAnneCutscene._smokeImage then
local paths = {
"pokefirered/graphics/ss_anne/smoke_transparent.png",
"pokefirered/graphics/ss_anne/smoke.png",
"graphics/ss_anne/smoke.png",
}
for _, p in ipairs(paths) do
local img = makeTransparentImage(p)
if img then
SSAnneCutscene._smokeImage = img
local sw, sh = 16, 72
if img.getDimensions then
local iw, ih = img:getDimensions()
if iw and ih then sw, sh = iw, ih end
elseif img.getHeight and img:getHeight() then
sh = img:getHeight()
end
-- Four 16x16 frames
SSAnneCutscene._smokeQuads = {
[0] = love.graphics.newQuad(0, 0, 16, 16, sw, sh),
[1] = love.graphics.newQuad(0, 16, 16, 16, sw, sh),
[2] = love.graphics.newQuad(0, 32, 16, 16, sw, sh),
[3] = love.graphics.newQuad(0, 48, 16, 16, sw, sh),
}
break
end
end
end
end
function SSAnneCutscene.isActive()
return SSAnneCutscene._active
end
function SSAnneCutscene.reset()
SSAnneCutscene._active = false
SSAnneCutscene._phase = "idle"
SSAnneCutscene._initTimer = 0
SSAnneCutscene._runTimer1 = 0
SSAnneCutscene._runTimer2 = 0
SSAnneCutscene._finishTimer = 0
SSAnneCutscene._boatOffset = 0
SSAnneCutscene._wake = nil
SSAnneCutscene._smokes = {}
end
--- pokefirered/src/ss_anne.c:82 DoSSAnneDepartureCutscene
function SSAnneCutscene.start(ctx, adapters)
SSAnneCutscene.reset()
SSAnneCutscene._adapters = adapters
SSAnneCutscene._active = true
SSAnneCutscene._phase = "init"
SSAnneCutscene._initTimer = INIT_FRAMES
-- Initial horn sound
playSe(SE_SS_ANNE_HORN)
loadGfx()
return function()
return SSAnneCutscene.step()
end
end
--- Ticked each frame during waitstate. Returns true when cutscene is fully finished.
function SSAnneCutscene.step()
if not SSAnneCutscene._active then return true end
local Objects = package.loaded["src.core.game3.objects"]
local eo = Objects and Objects.find and Objects.find(1)
if SSAnneCutscene._phase == "init" then
SSAnneCutscene._initTimer = SSAnneCutscene._initTimer - 1
if SSAnneCutscene._initTimer <= 0 then
-- Task_SSAnneInit finishes: creates wake sprite and switches to Task_SSAnneRun
SSAnneCutscene._phase = "run"
SSAnneCutscene._wake = {
timer = 0,
x2 = 0,
frame = 0,
}
end
return false
end
if SSAnneCutscene._phase == "run" then
SSAnneCutscene._runTimer1 = SSAnneCutscene._runTimer1 + 1
SSAnneCutscene._runTimer2 = SSAnneCutscene._runTimer2 + 1
-- Smoke puff creation every 70 frames
if SSAnneCutscene._runTimer1 == SMOKE_INTERVAL then
SSAnneCutscene._runTimer1 = 0
table.insert(SSAnneCutscene._smokes, {
timer = 0,
x2 = 0,
frame = 0,
animEnded = false,
boatOffsetAtSpawn = SSAnneCutscene._boatOffset,
})
end
-- Boat movement: x = data[2] / 5 (1 pixel every 5 frames)
SSAnneCutscene._boatOffset = math.floor(SSAnneCutscene._runTimer2 / SLIDE_SPEED_DIV)
if eo then
eo.raiseX = -SSAnneCutscene._boatOffset
end
-- Update wake sprite
if SSAnneCutscene._wake then
local w = SSAnneCutscene._wake
if math.floor(w.timer / 6) < 22 then
w.timer = w.timer + 1
end
w.x2 = math.floor(w.timer / 6)
-- 12 ticks per frame, looping between frame 0 and frame 1
w.frame = (math.floor(w.timer / 12) % 2 == 0) and 0 or 1
end
-- Update smoke sprites
local activeSmokes = {}
for _, s in ipairs(SSAnneCutscene._smokes) do
s.timer = s.timer + 1
s.x2 = math.floor(s.timer / 4)
if s.timer < 10 then
s.frame = 0
elseif s.timer < 30 then
s.frame = 1
elseif s.timer < 50 then
s.frame = 2
elseif s.timer < 80 then
s.frame = 3
else
s.animEnded = true
end
if not s.animEnded then
activeSmokes[#activeSmokes + 1] = s
end
end
SSAnneCutscene._smokes = activeSmokes
-- Exit check: when boat moves completely off-screen
if SSAnneCutscene._boatOffset >= TRAVEL_DISTANCE then
-- Final horn sound
playSe(SE_SS_ANNE_HORN)
SSAnneCutscene._phase = "finish"
SSAnneCutscene._finishTimer = 0
end
return false
end
if SSAnneCutscene._phase == "finish" then
SSAnneCutscene._finishTimer = SSAnneCutscene._finishTimer + 1
if SSAnneCutscene._finishTimer >= FINISH_FRAMES then
-- Task_SSAnneFinish complete: clean up and unblock script
SSAnneCutscene._active = false
SSAnneCutscene._phase = "done"
if eo then
eo.raiseX = 0
end
return true
end
return false
end
return true
end
--- Draw wake under boat actors (pret oam.priority = 2, subpriority = 0xFF).
function SSAnneCutscene.drawWake(camX, camY)
if not SSAnneCutscene._active then return end
if not (SSAnneCutscene._wake and SSAnneCutscene._wakeImage and SSAnneCutscene._wakeQuads) then return end
local Objects = package.loaded["src.core.game3.objects"]
local eo = Objects and Objects.find and Objects.find(1)
if not eo then return end
local curBoatPx = (eo.px or (eo.cellX * 16)) - SSAnneCutscene._boatOffset
local boatPy = eo.py or (eo.cellY * 16)
local boatLeft = curBoatPx - camX - 56
local boatTop = boatPy - camY - 48
local w = SSAnneCutscene._wake
local q = SSAnneCutscene._wakeQuads[w.frame or 0]
if q then
local wx = boatLeft + 106 + (w.x2 or 0)
local wy = boatTop + 24
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(SSAnneCutscene._wakeImage, q, wx, wy)
end
end
--- Draw smoke puffs rising from smokestack in overlay space (pret oam.priority = 0).
function SSAnneCutscene.drawSmoke(camX, camY)
if not SSAnneCutscene._active then return end
if not (SSAnneCutscene._smokeImage and SSAnneCutscene._smokeQuads and #SSAnneCutscene._smokes > 0) then return end
local Objects = package.loaded["src.core.game3.objects"]
local eo = Objects and Objects.find and Objects.find(1)
if not eo then return end
local boatPy = eo.py or (eo.cellY * 16)
local boatTop = boatPy - camY - 48
for _, s in ipairs(SSAnneCutscene._smokes) do
local q = SSAnneCutscene._smokeQuads[s.frame or 0]
if q then
local spawnBoatLeft = (eo.px or (eo.cellX * 16)) - s.boatOffsetAtSpawn - camX - 56
local sx = spawnBoatLeft + 78 + (s.x2 or 0)
local sy = boatTop + 2
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(SSAnneCutscene._smokeImage, q, sx, sy)
end
end
end
--- Draw wake and smoke overlay particles in world/screen space.
function SSAnneCutscene.draw(camX, camY)
SSAnneCutscene.drawWake(camX, camY)
SSAnneCutscene.drawSmoke(camX, camY)
end
return SSAnneCutscene
+51 -9
View File
@@ -47,12 +47,47 @@ local function bgr555_to_rgb8(c)
math.floor(b5 * 255 / 31 + 0.5)
end
local function wrap_rom_data(data)
if not data or #data < 0xC0 then return data end
local code = data:sub(0xAD, 0xB0)
local ver = data:byte(0xBD)
local okV, Versions = pcall(require, "src.import.gba.versions")
if okV and Versions and Versions.select then
if code == "BPGE" then
Versions.select("leafgreen")
else
Versions.select("firered")
end
end
if ver == 1 then
local sha1
if love and love.data and love.data.hash then
sha1 = love.data.hash("sha1", data)
end
local okR, RevisionView = pcall(require, "src.import.gba.revision_view")
if okR and RevisionView then
local rev = (sha1 and RevisionView.forSha1 and RevisionView.forSha1(sha1))
or (code == "BPGE" and require("src.import.gba.revisions.leafgreen_1_1"))
or require("src.import.gba.revisions.firered_1_1")
if rev and RevisionView.build then
data = RevisionView.build(data, rev)
end
end
end
return data
end
local function load_rom_bytes()
if TrainerPic._rom then return TrainerPic._rom end
local candidates = {
"1636 - Pokemon Fire Red (U)(Squirrels).gba",
"firered.gba",
"firered_dump.gba",
"Pokemon - FireRed Version (USA).gba",
"Pokemon - Fire Red Version (U) (V1.1).gba",
"Pokemon - LeafGreen Version (USA).gba",
"Pokemon - LeafGreen Version (USA, Europe) (Rev 1).gba",
"leafgreen.gba",
}
local bases = {
"",
@@ -66,8 +101,9 @@ local function load_rom_bytes()
local data = f:read("*a")
f:close()
if data and #data > 0 then
TrainerPic._rom = data
return data
local wrapped = wrap_rom_data(data)
TrainerPic._rom = wrapped
return wrapped
end
end
end
@@ -76,8 +112,9 @@ local function load_rom_bytes()
for _, name in ipairs(candidates) do
local data = love.filesystem.read(name)
if data and #data > 0 then
TrainerPic._rom = data
return data
local wrapped = wrap_rom_data(data)
TrainerPic._rom = wrapped
return wrapped
end
end
end
@@ -232,23 +269,28 @@ end
--- Player back pic strip (64×320, 5 frames). gender 0=boy, 1=girl.
function TrainerPic.back(gender)
gender = tonumber(gender) or 0
if gender ~= 0 and gender ~= 1 then gender = 0 end
if gender < 0 or gender > 5 then gender = 0 end
if TrainerPic._back[gender] then return TrainerPic._back[gender] end
if not TrainerPic._cache then TrainerPic.install(nil) end
local rel = cache_root() .. "/back_" .. gender .. ".rgba"
local cache = TrainerPic._cache
local rgba = cache and cache.read and cache:read(rel)
if not rgba or #rgba < 64 * 320 * 4 then
local frames = (gender == 0 or gender == 1) and 5 or 4
local expectedBytes = 64 * 64 * frames * 4
if not rgba or #rgba < expectedBytes then
rgba = decode_pic_rgba(
gender,
Versions.TRAINER_BACK_PIC_TABLE or 0x239FA4,
Versions.TRAINER_BACK_PIC_PAL_TABLE or 0x239FD4,
rel,
5)
frames)
end
local image = image_from_rgba(rgba, 64, 320)
if not rgba then return nil end
local actualFrames = math.floor(#rgba / (64 * 64 * 4))
if actualFrames <= 0 then return nil end
local image = image_from_rgba(rgba, 64, 64 * actualFrames)
if not image then return nil end
local entry = { image = image, w = 64, h = 320, frames = 5 }
local entry = { image = image, w = 64, h = 64 * actualFrames, frames = actualFrames }
TrainerPic._back[gender] = entry
return entry
end
+29
View File
@@ -281,6 +281,35 @@ function TrainerSight.engage(game, eo, dist)
local function finishEngagement()
P.facing = playerFacing
-- pokefirered/src/trainer_see.c:349-351 SetTrainerMovementType, OverrideMovementTypeForObjectEvent, OverrideTemplateCoordsForObjectEvent
local faceMt = ({ down = 0x08, up = 0x07, left = 0x09, right = 0x0A })[eo.facing] or 0x08
if Objs.setTrainerMovementType then
Objs.setTrainerMovementType(eo, faceMt)
else
eo.movementType = faceMt
eo.movement = "STAY"
eo.range = (eo.facing or "down"):upper()
end
if Objs.overrideTemplateMovementType then
Objs.overrideTemplateMovementType(eo.localId, faceMt)
end
eo.homeX = eo.cellX
eo.homeY = eo.cellY
if eo.def then
eo.def.movementType = faceMt
eo.def.movement = "STAY"
eo.def.x = eo.cellX
eo.def.y = eo.cellY
eo.def.range = (eo.facing or "down"):upper()
end
if Objs.rememberPerm and Objs._mapId then
Objs.rememberPerm(Objs._mapId, eo.localId, {
x = eo.cellX,
y = eo.cellY,
movementType = faceMt,
facing = eo.facing,
})
end
eo.frozen = false
eo.scriptBusy = false
F.locked = false
+1 -1
View File
@@ -531,7 +531,7 @@ local function cartSkin(imp, version)
and shellColor(os.getenv("POKEPORT_CART_SHELL")) or nil
local row = imp.activeCartRow and imp:activeCartRow(version) or nil
local color = shellOverride or shellColor(info.cartShell)
or (shape == "gba" and { 50, 171, 99 }) or cartColor(version)
or (shape == "gba" and { 38, 162, 78 }) or cartColor(version)
if not row then
local sparkle, holo = finishFlags(STOCK_FINISH[version])
return { cacheKey = prefix .. version, shape = shape,
+1 -1
View File
@@ -530,7 +530,7 @@ function TrainerExtract.run(rom, cache, opts)
"return { version = %d, trainerCount = %d, classCount = %d }\n",
pack.version, pack.trainerCount, pack.classCount))
for gender = 0, 1 do
for gender = 0, 5 do
local rgba = bake_back_pic(rom, gender)
if rgba then
cache:write(root .. "/back_" .. gender .. ".rgba", rgba)
+2 -1
View File
@@ -155,7 +155,8 @@ end
-- WaitSFX (pokegold home/audio.asm); a test stub that calls a handler off
-- the coroutine has no sfx to drain.
local function drainSfx()
if coroutine.running() then coroutine.yield({ kind = "waitsfx" }) end
local co, isMain = coroutine.running()
if co and not isMain then coroutine.yield({ kind = "waitsfx" }) end
end
-- Every routine that ends `call GetPokemonName / jp
+10 -3
View File
@@ -517,9 +517,10 @@ local function ensure_slot_sprites(i, mon, selected)
Oam.setInvisible(slot.ball, PartyMenu.mode == "summary")
end
local statusFr = PartyChrome.statusFrameFor(mon.status)
local SummaryData = require("src.core.game3.summary_data")
local statusFr = SummaryData.statusAilment(mon)
local stImg, stQ = PartyChrome.statusEntry(statusFr)
if statusFr > 0 and stImg then
if statusFr > 0 and statusFr ~= 6 and stImg then
if not slot.status then
local id = select(1, Oam.createSprite({
dims = Oam.HRECT_32x8,
@@ -2122,7 +2123,13 @@ local function draw_filled_slot(i, mon, selected)
if desc then party_print(desc, baseX + info.desc[1], baseY + info.desc[2], 64) end
return
end
party_print("Lv" .. tostring(mon.level or 0), baseX + info.level[1], baseY + info.level[2], 32)
local SummaryData = require("src.core.game3.summary_data")
local ailment = SummaryData.statusAilment(mon)
-- pokefirered/src/party_menu.c:2322 DisplayPartyPokemonLevelCheck:
-- Level is only shown when the mon is healthy (or PKRS); status ailments replace level.
if ailment == 0 or ailment == 6 then
party_print("Lv" .. tostring(mon.level or 0), baseX + info.level[1], baseY + info.level[2], 32)
end
local gender = mon.gender or (Pokemon.gender and Pokemon.gender(mon.species, mon.personality))
local isNidoran = (mon.species == 29 or mon.species == 32)
+1 -1
View File
@@ -38,7 +38,7 @@ local PAL = {
railAmber = { 218, 145, 32 }, -- Gold cartridge (deeper metal)
railSilver = { 190, 198, 210 }, -- Silver cartridge (cool light metal)
railCrystal = { 132, 196, 228 }, -- Crystal cartridge (translucent ice blue)
railLeafGreen = { 128, 188, 40 },
railLeafGreen = { 38, 162, 78 }, -- LeafGreen cartridge (vibrant deep forest green)
railFireRed = { 220, 48, 48 }, -- FireRed cartridge (deeper red than Red)
}
-- Semantic aliases kept so ported call sites read the same as before.
@@ -0,0 +1,68 @@
#!/usr/bin/env luajit
package.path = "./?.lua;./?/init.lua;" .. package.path
local failed = 0
local function check(cond, msg)
if cond then
print("[ok] " .. msg)
else
failed = failed + 1
print("[FAIL] " .. msg)
end
end
local Player = require("src.core.game3.m4a_player")
local Seq = require("src.core.game3.m4a_seq")
local Mix = require("src.core.game3.m4a_mix")
local Audio = require("src.core.game3.audio")
print("[test] 1. Player.stopAt keeps active voices after fast-forward")
local slot = {
songId = 296,
seq = {
tracks = {
{ pc = 1, vel = 100, callStack = {}, data = string.char(0x00, 0xB1) }
},
voices = {}
},
voices = {},
frameSamplesLeft = 0,
frameSampleCarry = 0,
abs = 100000,
}
local snaps = {
{
at = 50000,
seq = { tracks = { { pc = 1, vel = 100, callStack = {}, data = string.char(0x00, 0xB1) } }, mem = {} },
done = false,
frameSamplesLeft = 0,
frameSampleCarry = 0,
}
}
local newAbs = Player.stopAt(slot, snaps, 60000, 100000)
check(newAbs == 60000, "rewound abs to target at")
print("[test] 2. Player.stopAt handles timestamp older than retained snaps")
local emptySnaps = {
{
at = 80000,
seq = { tracks = { { pc = 15, vel = 100, callStack = {} } }, mem = {} },
done = false,
}
}
-- If at is 20000 (predates earliest snap of 80000)
local fallbackAbs = Player.stopAt(slot, emptySnaps, 20000, 100000)
check(fallbackAbs == 20000 or fallbackAbs == slot.abs, "stopAt safely handled out-of-range timestamp")
print("[test] 3. Audio.resumeBgm ensures bgmPaused is false")
Audio._bgmPaused = true
Audio.resumeBgm()
check(Audio._bgmPaused == false, "resumeBgm cleared _bgmPaused")
if failed > 0 then
print(string.format("\n%d check(s) FAILED", failed))
os.exit(1)
end
print("\nall fanfare BGM resume checks passed")
+37
View File
@@ -417,6 +417,43 @@ Player.reset(2, 5, "down")
check(TrainerSight.check(dummyGame) == true, "TRAINER_TYPE_NORMAL (1) from def engages")
resetField()
print("[test] 10. Wandering/Pacing Trainer (e.g. Route 3 Lass) Walk-Up and Permanent Stay Conversion")
local pacer = {
localId = 2, cellX = 40, cellY = 11, px = 640, py = 176, homeX = 40, homeY = 11,
facing = "down", sight = 3, trainerType = 1,
movement = "WALK", movementType = 0x03, range = "UP_DOWN", radius = { x = 1, y = 1 },
elevation = 0, visible = true, hidden = false, moving = false, frozen = false, scriptBusy = false,
scriptKey = "trainer_battle_01",
def = { localId = 2, x = 40, y = 11, movementType = 0x03, movement = "WALK", range = "UP_DOWN" },
}
Objects.clear()
Objects._byId[2] = pacer
Objects._order = { 2 }
-- Player stands 3 tiles below pacer at (40, 14)
Player.reset(40, 14, "up")
check(TrainerSight.check(dummyGame) == true, "Pacing trainer spots player 3 tiles away")
check(Field.locked == true, "Field locked during exclamation and walk-up")
-- Simulate frames for exclamation effect and approach walk track
for _ = 1, 100 do
FieldEffects.step()
Objects.update(dummyGame)
end
check(pacer.cellX == 40 and pacer.cellY == 13, "Pacer walked 2 steps down to tile adjacent to player (40, 13)")
check(pacer.movement == "STAY", "Pacer movement changed to STAY")
check(pacer.movementType == 0x08, "Pacer movementType changed to MOVEMENT_TYPE_FACE_DOWN (0x08)")
check(pacer.homeX == 40 and pacer.homeY == 13, "Pacer home coordinates updated to post-approach location (40, 13)")
-- Verify that subsequent idle updates do not cause the trainer to resume pacing
pacer.idleTimer = 0
for _ = 1, 10 do
Objects.update(dummyGame)
end
check(pacer.moving == false and pacer.cellX == 40 and pacer.cellY == 13, "Trainer remains stationary and does not resume wander cycle")
resetField()
if failed > 0 then
print(string.format("\n%d FAILURE(S)", failed))
os.exit(1)
+6 -1
View File
@@ -339,9 +339,14 @@ print("[test] 12. Complete 743 Trainer aiFlags ROM parity vs pokefirered/src/dat
do
local f = io.open("pokefirered/src/data/trainers.h", "r")
if f then
local okT, trainerPack = pcall(require, "data.generated.gba.trainers")
if not okT or not trainerPack then
f:close()
print("[skip] data.generated.gba.trainers not found")
return
end
local content = f:read("*a")
f:close()
local trainerPack = require("data.generated.gba.trainers")
local trainerBlocks = {}
local currentId = nil
local currentAiFlags = 0
@@ -0,0 +1,178 @@
-- Unit tests for Game 3 battle experience participant tracking and distribution.
-- Covers pret parity for in-battle switches, shift switches, enemy switches, and double battles.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local State = require("src.core.game3.battle.state")
local Engine = require("src.core.game3.battle.engine")
local SwitchSeq = require("src.core.game3.battle.switch_seq")
local Experience = require("src.core.game3.battle.experience")
local Damage = require("src.core.game3.battle.damage")
local Battle = require("src.core.game3.battle.init")
local origExpYield = Experience.expYield
Experience.expYield = function(species)
local y = origExpYield(species)
if y and y > 0 then return y end
return 100
end
local function check(cond, msg)
if not cond then error("[FAIL] " .. tostring(msg), 2) end
print("[PASS] " .. tostring(msg))
end
local function eq(a, b, msg)
if a ~= b then
error(string.format("[FAIL] %s: expected %s, got %s", tostring(msg), tostring(b), tostring(a)), 2)
end
print("[PASS] " .. tostring(msg))
end
print("=== 1. In-battle switch: both participants split EXP ===")
do
local p1 = Damage.ensureStats({ species = 1, level = 10, hp = 30, maxHp = 30, exp = 1000 })
local p2 = Damage.ensureStats({ species = 4, level = 10, hp = 30, maxHp = 30, exp = 1000 })
local e1 = Damage.ensureStats({ species = 16, level = 10, hp = 30, maxHp = 30, exp = 1000 })
local st = State.new({
playerParty = { p1, p2 },
foeParty = { e1 },
foeMon = e1,
wild = true,
})
-- Slot 1 starts active. Switch to slot 2 during battle against e1.
eq(st.enemy.participants[1], true, "slot 1 initially tracked as participant on e1")
eq(st.enemy.participants[2], nil, "slot 2 not yet participant on e1")
SwitchSeq.beginPlayerSwitch(st, 2, { headless = true })
eq(st.enemy.participants[1], true, "slot 1 remained participant after switch")
eq(st.enemy.participants[2], true, "slot 2 tracked as participant after switch")
local awards = Experience.awardFoe(st, st.enemy, { trainer = false })
eq(#awards, 2, "both mons received awards")
eq(awards[1].partyIndex, 1, "award 1 goes to slot 1")
eq(awards[2].partyIndex, 2, "award 2 goes to slot 2")
eq(awards[1].amount, awards[2].amount, "both mons received equal split of EXP")
end
print("\n=== 2. Shift switch on enemy defeat: previous mons do NOT receive EXP for next enemy ===")
do
local p1 = Damage.ensureStats({ species = 1, level = 10, hp = 30, maxHp = 30, exp = 1000 })
local p2 = Damage.ensureStats({ species = 4, level = 10, hp = 30, maxHp = 30, exp = 1000 })
local p3 = Damage.ensureStats({ species = 7, level = 10, hp = 30, maxHp = 30, exp = 1000 })
local e1 = Damage.ensureStats({ species = 16, level = 10, hp = 1, maxHp = 30, exp = 1000 })
local e2 = Damage.ensureStats({ species = 19, level = 10, hp = 30, maxHp = 30, exp = 1000 })
local st = State.new({
playerParty = { p1, p2, p3 },
foeParty = { e1, e2 },
foeMon = e1,
wild = false,
})
-- In fight against e1, player switches from p1 to p2
SwitchSeq.beginPlayerSwitch(st, 2, { headless = true })
eq(st.enemy.participants[1], true, "p1 is participant on e1")
eq(st.enemy.participants[2], true, "p2 is participant on e1")
eq(st.enemy.participants[3], nil, "p3 is not participant on e1")
-- e1 faints, awards given
local awards1 = Experience.awardFoe(st, st.enemy, { trainer = true })
eq(#awards1, 2, "p1 and p2 both received EXP for e1")
-- Shift switch: player chooses to switch to p3 for e2
SwitchSeq.beginShiftSwitch(st, 3, 2, { headless = true })
eq(st.player.partyIndex, 3, "p3 is now active player mon")
eq(st.enemy.partyIndex, 2, "e2 is now active enemy mon")
eq(st.enemy.participants[1], nil, "p1 is NOT participant on e2")
eq(st.enemy.participants[2], nil, "p2 is NOT participant on e2")
eq(st.enemy.participants[3], true, "only p3 is participant on e2")
-- e2 faints, awards given
local awards2 = Experience.awardFoe(st, st.enemy, { trainer = true })
eq(#awards2, 1, "only p3 receives EXP for e2")
eq(awards2[1].partyIndex, 3, "award goes to p3")
check(awards2[1].amount > awards1[1].amount, "p3 receives full undivided EXP for e2")
end
print("\n=== 3. Enemy AI switch resets participant tracking ===")
do
local p1 = Damage.ensureStats({ species = 1, level = 10, hp = 30, maxHp = 30, exp = 1000 })
local p2 = Damage.ensureStats({ species = 4, level = 10, hp = 30, maxHp = 30, exp = 1000 })
local e1 = Damage.ensureStats({ species = 16, level = 10, hp = 30, maxHp = 30, exp = 1000 })
local e2 = Damage.ensureStats({ species = 19, level = 10, hp = 30, maxHp = 30, exp = 1000 })
local st = State.new({
playerParty = { p1, p2 },
foeParty = { e1, e2 },
foeMon = e1,
wild = false,
})
-- Player switches to p2 against e1
SwitchSeq.beginPlayerSwitch(st, 2, { headless = true })
eq(st.enemy.participants[1], true, "p1 fought e1")
eq(st.enemy.participants[2], true, "p2 fought e1")
-- Opponent trainer switches e1 out for e2
SwitchSeq.beginSendOut(st, "enemy", 2, { headless = true })
eq(st.enemy.partyIndex, 2, "e2 is now active")
eq(st.enemy.participants[1], nil, "p1 is NOT a participant on e2")
eq(st.enemy.participants[2], true, "only active mon p2 is participant on e2")
local awards = Experience.awardFoe(st, st.enemy, { trainer = true })
eq(#awards, 1, "only p2 receives EXP for e2")
eq(awards[1].partyIndex, 2, "award goes to p2")
end
print("\n=== 4. Double battle participant tracking across replacements ===")
do
local p1 = Damage.ensureStats({ species = 1, level = 10, hp = 30, maxHp = 30, exp = 1000 })
local p2 = Damage.ensureStats({ species = 4, level = 10, hp = 30, maxHp = 30, exp = 1000 })
local p3 = Damage.ensureStats({ species = 7, level = 10, hp = 30, maxHp = 30, exp = 1000 })
local e1 = Damage.ensureStats({ species = 16, level = 10, hp = 30, maxHp = 30, exp = 1000 })
local e2 = Damage.ensureStats({ species = 19, level = 10, hp = 30, maxHp = 30, exp = 1000 })
local e3 = Damage.ensureStats({ species = 25, level = 10, hp = 30, maxHp = 30, exp = 1000 })
local st = State.new({
playerParty = { p1, p2, p3 },
foeParty = { e1, e2, e3 },
wild = false,
double = true,
})
local foe1 = State.battler(st, 1)
local foe3 = State.battler(st, 3)
eq(foe1.participants[1], true, "foe1 tracks p1")
eq(foe1.participants[2], true, "foe1 tracks p2")
eq(foe3.participants[1], true, "foe3 tracks p1")
eq(foe3.participants[2], true, "foe3 tracks p2")
-- Player switches battler 0 (p1) to p3
State.updateSentPokes(st, { side = "player", partyIndex = 3 })
eq(foe1.participants[3], true, "foe1 now tracks p3 as well")
eq(foe3.participants[3], true, "foe3 now tracks p3 as well")
-- foe1 faints, replacement foe3 (e3) is sent out into slot 1
local newFoe1 = State.makeBattler(st.foeParty[3], "enemy", { state = st, partyIndex = 3, id = 1 })
st.battlers[1] = newFoe1
State.opponentSwitchInResetSentPokes(st, newFoe1)
-- Currently active player mons are p2 (battler 2) and p3 (battler 0)
st.battlers[0].partyIndex = 3
st.battlers[2].partyIndex = 2
State.opponentSwitchInResetSentPokes(st, newFoe1)
eq(newFoe1.participants[1], nil, "withdrawn p1 is NOT participant on replacement newFoe1")
eq(newFoe1.participants[2], true, "active p2 is participant on newFoe1")
eq(newFoe1.participants[3], true, "active p3 is participant on newFoe1")
end
print("\n[ALL EXP PARTICIPANT TESTS PASSED! 100%]")
+2
View File
@@ -40,6 +40,7 @@ if os.getenv("POKEPORT_IDENTITY") then
print("[skip] fixture sections need a run without POKEPORT_IDENTITY: the identity cache is read first")
else
print("[test] 3. a missing cache degrades to synthetic labels")
Multichoice.LISTS = {}
check(Multichoice.tryLoadCache() == false, "no cache means no lists")
local synthetic = Multichoice.resolve(4242, 3)
check(#synthetic == 3, "an unknown list keeps the count hint (" .. #synthetic .. ")")
@@ -57,6 +58,7 @@ else
fixture:write('}\n')
fixture:close()
Extract.CACHE_ROOT = tmp
Multichoice.LISTS = {}
check(Multichoice.tryLoadCache() == true, "the cache table loads")
local zero = Multichoice.resolve(0, 2)
check(zero[1] == "CACHE YES" and zero[2] == "CACHE NO",
+5 -1
View File
@@ -38,7 +38,11 @@ for _, case in ipairs({ { "win", 1, 0 }, { "ran", 4, 1 }, { "lose", 2, 1 },
end
print("PASS marowak_boolean_and_raw_outcomes")
local Cache = require("tests.game3_cache")
local bundle = assert(Cache.bundle("scripts/events.lua", { native = true }), Cache.reason)
local bundle = Cache.bundle("scripts/events.lua", { native = true })
if not bundle then
print("[skip] game3_marowak_progression_test: " .. tostring(Cache.reason))
os.exit(0)
end
local Vm = require("src.core.game3.scripting.vm")
local Adapters = require("src.core.game3.scripting.adapters")
local events = assert(bundle.events.FR_POKEMON_TOWER_6F.coordEvents)
@@ -0,0 +1,190 @@
-- Tests for Game 3 Old Man catching tutorial battle (pokefirered special StartOldManTutorialBattle)
local function check(cond, msg)
if not cond then error(msg or "check failed", 2) end
print("[ok] " .. tostring(msg or "check passed"))
end
local function eq(a, b, msg)
if a ~= b then
error(string.format("%s: expected %s, got %s", msg or "eq failed", tostring(b), tostring(a)), 2)
end
print("[ok] " .. tostring(msg or "eq passed"))
end
print("[test] 1. TrainerPic.back supports back pic 5 (Old Man)")
local TrainerPic = require("src.core.game3.trainer_pic")
TrainerPic._back[0] = { image = "red", w = 64, h = 320 }
TrainerPic._back[5] = { image = "old_man", w = 64, h = 256, frames = 4 }
local oldManPic = TrainerPic.back(5)
check(oldManPic ~= nil and oldManPic.image == "old_man", "TrainerPic.back(5) returns Old Man back pic entry")
local clampedPic = TrainerPic.back(99)
check(clampedPic ~= nil and clampedPic.image == "red", "TrainerPic.back(99) clamps to index 0")
print("[test] 2. Intro sequence configures Old Man back sprite and omits player mon sendout")
local Battle = require("src.core.game3.battle.init")
local IntroSeq = require("src.core.game3.battle.intro_seq")
local Ui = require("src.core.game3.battle.ui")
local CatchSeq = require("src.core.game3.battle.catch_seq")
local Catching = require("src.core.game3.battle.catching")
local Anim = require("src.core.game3.battle.anim")
local foe = {
species = 13, -- WEEDLE
name = "WEEDLE",
level = 5,
gender = "M",
oldManTutorial = true,
}
if Battle.isActive() then Battle.abort("win") end
local ok = Battle.start({
wild = true,
headless = true,
foe = foe,
oldManTutorial = true,
playerParty = {
{ species = 1, level = 5, hp = 20, maxHp = 20, moves = { 33, 45 }, pp = { 35, 20 }, maxPp = { 35, 20 } }
}
})
check(ok, "battle started")
local st = Battle.getState()
eq(st.oldManTutorial, true, "st.oldManTutorial is true")
IntroSeq.begin(st, { playerGender = 0 })
local stage = Anim.stage()
eq(stage.trainer.player.gender, 5, "player trainer back sprite gender set to 5 (OLD MAN)")
local hasSendout = false
local hasPlayerHealthbox = false
for _, step in ipairs(IntroSeq._steps or {}) do
if step.kind == "player_throw" then hasSendout = true end
if step.kind == "healthbox" and step.data and step.data.side == "player" then
hasPlayerHealthbox = true
end
end
check(not hasSendout, "player mon sendout / throw step omitted")
check(not hasPlayerHealthbox, "player healthbox omitted")
print("[test] 3. Action selection UI: prompt text and automated input")
-- Test non-headless step timing
Ui.reset({ headless = false })
Ui.bindState(st)
Ui.openMenu()
eq(Ui._menuIndex, 1, "menu starts on FIGHT (index 1)")
-- 63 ticks on FIGHT
for _ = 1, 63 do
Ui.tick()
end
eq(Ui._menuIndex, 1, "still on FIGHT after 63 ticks")
-- 64th tick moves to BAG
Ui.tick()
eq(Ui._menuIndex, 2, "moves to BAG (index 2) on 64th tick")
-- 63 ticks on BAG
for _ = 1, 63 do
Ui.tick()
end
check(Ui.takeCommand() == nil, "no command produced before 64 ticks on BAG")
-- 64th tick on BAG selects Poké Ball
Ui.tick()
local cmd = Ui.takeCommand()
check(cmd ~= nil, "command emitted after 64 ticks on BAG")
eq(cmd.kind, "bag", "command is bag")
eq(cmd.itemId, 4, "itemId is Poké Ball (4)")
-- Input lock test
Ui.openMenu()
local fakeInput = { wasPressed = function() return true end }
local handled = Ui.handleInput(fakeInput)
eq(handled, true, "player gamepad input is consumed/locked during tutorial")
print("[test] 4. Catch mechanics and messaging for Old Man tutorial")
local session = {
name = "RED",
party = { { species = 1, level = 5, hp = 20, maxHp = 20 } },
box = { {}, {} },
bag = {}, -- empty bag
pokedex = {},
}
-- tryCatch always succeeds in tutorial
local caught, shakes = Catching.tryCatch(4, st.enemy, st, session, function() return 999999 end)
eq(caught, true, "Catching.tryCatch succeeds")
eq(shakes, 4, "Catching.tryCatch yields 4 shakes")
-- CatchSeq messages
local msgs = {}
CatchSeq.begin(st, 4, true, 4, {
headless = true,
session = session,
pushMsg = function(t) msgs[#msgs + 1] = t end,
})
check(#msgs >= 2, "messages were emitted")
eq(msgs[1], "OLD MAN used\nPOKé BALL!", "throw msg is 'OLD MAN used POKé BALL!'")
eq(msgs[2], "Gotcha!\nWEEDLE was caught!", "catch msg is 'Gotcha! WEEDLE was caught!'")
-- Weedle not added to player party or dex
eq(#session.party, 1, "player party unchanged (weedle not added)")
check(session.pokedex[13] == nil, "pokedex unchanged")
-- Non-headless post-catch flow must skip nickname prompt
Battle._headless = false
Battle.startPostCatchFlow(nil)
eq(Battle._phase, "ending", "startPostCatchFlow goes straight to ending phase (no nickname prompt)")
eq(Battle._pendingEnd, "catch", "pendingEnd is catch")
print("[test] 5. Full battle headless execution runs to catch completion")
if Battle.isActive() then Battle.abort("win") end
local ok2 = Battle.start({
wild = true,
headless = true,
foe = foe,
oldManTutorial = true,
playerParty = {
{ species = 1, level = 5, hp = 20, maxHp = 20, moves = { 33 }, pp = { 35 }, maxPp = { 35 } }
}
})
check(ok2, "second battle started")
local res = Battle.runToEnd()
eq(res, "catch", "battle concluded with catch outcome")
print("[test] 6. Old Man back pic extraction across FR 1.0, FR 1.1, LG 1.0, LG 1.1")
do
local mockLove = {
image = { newImageData = function(w, h, fmt, data) return { data = data, w = w, h = h } end },
graphics = { newImage = function(imgData) return { imgData = imgData } end },
}
local prevLove = _G.love
_G.love = mockLove
local romFiles = {
{ edition = "FireRed 1.0", file = "firered_dump.gba" },
{ edition = "FireRed 1.1", file = "Pokemon - Fire Red Version (U) (V1.1).gba" },
{ edition = "LeafGreen 1.0", file = "Pokemon - LeafGreen Version (USA).gba" },
{ edition = "LeafGreen 1.1", file = "Pokemon - LeafGreen Version (USA, Europe) (Rev 1).gba" },
}
for _, rf in ipairs(romFiles) do
local f = io.open(rf.file, "rb")
if f then
local raw = f:read("*a")
f:close()
TrainerPic._rom = nil
TrainerPic._front = {}
TrainerPic._back = {}
TrainerPic._cache = nil
local pic = TrainerPic.back(5)
check(pic ~= nil and pic.w == 64 and pic.h == 256 and pic.frames == 4,
string.format("Old Man back pic extracted successfully for %s", rf.edition))
end
end
_G.love = prevLove
end
print("[PASS] game3 old man tutorial battle tests")
+122
View File
@@ -0,0 +1,122 @@
-- Tests for Game 3 S.S. Anne departure animation cutscene (DoSSAnneDepartureCutscene).
-- Matches pret pokefirered (src/ss_anne.c, data/maps/SSAnne_Exterior/scripts.inc).
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Std = require("src.core.game3.scripting.stdscripts")
local Natives = require("src.core.game3.scripting.natives")
local SSAnneCutscene = require("src.core.game3.ss_anne_cutscene")
local Objects = require("src.core.game3.objects")
local playedSEs = {}
local Audio = require("src.core.game3.audio")
Audio.playSe = function(id)
table.insert(playedSEs, id)
end
local function assert_eq(a, b, msg)
if a ~= b then
error(string.format("%s: expected %s, got %s", msg or "assertion failed", tostring(b), tostring(a)))
end
end
local function assert_true(cond, msg)
if not cond then
error(msg or "expected true, got false")
end
end
print("[test] 1. Special constant and registration")
assert_eq(Std.SPECIAL.DoSSAnneDepartureCutscene, 0x191, "DoSSAnneDepartureCutscene is 0x191 (401)")
assert_true(Natives.ALLOW["special:" .. 0x191] ~= nil, "special 0x191 handler registered")
print("[ok] special 0x191 correctly defined and registered")
print("[test] 2. S.S. Anne cutscene lifecycle and timing")
playedSEs = {}
-- Mock the S.S. Anne boat event object (localId 1)
Objects.clear()
local mockBoat = {
localId = 1,
graphicsId = 151,
cellX = 30,
cellY = 16,
px = 30 * 16,
py = 16 * 16,
visible = true,
hidden = false,
raiseX = 0,
}
Objects._byId[1] = mockBoat
Objects._order = { 1 }
local task = SSAnneCutscene.start()
assert_true(SSAnneCutscene.isActive(), "cutscene is active")
assert_eq(SSAnneCutscene._phase, "init", "initial phase is init")
assert_eq(#playedSEs, 1, "initial horn SE played")
assert_eq(playedSEs[1], 249, "SE is SE_SS_ANNE_HORN (249)")
-- Init phase: 50 frames
for f = 1, 49 do
local done = task()
assert_eq(done, false, "init phase not done at frame " .. f)
assert_eq(SSAnneCutscene._phase, "init", "still init phase at frame " .. f)
end
-- Frame 50 finishes init and enters run
local done50 = task()
assert_eq(done50, false, "not done at frame 50")
assert_eq(SSAnneCutscene._phase, "run", "switched to run phase")
assert_true(SSAnneCutscene._wake ~= nil, "wake sprite created")
print("[ok] init phase successfully executed 50 frames and spawned wake")
print("[test] 3. Run phase boat movement, smoke spawning, and wake animation")
-- Tick 70 frames: first smoke puff spawned at 70th run frame
for f = 1, 69 do
task()
assert_eq(#SSAnneCutscene._smokes, 0, "no smoke before 70 frames")
end
task() -- 70th run frame
assert_eq(#SSAnneCutscene._smokes, 1, "smoke puff spawned at 70 frames")
assert_eq(SSAnneCutscene._smokes[1].frame, 0, "smoke starts at frame 0")
-- Verify boat offset: at 70 run frames, offset = math.floor(70 / 5) = 14 px
assert_eq(SSAnneCutscene._boatOffset, 14, "boat offset is 14 px at frame 70")
assert_eq(mockBoat.raiseX, -14, "boat object raiseX is -14")
-- Verify wake animation
assert_true(SSAnneCutscene._wake.x2 > 0, "wake drifting right relative to boat")
-- Advance to near exit (travel distance 216 px = 216 * 5 = 1080 frames)
local prevSmokesCount = #SSAnneCutscene._smokes
for f = 71, 1079 do
task()
end
assert_eq(SSAnneCutscene._phase, "run", "still run phase before distance reached")
assert_true(#playedSEs == 1, "only 1 horn so far")
-- Frame 1080 reaches 216 px offset and triggers exit horn
task()
assert_eq(SSAnneCutscene._phase, "finish", "switched to finish phase")
assert_eq(#playedSEs, 2, "second horn played on departure")
assert_eq(playedSEs[2], 249, "departure horn is SE_SS_ANNE_HORN (249)")
print("[ok] run phase motion, smoke puffs, and departure horn verified")
print("[test] 4. Finish phase delay and clean completion")
for f = 1, 39 do
local done = task()
assert_eq(done, false, "finish phase not done at frame " .. f)
end
local finalDone = task() -- 40th frame of finish
assert_eq(finalDone, true, "cutscene completed on 40th finish frame")
assert_eq(SSAnneCutscene.isActive(), false, "cutscene inactive after completion")
assert_eq(mockBoat.raiseX, 0, "boat raiseX reset")
print("[ok] finish phase completed after 40 frames and cleaned up state")
print("[PASS] game3 S.S. Anne departure cutscene tests")
+1 -1
View File
@@ -2,7 +2,7 @@ package.path = "./?.lua;./?/init.lua;" .. package.path
require("tests.game3_cache").requireData("game3_surf_connections_test")
require("src.core.GameVersion").set("firered")
local Cache = require("tests.game3_cache")
assert(Cache.mount("scripts/events.lua", { native = true }), Cache.reason)
Cache.mountOrSkip("game3_surf_connections_test", "scripts/events.lua", { native = true })
local Dataset = require("src.core.game3.dataset")
local Collision = require("src.core.game3.collision")
local Player = require("src.core.game3.player")