From a70c0a48cf6eb3861346cdbe519b43e43714e921 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:30:34 +0100 Subject: [PATCH 01/14] Connect a distinct Gen2 model to native reads and battle completion --- docs/native-gen2-model-boundary.md | 38 +++++ src/battle/BattleState.lua | 69 ++++---- src/battle/Damage.lua | 23 +-- src/battle/Gen2BattleRead.lua | 47 ++++++ src/battle/Gen2BattleView.lua | 61 +++++++ src/battle/MoveEffects.lua | 18 +- src/battle/Status.lua | 3 +- src/battle/StatusRegistry.lua | 3 +- src/battle/TurnOrder.lua | 3 +- src/battle/Weather.lua | 3 +- src/battle/gen2/Battle.lua | 67 ++++++++ src/core/ModCompat.lua | 15 +- tests/engine/gen2_battle_model_contract.lua | 178 ++++++++++++++++++++ 13 files changed, 464 insertions(+), 64 deletions(-) create mode 100644 docs/native-gen2-model-boundary.md create mode 100644 src/battle/Gen2BattleRead.lua create mode 100644 src/battle/Gen2BattleView.lua create mode 100644 src/battle/gen2/Battle.lua create mode 100644 tests/engine/gen2_battle_model_contract.lua diff --git a/docs/native-gen2-model-boundary.md b/docs/native-gen2-model-boundary.md new file mode 100644 index 000000000..4abcd7bd4 --- /dev/null +++ b/docs/native-gen2-model-boundary.md @@ -0,0 +1,38 @@ +# Native Gen2 model boundary + +`src.battle.gen2.Battle` is a distinct model class attached to the shared +native battle controller by `Gen2BattleView`. It is not a name alias for +`BattleState`, a second controller, or a detached copy of the saved party. +Methods resolve through the class so an SDK-owned decoration also reaches +already-created models and is restored by loader retirement. + +The boundary supplies live `moveDef`, `speciesDef`, `sideOf`, raw `battleStat` +and `endBattle` operations. Damage, speed, status immunity, weather and shared +move effects consult the native bound reads. Stages, badge/status/item +modifiers, RNG and PP stay with their existing native owners. With no +decoration the read returns the original native table, without a copy. +Temporary stat/type answers do not replace a species registry row, party +statistics, wrapper identity or checkpoint data. Explicit native +Conversion/Transform types keep their priority over a species decoration. + +The native finish path invokes the model's end operation, including the +skipped battle path. It raises one finish notification and permits transient +cleanup before subscribers observe it. `completeBattle` runs the existing +screen finish/return path, with repeated completion prevented from popping +another screen. Existing native consumers retain the screen in the event's +`battle`; the distinct model is available in `model`. + +This is an implemented boundary, not complete source-model parity. The split +Gen2 model contract was inspected at Gen1Recomp `535a5ec0` and its current +`e46d1eb2` counterpart. That model additionally has constructors and a pure +turn/event API, which are not guessed from screen methods here. Its inspected +class has no `battleStat` method; the raw-stat operation above is an explicit +native extension, not a claim that all source versions expose it. Source +event payload normalization and the remaining model operations still require +separate implementation and acceptance. Existing project and third-party +credits remain applicable. + +ROM-free coverage uses an SDK-loaded generic decorator and actual native +damage, speed, status, switch, Transform and completion calls. It also checks +restoration and direct table identity in other generations. It does not prove +an unchanged client, GPU presentation, a full story run or a release package. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 8f8e1409c..9b4ef71b4 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -38,6 +38,8 @@ local WideBattle = require("src.battle.WideBattle") local Gen3Battle = require("src.battle.Gen3Battle") local Gen4Battle = require("src.battle.Gen4Battle") local GameVersion = require("src.core.GameVersion") +local Gen2BattleView = require('src.battle.Gen2BattleView') +local Gen2Read = require('src.battle.Gen2BattleRead') -- FORWARD-DECLARED, because they are defined next to the other doubles -- helpers two thousand lines down but read from `stepHPDrain` up here. A @@ -981,7 +983,7 @@ end -- construction -- --------------------------------------------------------------------- -local function makeBattler(data, mon, isPlayer, save) +local function makeBattler(data, mon, isPlayer, save, battle) local def = require('src.pokemon.Gen4Forms').definition(data,mon) local badgeBoosts = data.constants and data.constants.badgeBoosts local badges = nil @@ -993,7 +995,7 @@ local function makeBattler(data, mon, isPlayer, save) if save.inventory[row.badge] then badges[row.badge] = true end end end - return { + local battler = { mon = mon, def = def, name = mon.nickname or def.name, @@ -1050,6 +1052,7 @@ local function makeBattler(data, mon, isPlayer, save) monAnim = (not isPlayer) and require("src.pokemon.MonAnim").new(data, mon.species) or nil, } + return Gen2Read.bind(battler, battle) end -- LinkBattle builds clamped copies with save=nil (no badge boosts); wild @@ -1164,12 +1167,9 @@ local function newBattle(game) local self = setmetatable({}, BattleState) self.game = game self.data = game.data - -- On this engine the battle screen IS the battle logic object: there is no - -- separate model that a screen points at. Publishing the self-reference makes - -- that explicit so anything holding "a thing with a .battle" (the party menu - -- during a switch, mods walking the state stack) can treat the battle screen - -- and the party menu uniformly instead of special-casing this one screen. - self.battle = self + -- Gen2 exposes its actual party through a distinct live model; native + -- decisions, wrappers and presentation remain on this controller. + if GameVersion.isGen2() then Gen2BattleView.bind(self) else self.battle = self end -- BLACK AND WHITE: their move effects named for this engine, their species' -- abilities and their ruleset, before the ruleset below is chosen -- (src/battle/Gen5MoveEffects.lua; sets self.bw on a BW cache only) @@ -1371,7 +1371,7 @@ function BattleState.newWild(game, species, level, opts) Logger.warn("wild battle with no healthy party; skipping") self.dead = true else - self.player = makeBattler(game.data, playerMon, true, game.save) + self.player = makeBattler(game.data, playerMon, true, game.save, self) end local form=opts and opts.form if form==nil and (game.data.constants or {}).gen==4 then @@ -1428,7 +1428,7 @@ function BattleState.newWild(game, species, level, opts) if opts and opts.roamerSeed then Pokemon.applySeed(game.data, wild, opts.roamerSeed) end - self.enemy = makeBattler(game.data, wild, false) + self.enemy = makeBattler(game.data, wild, false, nil, self) require("src.battle.Gen2Wild").initialize(self, opts) if self.roamer then local kept = math.floor(tonumber(opts.roamerHP) or 0) @@ -1552,9 +1552,9 @@ function BattleState.newPokedudeDemo(game, lesson) local self = newBattle(game) self.kind = "wild" self.noRun = true - self.player = makeBattler(game.data, party[1], true, game.save) + self.player = makeBattler(game.data, party[1], true, game.save, self) self.enemyParty = { pokedudeMon(game, def.enemy) } - self.enemy = makeBattler(game.data, self.enemyParty[1], false) + self.enemy = makeBattler(game.data, self.enemyParty[1], false, nil, self) self.introText = self:romText("_WildMonAppearedText", "Wild %s\nappeared!", self.enemy.name) self.demoName = "POKé DUDE" @@ -1742,9 +1742,9 @@ function BattleState.newTrainer(game, oppClass, partyIndex) Logger.warn("trainer battle with no healthy party; skipping") self.dead = true else - self.player = makeBattler(game.data, playerMon, true, game.save) + self.player = makeBattler(game.data, playerMon, true, game.save, self) end - self.enemy = makeBattler(game.data, self.enemyParty[1], false) + self.enemy = makeBattler(game.data, self.enemyParty[1], false, nil, self) self.aiUses = self:aiUsesFor() -- wAICount, reset per enemy mon markSeen(game, self.enemyParty[1].species, self.enemyParty[1]) @@ -1836,7 +1836,7 @@ function BattleState:sendOutSecondPair() if self:battlerAt(BattleState.POS.PLAYER_RIGHT) == nil and self.ally then local first = Party.firstHealthy(self.ally.party or {}) if first then - local b = makeBattler(game.data, first, true, nil) + local b = makeBattler(game.data, first, true, nil, self) b.ally = true self:placeBattler(BattleState.POS.PLAYER_RIGHT, b) end @@ -1847,7 +1847,7 @@ function BattleState:sendOutSecondPair() local second = Party.firstHealthy(game.save.party, lead) if second then self:placeBattler(BattleState.POS.PLAYER_RIGHT, - makeBattler(game.data, second, true, game.save)) + makeBattler(game.data, second, true, game.save, self)) elseif GameVersion.isGen4() then -- ...EXCEPT ON PLATINUM, where an escort makes it reachable: the -- partner flag forces hasTwoAliveMons, so a same-id double spots a @@ -1874,7 +1874,7 @@ function BattleState:sendOutSecondPair() if party and party[2] then self.enemyIndexRight = 2 self:placeBattler(BattleState.POS.OPPONENT_RIGHT, - makeBattler(game.data, party[2], false)) + makeBattler(game.data, party[2], false, nil, self)) markSeen(game, party[2].species, party[2]) else -- a "double" trainer with one Pokemon fights alone on their side, @@ -1932,7 +1932,7 @@ function BattleState:addOpponentTrainer(oppClass) -- the right-hand opponent is the SECOND trainer's lead, replacing -- whatever the first trainer's second Pokemon had been put there self:placeBattler(BattleState.POS.OPPONENT_RIGHT, - makeBattler(self.game.data, self.enemyPartyB[1], false)) + makeBattler(self.game.data, self.enemyPartyB[1], false, nil, self)) markSeen(self.game, self.enemyPartyB[1].species, self.enemyPartyB[1]) end return true @@ -2167,7 +2167,7 @@ function BattleState:makeOldManDemo(name) if not self.player then self.dead = false self.player = makeBattler(self.game.data, - Pokemon.new(self.game.data, self.enemy.mon.species, 5), true) + Pokemon.new(self.game.data, self.enemy.mon.species, 5), true, nil, self) end end @@ -3616,7 +3616,8 @@ function BattleState:enter() self.result = "lose" self.game.stack:pop() require("src.core.Music").restoreMap(self.data) - Runtime.emit("battle.ended", { battle = self, result = "lose", skipped = true }) + if GameVersion.isGen2() then self.battle:endBattle('lose', {skipped=true}) + else Runtime.emit("battle.ended", { battle = self, result = "lose", skipped = true }) end local onFinish = self.onFinish local function blackedOut() if onFinish then onFinish("lose") end @@ -5904,7 +5905,7 @@ function BattleState:switchPlayerInto(pos, newMon) end local previous = self:battlerAt(pos) self:restoreMimicked(previous) -- the battle copy leaves with it - local incoming = makeBattler(self.data, newMon, true, self.game.save) + local incoming = makeBattler(self.data, newMon, true, self.game.save, self) self:placeBattler(pos, incoming) -- BATON PASS: the stat stages and the volatiles go with the switch, which -- is the whole move. Nothing else survives a switch, so a pass that was @@ -6019,7 +6020,7 @@ function BattleState:abilitySwitchIn(battler) local side = self.sideOf and self:sideOf(battler) if side and side.spikes then local grounded = Abilities.of(battler) ~= "LEVITATE" - for _, t in ipairs(battler.curTypes or {}) do + for _, t in ipairs(Gen2Read.types(battler) or {}) do if t == "FLYING" then grounded = false end end -- PLATINUM / HGSS: subscript_hazards_check decides -- Magic Guard spares @@ -6232,7 +6233,7 @@ function BattleState:abilityForecast(battler) if not (battler and battler.mon and battler.mon.hp > 0) then return false end local want = Abilities.forecastType(battler, Weather.current(self)) if not want then return false end - local types = battler.curTypes + local types = Gen2Read.types(battler) if type(types) == "table" and #types == 1 and types[1] == want then return false end @@ -7785,7 +7786,7 @@ function BattleState:executeAction(user, target, action) local previous = self.enemy local oldName = self.enemy.name self.enemyIndex = action.index - self.enemy = makeBattler(self.data, self.enemyParty[action.index], false) + self.enemy = makeBattler(self.data, self.enemyParty[action.index], false, nil, self) -- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap clearTrapping(self.player) self:syncSides() @@ -8120,7 +8121,7 @@ function BattleState:applyGen3TransformVisual(user, target, whiteCoeff) end local function battlerHasType(battler, wanted) - for _, t in ipairs(battler and battler.curTypes or {}) do + for _, t in ipairs(Gen2Read.types(battler) or {}) do if t == wanted then return true end end return false @@ -8786,7 +8787,7 @@ function BattleState:fillEmptySlots() if pick then local allySlot = isPlayer and self.ally and pos == BattleState.POS.PLAYER_RIGHT self:placeBattler(pos, makeBattler(self.data, pick, isPlayer, - (isPlayer and not allySlot) and self.game.save or nil)) + (isPlayer and not allySlot) and self.game.save or nil, self)) self:syncSides() if not isPlayer then markSeen(self.game, pick.species, pick) end local who = self:battlerAt(pos) @@ -8828,7 +8829,7 @@ function BattleState:promptDoubleReplacement(pos, rest) end end done = true - self:placeBattler(pos, makeBattler(self.data, mon, true, game.save)) + self:placeBattler(pos, makeBattler(self.data, mon, true, game.save, self)) self:syncSides() local who = self:battlerAt(pos) self.nextInsert = 0 @@ -9125,7 +9126,7 @@ function BattleState:enemyMonFainted() end self:act(function() local previous = self.enemy - self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false) + self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false, nil, self) -- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap clearTrapping(self.player) self:syncSides() @@ -9163,7 +9164,7 @@ function BattleState:enemyMonFainted() local mon = shiftSwitchMon if not mon then return end local previous = self.player - self.player = makeBattler(self.data, mon, true, self.game.save) + self.player = makeBattler(self.data, mon, true, self.game.save, self) clearTrapping(self.enemy) self:syncSides() Runtime.emit("battle.battler_switched", { @@ -9536,7 +9537,7 @@ function BattleState:openReplacementMenu() end self:restoreMimicked(self.player) local previous = self.player - self.player = makeBattler(self.data, mon, true, game.save) + self.player = makeBattler(self.data, mon, true, game.save, self) clearTrapping(self.enemy) -- SendOutMon clears foe trap self:syncSides() Runtime.emit("battle.battler_switched", { @@ -9764,7 +9765,7 @@ function BattleState:safariEnemyTurn() self:sayNext(self:romText("_SafariZoneAngryText", "Wild %s\nis angry!", self.enemy.name)) end self:act(function() - local speed = self.enemy.curStats.speed % 256 + local speed = Gen2Read.stats(self.enemy).speed % 256 local fled = speed > 127 local b = (speed * 2) % 256 if not fled then @@ -10800,6 +10801,7 @@ function BattleState:playVictoryMusic() end function BattleState:finish() + if self._gen2Complete then return end local pd = self.pokedudeDemo if pd and not pd.finalVoiceQueued then pd.finalVoiceQueued = true @@ -10860,8 +10862,10 @@ function BattleState:finish() -- like the overworld reload's PlayDefaultMusicFadeOutCurrent -- (home/overworld.asm:2343-2348) require("src.core.Music").restoreMap(self.data) + if GameVersion.isGen2() then self._gen2Complete = true end self.game.stack:pop() - Runtime.emit("battle.ended", { battle = self, result = self.result or "run" }) + if GameVersion.isGen2() then self.battle:endBattle(self.result or 'run') + else Runtime.emit("battle.ended", { battle = self, result = self.result or "run" }) end -- Coming back from the battle screen is a fade, not a cut: EnterMap sees -- BIT_BATTLE_OVER_OR_BLACKOUT set and runs MapEntryAfterBattle -- (home/overworld.asm:22, :749-753) = GBFadeInFromWhite. This is the one @@ -12963,4 +12967,5 @@ function BattleState:drawClassic() end end +Gen2BattleView.install(BattleState) return BattleState diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index c287f54a9..60cd86214 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -6,6 +6,7 @@ -- Battlers built by makeBattler also carry the merged badgeBoosts rows and -- statuses records; hand-built battlers fall back to the vanilla tables. +local Gen2Read = require('src.battle.Gen2BattleRead') local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") local Stats = require("src.pokemon.Stats") @@ -115,7 +116,7 @@ function Damage.critRoll(ruleset, attacker, moveId, rng, highCrit, data) local function shl(x) return math.min(255, x * 2) end local speed if ruleset.critUsesBaseSpeed == false then - speed = Stats.applyStage(attacker.curStats.speed, + speed = Stats.applyStage(Gen2Read.stats(attacker).speed, attacker.stages and attacker.stages.speed or 0) else speed = attacker.def.baseStats.speed @@ -231,6 +232,8 @@ end -- self-hit reads the opponent's screens). -- Returns damage, {crit=bool, typeMult=x10}. function Damage.compute(ruleset, attacker, defender, move, opts) + local attackStats, defendStats = Gen2Read.stats(attacker), Gen2Read.stats(defender) + local attackTypes, defendTypes = Gen2Read.types(attacker), Gen2Read.types(defender) opts = opts or {} local rng = opts.rng or love.math.random -- ...and the same field again, because a STATUS move must not roll damage. @@ -297,18 +300,18 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- Gen 2 kept the same type-based physical/special split but gave the -- attacker Sp.Atk and the defender Sp.Def. A Gen 1 stat block has -- neither key, so it stays on the single `special`. - local split = special and attacker.curStats.spatk and defender.curStats.spdef + local split = special and attackStats.spatk and defendStats.spdef local atkStat = special and (split and "spatk" or "special") or "attack" local defStat = special and (split and "spdef" or "special") or "defense" local atk, dfn if crit and ruleset.critIgnoresStages then - atk = attacker.curStats[atkStat] - dfn = defender.curStats[defStat] + atk = attackStats[atkStat] + dfn = defendStats[defStat] else - atk = Stats.applyStage(attacker.curStats[atkStat], + atk = Stats.applyStage(attackStats[atkStat], stageOf(attacker, atkStat)) - dfn = Stats.applyStage(defender.curStats[defStat], + dfn = Stats.applyStage(defendStats[defStat], stageOf(defender, defStat)) -- badge boosts (x9/8), engine/battle/core.asm ApplyBadgeStatBoosts: -- Boulder -> attack, Thunder -> defense, Soul -> speed (TurnOrder), @@ -464,7 +467,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) if not opts.typeless then -- STAB local stab = false - for _, t in ipairs(attacker.curTypes) do + for _, t in ipairs(attackTypes) do if t == move.type then stab = true break end end if stab then @@ -474,7 +477,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- type effectiveness: each TypeEffects row is applied to the -- running damage separately with its own floor (0.5*0.5 lands on -- floor(floor(d/2)/2), not d*0.25) - mult = TypeChart.effectiveness(move.type, defender.curTypes) + mult = TypeChart.effectiveness(move.type, defendTypes) -- FORESIGHT / ODOR SLEUTH: a GHOST that has been identified stops being -- immune to NORMAL and FIGHTING. It does not become weak to them -- -- the immunity simply becomes neutral -- so this reads the chart's @@ -489,7 +492,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) if mult == 0 and bwLift then foresight = true mult = 10 - for _, m in ipairs(TypeChart.rows(move.type, defender.curTypes)) do + for _, m in ipairs(TypeChart.rows(move.type, defendTypes)) do if m ~= 0 then mult = math.floor(mult * m / 10) end end end @@ -504,7 +507,7 @@ function Damage.compute(ruleset, attacker, defender, move, opts) return 0, { crit = false, typeMult = 0, ability = Abilities.of(defender) } end - for _, m in ipairs(TypeChart.rows(move.type, defender.curTypes)) do + for _, m in ipairs(TypeChart.rows(move.type, defendTypes)) do -- the identified GHOST's zero row is the one skipped; every other -- row a dual-typed Pokemon carries still applies if not (foresight and m == 0) then d = math.floor(d * m / 10) end diff --git a/src/battle/Gen2BattleRead.lua b/src/battle/Gen2BattleRead.lua new file mode 100644 index 000000000..f4e044834 --- /dev/null +++ b/src/battle/Gen2BattleRead.lua @@ -0,0 +1,47 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- Pure reads at native calculation sites. Wrappers, PP, volatile state and +-- checkpoint tables retain their identity and original serializable fields. +local Model = require('src.battle.gen2.Battle') +local Read = {} +local owners = setmetatable({}, { __mode = 'kv' }) +local nativeStat, nativeDef = Model.battleStat, Model.speciesDef + +function Read.bind(battler, screen) + if battler and screen and screen.battle and screen.battle ~= screen then + owners[battler] = screen + end + return battler +end + +local function modelOf(battler) + local screen = owners[battler] + local model = screen and screen.battle + -- Non-Gen2 screens use themselves as their model and keep direct reads. + return model ~= screen and model or nil +end + +function Read.stats(battler) + local stats = battler and battler.curStats + if Model.battleStat == nativeStat then return stats end + local model = battler and modelOf(battler) + if not model then return stats end + local values = {} + for key in pairs(stats or {}) do + values[key] = model:battleStat(battler.mon, key) + end + return values +end + +function Read.types(battler) + local types = battler and battler.curTypes + if Model.speciesDef == nativeDef then return types end + local model = battler and modelOf(battler) + if not model then return types end + -- Conversion/Transform own an explicit native type list. A species reader + -- must not erase a move's live volatile override, or mutate its source row. + if not battler.def or types ~= battler.def.types then return types end + local def = model:speciesDef(battler.mon) + return def and def.types or types +end + +return Read diff --git a/src/battle/Gen2BattleView.lua b/src/battle/Gen2BattleView.lua new file mode 100644 index 000000000..d5fd55437 --- /dev/null +++ b/src/battle/Gen2BattleView.lua @@ -0,0 +1,61 @@ +-- Copyright (c) 2026 Roxas2712. See the offered contribution licence. +-- Separate live Gen2 model over the existing shared native controller. +local View = {} +local Model = require('src.battle.gen2.Battle') +local function partyOf(screen) + return screen.playerParty or screen.game.save.party or {} +end + +local function indexOf(party, mon) + for index, candidate in ipairs(party or {}) do + if candidate == mon then return index end + end +end + +function View.bind(screen) + -- Bind once; callers read the current native catalog without copying a + -- move, caching a definition, or switching the controller's moveset. + local readers = { + player = function() return screen.player and screen.player.mon end, + enemy = function() return screen.enemy and screen.enemy.mon end, + party = function() return partyOf(screen) end, + enemyParty = function() return screen.enemyParty end, + save = function() return screen.game.save end, + data = function() return screen.data end, + wild = function() return screen.kind == "wild" end, + playerIndex = function() return indexOf(partyOf(screen), screen.player and screen.player.mon) end, + enemyIndex = function() return indexOf(screen.enemyParty, screen.enemy and screen.enemy.mon) end, + over = function() return screen.result ~= nil or screen.dead == true end, + outcome = function() return screen.result end, + battleType = function() return screen.battleType end, + trainer = function() return screen.trainer end, + tutorial = function() return screen.demo or screen.dudeDemo or screen.pokedudeDemo end, + contest = function() return screen.contest end, + safari = function() return screen.safari and true or false end, + link = function() return screen.kind == "link" end, + kind = function() return screen.kind end, + inBattleTowerBattle = function() return screen.battleTower == true end, + } + screen.battle = Model.bind(setmetatable({}, { + __index = function(_, key) + local read = readers[key] + if read then return read() end + return Model[key] + end, + __newindex = function(model, key, value) + if readers[key] or Model[key] ~= nil then error("native Gen2 battle view is read-only", 2) end + -- Native combat fields stay owned by the engine. Presentation consumers + -- may attach and release their own transient owners, as on the native + -- Gen2 model; these extensions never write to a battler or save. + rawset(model, key, value) + end, + }), screen) +end + +function View.install(Screen) + function Screen:completeBattle() + assert(require('src.core.GameVersion').isGen2(), 'Gen2 completion requires a Gen2 battle') + return self:finish() + end +end +return View diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 5772141ae..d5e4059ae 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -15,6 +15,7 @@ local Logger = require("src.core.Logger") local StatusRegistry = require("src.battle.StatusRegistry") local TurnOrder = require("src.battle.TurnOrder") local TypeChart = require("src.battle.TypeChart") +local Gen2Read = require('src.battle.Gen2BattleRead') local Strings = require("src.core.Strings") local HeldItems = require("src.battle.HeldItems") local Weather = require("src.battle.Weather") @@ -274,7 +275,7 @@ MoveEffects.primary = { if target.leechSeeded then return { Strings("But, it failed!") } end - for _, t in ipairs(target.curTypes) do + for _, t in ipairs(Gen2Read.types(target)) do if t == "GRASS" then return { Strings("But, it failed!") } end end target.leechSeeded = true @@ -370,7 +371,8 @@ MoveEffects.primary = { if target.invulnerable then return { Strings("But, it failed!") } end - user.curTypes = { target.curTypes[1], target.curTypes[2] } + local types = Gen2Read.types(target) + user.curTypes = { types[1], types[2] } -- _ConvertedTypeText return { Strings("Converted type to\n%s's!", displayName(target)) } end, @@ -394,13 +396,15 @@ MoveEffects.primary = { local okT, T = pcall(require, "src.battle.Gen5Transform") if okT then T.apply(battle, user, target) end end + local stats = Gen2Read.stats(target) user.curStats = { hp = user.mon.stats.hp, -- HP is kept - attack = target.curStats.attack, defense = target.curStats.defense, - speed = target.curStats.speed, special = target.curStats.special, - spatk = target.curStats.spatk, spdef = target.curStats.spdef, + attack = stats.attack, defense = stats.defense, + speed = stats.speed, special = stats.special, + spatk = stats.spatk, spdef = stats.spdef, } - user.curTypes = { target.curTypes[1], target.curTypes[2] } + local types = Gen2Read.types(target) + user.curTypes = { types[1], types[2] } -- transform.asm:130-132 copies the target's stat MODS into the user -- (wEnemyMonStatMods -> wPlayerMonStatMods), it does NOT clear them; -- deep copy so later stage changes on either mon stay independent @@ -566,7 +570,7 @@ end -- effectiveness step. So in Gen 1 NIGHT_SHADE hits Normal-types and -- SUPER_FANG hits Ghosts, and only OHKO_EFFECT calls this (#616). local function immuneMsg(ctx) - if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then + if TypeChart.effectiveness(ctx.move.type, Gen2Read.types(ctx.target)) == 0 then return Strings("It doesn't affect\n%s!", displayName(ctx.target)) end return nil diff --git a/src/battle/Status.lua b/src/battle/Status.lua index 955361db0..d81ec5c1b 100644 --- a/src/battle/Status.lua +++ b/src/battle/Status.lua @@ -5,6 +5,7 @@ -- and residual sweep. Callers without a battle (pure-module tests) fall -- back to the vanilla records, which is bit-identical behavior. +local Gen2Read = require('src.battle.Gen2BattleRead') local Strings = require("src.core.Strings") local HeldItems = require("src.battle.HeldItems") @@ -24,7 +25,7 @@ end local VOLATILE_PRIORITY = 20 local function hasType(battler, wanted) - for _, t in ipairs(battler.curTypes or {}) do + for _, t in ipairs(Gen2Read.types(battler) or {}) do if t == wanted then return true end end return false diff --git a/src/battle/StatusRegistry.lua b/src/battle/StatusRegistry.lua index d65321c78..b0b44f619 100644 --- a/src/battle/StatusRegistry.lua +++ b/src/battle/StatusRegistry.lua @@ -3,6 +3,7 @@ -- (canInflict) and so does the landing text (onInflict), so a mod status -- inflicts through the same path as the vanilla five. +local Gen2Read = require('src.battle.Gen2BattleRead') local Runtime = require("src.mods.Runtime") local Status = require("src.battle.Status") local Strings = require("src.core.Strings") @@ -32,7 +33,7 @@ function StatusRegistry.inflict(battle, target, status, opts) -- move's type matches either of the target's types (Body Slam can't -- paralyze Normals, Fire can't burn Fire, Ice can't freeze Ice) if opts.secondary and status ~= "PSN" then - for _, t in ipairs(target.curTypes or {}) do + for _, t in ipairs(Gen2Read.types(target) or {}) do if opts.moveType == t then return {} end end end diff --git a/src/battle/TurnOrder.lua b/src/battle/TurnOrder.lua index bb481a7ad..1ebd0c231 100644 --- a/src/battle/TurnOrder.lua +++ b/src/battle/TurnOrder.lua @@ -3,6 +3,7 @@ -- record's priority field; the id table below covers pre-existing -- imported caches (Gen 1 has only QUICK_ATTACK first and COUNTER last). +local Gen2Read = require('src.battle.Gen2BattleRead') local Damage = require("src.battle.Damage") local Stats = require("src.pokemon.Stats") local Status = require("src.battle.Status") @@ -17,7 +18,7 @@ local Abilities = require("src.battle.Abilities") -- nowhere else the weather is visible from here. Every existing caller -- passes nothing and gets exactly the old answer. local function effectiveSpeed(battler, battle) - local spd = Stats.applyStage(battler.curStats.speed, + local spd = Stats.applyStage(Gen2Read.stats(battler).speed, battler.stages and battler.stages.speed or 0) -- ApplyBadgeStatBoosts: the SOULBADGE boosts speed; the rows come from -- the battler's merged badgeBoosts with the vanilla list as fallback diff --git a/src/battle/Weather.lua b/src/battle/Weather.lua index e3a3fc95e..fc9a18bef 100644 --- a/src/battle/Weather.lua +++ b/src/battle/Weather.lua @@ -14,6 +14,7 @@ -- Weather.start and every other entry point is a nil check on -- battle.field.weather. +local Gen2Read = require('src.battle.Gen2BattleRead') local Strings = require("src.core.Strings") local Weather = {} @@ -296,7 +297,7 @@ local function weatherDamage(battle, battler, weather) return end local immune = WEATHER_IMMUNE[weather] or {} - for _, t in ipairs(battler.curTypes or {}) do + for _, t in ipairs(Gen2Read.types(battler) or {}) do if immune[t] then return end end battle:animNext(weather == Weather.HAIL and "inHail" or "inSandstorm", diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua new file mode 100644 index 000000000..93efa27f9 --- /dev/null +++ b/src/battle/gen2/Battle.lua @@ -0,0 +1,67 @@ +-- Copyright (c) 2026 Roxas2712. See https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md. +-- The live Gen2 model boundary of the shared native controller. This is not +-- an alias to the screen and does not construct or advance a second battle. +local Runtime = require('src.mods.Runtime') +local Model = {} +local screens = setmetatable({}, { __mode = 'kv' }) +local ended = setmetatable({}, { __mode = 'k' }) + +function Model.bind(model, screen) + screens[model] = screen + return model +end + +local function owner(model) + return assert(screens[model], 'native Gen2 model is not attached to a battle') +end + +local function active(screen, mon) + if screen.player and screen.player.mon == mon then return screen.player end + if screen.enemy and screen.enemy.mon == mon then return screen.enemy end +end + +function Model:moveDef(move) + return owner(self):moveDef(move) +end + +function Model:speciesDef(mon) + return mon and self.data.pokemon and self.data.pokemon[mon.species] or nil +end + +-- Raw native battle statistics, before stages, badges, status or held items. +-- Those modifiers remain in the actual damage/order owners and run once. +-- A caller's temporary shadow mon uses its own stats without changing a party +-- member. Active Transform statistics remain on the existing native wrapper. +function Model:battleStat(mon, key) + local screen = screens[self] + local battler = screen and active(screen, mon) + local stats = battler and battler.curStats or mon and mon.stats + return stats and stats[key] +end + +function Model:sideOf(mon) + local screen = owner(self) + local battler = active(screen, mon) + if battler then return battler.isPlayer and 'player' or 'enemy' end +end + +-- Finish notification is owned by the native result path. Presentation stays +-- on the native queue; completeBattle/finish perform its actual screen return. +-- Existing native event consumers retain the screen in `battle`; the distinct +-- live model is also provided explicitly. No second save/party snapshot exists. +local outcomes = { win=true, lose=true, run=true, caught=true, fled=true, draw=true } +function Model:endBattle(outcome, details) + local screen = owner(self) + assert(outcomes[outcome], 'invalid native Gen2 battle outcome') + if ended[self] then return false end + screen.result = outcome + screen:restoreMimicked(screen.player) + screen:restoreMimicked(screen.enemy) + ended[self] = true + local event = { battle=screen, model=self, result=outcome } + if details and details.skipped then event.skipped = true end + Runtime.emit('battle.ended', event) + return true +end + +return Model diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..69eece921 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -37,17 +37,10 @@ -- src.ui.gen2.BattleAnimView Gen2AnimPlayer is a player, not a view -- src.battle.UIVisibility no equivalent -- src.ui.PartyPanel no equivalent --- src.battle.gen2.Battle AMBIGUOUS, which is the same as absent here. --- src.ui.gen2.BattleState is already mapped to --- src.battle.BattleState below; a port that --- spells BOTH names is not spelling one thing --- twice, it is naming the battle CONTROLLER and --- the battle STATE. This engine has one object --- for both, so whichever of the two a mod meant --- it would get -- and the half of the contract --- it did not mean would be missing, somewhere --- far away from the require. It goes in when a --- mod that wants it says what it calls on it. +-- +-- src.battle.gen2.Battle is a real live model module, not an alias. Its native +-- reads/completion are attached by Gen2BattleView and Gen2BattleRead. Unsupported +-- constructors and turn APIs are not guessed from similarly named screens. -- -- Each of those turns one optional feature off in the mod and says so in the -- log, which is the outcome we want. diff --git a/tests/engine/gen2_battle_model_contract.lua b/tests/engine/gen2_battle_model_contract.lua new file mode 100644 index 000000000..fc535a856 --- /dev/null +++ b/tests/engine/gen2_battle_model_contract.lua @@ -0,0 +1,178 @@ +-- Native calculation and completion paths, decorated through the real SDK. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local Version = require('src.core.GameVersion') +local Screen = require('src.battle.BattleState') +local Model = require('src.battle.gen2.Battle') +local Read = require('src.battle.Gen2BattleRead') +local Pokemon = require('src.pokemon.Pokemon') +local Save = require('src.core.SaveData') +local Order = require('src.battle.TurnOrder') +local Status = require('src.battle.StatusRegistry') +local Effects = require('src.battle.MoveEffects') +local savedVersion = Version.get() +local nativeStat, nativeDef, nativeEnd = Model.battleStat, Model.speciesDef, Model.endBattle + +local function fixture(version) + Version.set(version) + local data = T.fixtures.fresh() + data.constants.gen, data.constants.generation = Version.generation(version), Version.generation(version) + data.type_chart.matchups[#data.type_chart.matchups+1] = { + attacker='NORMAL', defender='GHOST', multiplier=0, + } + local run = T.sdk.loadMods({'mods/model_probe'}, { + data=data, fs=T.sdk.memfs({ + ['mods/model_probe/manifest.json']=[[{ + "id":"model_probe","name":"Native model probe","version":"1.0.0", + "api":2,"entry":"main.lua","affects_link":true + }]], + ['mods/model_probe/main.lua']=[[ + local mod=... + local Model=require('src.battle.gen2.Battle') + local stat,def,finish=Model.battleStat,Model.speciesDef,Model.endBattle + mod.exports.scale=1 + Model.battleStat=function(model,mon,key) + local value=stat(model,mon,key) + if mon==model.player and (key=='attack' or key=='speed') then + return value*mod.exports.scale + end + return value + end + Model.speciesDef=function(model,mon) + local value=def(model,mon) + if not (value and mon==model.enemy and mod.exports.types) then return value end + local result={};for key,item in pairs(value)do result[key]=item end + result.types=mod.exports.types + return result + end + Model.endBattle=function(model,...) + if model.player then model.player._probeTransient=nil end + mod.exports.endCalls=(mod.exports.endCalls or 0)+1 + return finish(model,...) + end + mod.events:on('battle.ended',function(event) + mod.exports.ended=(mod.exports.ended or 0)+1 + mod.exports.event=event + mod.exports.cleanAtEvent=not(event.model and event.model.player + and event.model.player._probeTransient) + end) + ]], + }), + }) + T.eq(#run.errors,0,version..' real SDK model decorator loads') + require('src.render.Font').load(data) + local save=Save.newGame() + save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',18)} + local game={data=data,save=save} + local stack={states={},pops=0} + function stack:top()return self.states[#self.states]end + function stack:pop()self.pops=self.pops+1;return table.remove(self.states)end + function stack:push(state)self.states[#self.states+1]=state end + game.stack=stack + local battle=Screen.newWild(game,'FIXMON_C',23) + stack.states={{},battle} + return battle,game,run,assert(run.loader.exports.model_probe) +end + +for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do + local b,game,run,probe=fixture(version) + local model=b.battle + local supported=type(model.battleStat)=='function' and type(model.speciesDef)=='function' + T.check(supported,version..' actual model resolves the genuine model class') + T.check(model~=b,version..' screen and model stay distinct') + T.eq(model.player,game.save.party[1],'model retains live party identity') + T.eq(model.moveDef,Model.moveDef,'method lookup retains the class decoration seam') + if supported then + T.eq(model:speciesDef(model.player),game.data.pokemon[model.player.species],'actual species catalog row') + T.eq(model:speciesDef(nil),nil,'no invented missing species') + T.eq(model:battleStat(model.player,'attack'),b.player.curStats.attack,'native raw stat authority') + T.eq(model:battleStat({},'attack'),nil,'unknown stat stays absent') + T.eq(model:sideOf(model.player),'player','native player side') + T.eq(model:sideOf(model.enemy),'enemy','native enemy side') + end + T.eq(Read.stats(b.player).attack,b.player.curStats.attack,'default decorated read keeps native values') + local playerStats,enemyStats=b.player.curStats,b.enemy.curStats + local partyStats,types=b.player.mon.stats,b.enemy.def.types + local pp=b.player.curMoves[1].pp + local move=b:moveDef('FIX_TACKLE') + local function damage() + return b:computeDamage(b.player,b.enemy,move,{forceCrit=false,rng=function(lo,hi)return hi or lo end}) + end + local normal=damage() + local speed=Order.effectiveSpeed(b.player,b) + probe.scale=4 + local boosted=damage() + T.check(boosted>normal,version..' actual native damage consumes decorated model stat') + T.check(Order.effectiveSpeed(b.player,b)>speed,'actual native speed consumes same model stat') + T.eq(b.player.curStats,playerStats,'native stat table identity is retained') + T.eq(b.enemy.curStats,enemyStats,'native opposing stat table is retained') + T.eq(b.player.mon.stats,partyStats,'save stats are not replaced') + T.eq(b.player.curMoves[1].pp,pp,'queries cannot spend PP') + T.eq(Read.stats(b.player).attack,partyStats.attack*4,'live value has no frame cache') + probe.scale=1 + T.eq(damage(),normal,'removing presentation-owned stat override restores native damage') + probe.types={'GHOST'} + T.eq(damage(),0,'actual damage honors model species type immunity') + T.eq(b.enemy.def.types,types,'species registry type list is not replaced') + T.eq(b.enemy.curTypes,types,'native volatile type list is not replaced') + probe.types={'FIRE'} + b.enemy.mon.status=nil + Status.inflict(b,b.enemy,'BRN',{}) + T.eq(b.enemy.mon.status,nil,'actual native status immunity honors model type') + probe.types={'NORMAL'} + Status.inflict(b,b.enemy,'BRN',{}) + T.eq(b.enemy.mon.status,'BRN','removing immunity restores native status path') + b.enemy.mon.status=nil + b.enemy.curTypes={'WATER'} + probe.types={'GHOST'} + T.eq(Read.types(b.enemy)[1],'WATER','native Conversion list wins over a species decoration') + T.check(damage()>0,'volatile type override reaches actual damage') + b.enemy.curTypes=types + probe.types=nil + local old=b.player + b:switchPlayerInto(Screen.POS.PLAYER_LEFT,game.save.party[2]) + T.eq(model.player,game.save.party[2],'model follows actual native switch') + probe.scale=2 + T.eq(Read.stats(b.player).attack,b.player.curStats.attack*2,'new native battler is bound') + T.neq(b.player,old,'native wrapper really changed') + probe.scale=1 + -- Native Transform keeps temporary stats on its wrapper, never the save. + local source=b.enemy.curStats.attack + Effects.primary.TRANSFORM_EFFECT(b,b.player,b.enemy) + if supported then T.eq(model:battleStat(model.player,'attack'),source,'Transform uses actual native volatile stat')end + T.eq(model.player.stats,game.save.party[2].stats,'Transform does not replace saved stats') + model.player._probeTransient=true + b.result='run' + local complete=type(b.completeBattle)=='function' + T.check(complete,'native Gen2 completion seam exists') + if complete then + b:completeBattle() + T.eq(probe.ended,1,'actual native finish emits one ended event') + T.eq(probe.event.battle,b,'existing native consumers retain their screen owner') + T.eq(probe.event.model,model,'notification also exposes actual live model') + T.check(probe.cleanAtEvent,'model end decoration cleans before native end notification') + T.eq(game.stack.pops,1,'completion uses native stack return once') + b:completeBattle() + T.eq(game.stack.pops,1,'repeated completion cannot pop another screen') + if type(model.endBattle)=='function'then model:endBattle('run')end + T.eq(probe.ended,1,'repeated model completion cannot re-emit ended event') + end + require('src.mods.Loader').teardown() + run.release() + T.eq(Model.battleStat,nativeStat,'loader retirement restores model stat method') + T.eq(Model.speciesDef,nativeDef,'loader retirement restores model species method') + T.eq(Model.endBattle,nativeEnd,'loader retirement restores model end method') + T.eq(Read.stats(b.player),b.player.curStats,'undecorated read returns original stat table') + T.eq(Read.types(b.enemy),b.enemy.curTypes,'undecorated read returns original type list') +end +for _,version in ipairs({'red','firered','emerald'})do + local b,game,run,probe=fixture(version) + T.eq(b.battle,b,version..' keeps its original shared native identity') + probe.scale=9;probe.types={'GHOST'} + T.eq(Read.stats(b.player),b.player.curStats,'other generation retains exact raw stat table') + T.eq(Read.types(b.enemy),b.enemy.curTypes,'other generation retains exact raw type list') + require('src.mods.Loader').teardown() + run.release() +end +Version.set(savedVersion) +T.finish('native Gen2 model calculation and completion boundary') From a1684d351cb0e50d5c5509f46bae0fd1dfd0f810 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:33:05 +0100 Subject: [PATCH 02/14] Keep scalar model queries separate from older native screen signature --- src/battle/gen2/Battle.lua | 3 +++ tests/engine/gen2_battle_model_contract.lua | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 93efa27f9..1b4fb282f 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -21,6 +21,9 @@ local function active(screen, mon) end function Model:moveDef(move) + -- Older native screens take a move instance; scalar catalog queries belong + -- to this model and must not pass an id through that older signature. + if type(move) ~= 'table' then return self.data.moves[move] end return owner(self):moveDef(move) end diff --git a/tests/engine/gen2_battle_model_contract.lua b/tests/engine/gen2_battle_model_contract.lua index fc535a856..e128523f7 100644 --- a/tests/engine/gen2_battle_model_contract.lua +++ b/tests/engine/gen2_battle_model_contract.lua @@ -94,7 +94,7 @@ for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do local playerStats,enemyStats=b.player.curStats,b.enemy.curStats local partyStats,types=b.player.mon.stats,b.enemy.def.types local pp=b.player.curMoves[1].pp - local move=b:moveDef('FIX_TACKLE') + local move=b:moveDef(b.player.curMoves[1]) local function damage() return b:computeDamage(b.player,b.enemy,move,{forceCrit=false,rng=function(lo,hi)return hi or lo end}) end From a0baf99219097428a7fe5e58f71226157af5a4dc Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:37:00 +0100 Subject: [PATCH 03/14] Preserve native link and extension completion labels --- src/battle/gen2/Battle.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 1b4fb282f..044aaa6aa 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -52,10 +52,10 @@ end -- on the native queue; completeBattle/finish perform its actual screen return. -- Existing native event consumers retain the screen in `battle`; the distinct -- live model is also provided explicitly. No second save/party snapshot exists. -local outcomes = { win=true, lose=true, run=true, caught=true, fled=true, draw=true } function Model:endBattle(outcome, details) local screen = owner(self) - assert(outcomes[outcome], 'invalid native Gen2 battle outcome') + -- Preserve link/spectator and extension-owned native string outcomes. + assert(type(outcome) == 'string' and outcome ~= '', 'invalid native Gen2 battle outcome') if ended[self] then return false end screen.result = outcome screen:restoreMimicked(screen.player) From ad95984609d8b587eb8e6790432239d01bb6f657 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:37:34 +0100 Subject: [PATCH 04/14] Cover native link and spectator outcome strings in model fixture --- tests/engine/gen2_battle_model_contract.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/engine/gen2_battle_model_contract.lua b/tests/engine/gen2_battle_model_contract.lua index e128523f7..7b6acba6b 100644 --- a/tests/engine/gen2_battle_model_contract.lua +++ b/tests/engine/gen2_battle_model_contract.lua @@ -142,12 +142,14 @@ for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do if supported then T.eq(model:battleStat(model.player,'attack'),source,'Transform uses actual native volatile stat')end T.eq(model.player.stats,game.save.party[2].stats,'Transform does not replace saved stats') model.player._probeTransient=true - b.result='run' + b.result=({gold='ended',silver='hostWin',crystal='guestWin',prism='draw',polishedcrystal='run'})[version] + local outcome=b.result local complete=type(b.completeBattle)=='function' T.check(complete,'native Gen2 completion seam exists') if complete then b:completeBattle() T.eq(probe.ended,1,'actual native finish emits one ended event') + T.eq(probe.event.result,outcome,'completion retains the native owner string unchanged') T.eq(probe.event.battle,b,'existing native consumers retain their screen owner') T.eq(probe.event.model,model,'notification also exposes actual live model') T.check(probe.cleanAtEvent,'model end decoration cleans before native end notification') From 870dbab1b5d56cfa9fb3a2a29757d823d9554ec1 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:39:49 +0100 Subject: [PATCH 05/14] Preserve absent native result while reporting completion fallback --- src/battle/BattleState.lua | 2 +- src/battle/Gen2BattleView.lua | 2 +- src/battle/gen2/Battle.lua | 2 +- tests/engine/gen2_battle_model_contract.lua | 5 +++-- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 9b4ef71b4..11de665d4 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -10864,7 +10864,7 @@ function BattleState:finish() require("src.core.Music").restoreMap(self.data) if GameVersion.isGen2() then self._gen2Complete = true end self.game.stack:pop() - if GameVersion.isGen2() then self.battle:endBattle(self.result or 'run') + if GameVersion.isGen2() then self.battle:endBattle(self.result or 'run', {nativeNotification=true}) else Runtime.emit("battle.ended", { battle = self, result = self.result or "run" }) end -- Coming back from the battle screen is a fade, not a cut: EnterMap sees -- BIT_BATTLE_OVER_OR_BLACKOUT set and runs MapEntryAfterBattle diff --git a/src/battle/Gen2BattleView.lua b/src/battle/Gen2BattleView.lua index d5fd55437..06605b8e1 100644 --- a/src/battle/Gen2BattleView.lua +++ b/src/battle/Gen2BattleView.lua @@ -25,7 +25,7 @@ function View.bind(screen) wild = function() return screen.kind == "wild" end, playerIndex = function() return indexOf(partyOf(screen), screen.player and screen.player.mon) end, enemyIndex = function() return indexOf(screen.enemyParty, screen.enemy and screen.enemy.mon) end, - over = function() return screen.result ~= nil or screen.dead == true end, + over = function() return screen.result ~= nil or screen.dead == true or screen._gen2Complete == true end, outcome = function() return screen.result end, battleType = function() return screen.battleType end, trainer = function() return screen.trainer end, diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 044aaa6aa..8f00ffd0f 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -57,7 +57,7 @@ function Model:endBattle(outcome, details) -- Preserve link/spectator and extension-owned native string outcomes. assert(type(outcome) == 'string' and outcome ~= '', 'invalid native Gen2 battle outcome') if ended[self] then return false end - screen.result = outcome + if not (details and details.nativeNotification) then screen.result = outcome end screen:restoreMimicked(screen.player) screen:restoreMimicked(screen.enemy) ended[self] = true diff --git a/tests/engine/gen2_battle_model_contract.lua b/tests/engine/gen2_battle_model_contract.lua index 7b6acba6b..dd622c770 100644 --- a/tests/engine/gen2_battle_model_contract.lua +++ b/tests/engine/gen2_battle_model_contract.lua @@ -142,14 +142,15 @@ for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do if supported then T.eq(model:battleStat(model.player,'attack'),source,'Transform uses actual native volatile stat')end T.eq(model.player.stats,game.save.party[2].stats,'Transform does not replace saved stats') model.player._probeTransient=true - b.result=({gold='ended',silver='hostWin',crystal='guestWin',prism='draw',polishedcrystal='run'})[version] + b.result=({gold='ended',silver='hostWin',crystal='guestWin',prism='draw'})[version] local outcome=b.result local complete=type(b.completeBattle)=='function' T.check(complete,'native Gen2 completion seam exists') if complete then b:completeBattle() T.eq(probe.ended,1,'actual native finish emits one ended event') - T.eq(probe.event.result,outcome,'completion retains the native owner string unchanged') + T.eq(probe.event.result,outcome or 'run','notification retains the native event fallback') + T.eq(b.result,outcome,'notification does not persist an event-only fallback') T.eq(probe.event.battle,b,'existing native consumers retain their screen owner') T.eq(probe.event.model,model,'notification also exposes actual live model') T.check(probe.cleanAtEvent,'model end decoration cleans before native end notification') From 395c52643f6be50d5501527b9acdd3265d9e60c1 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:43:44 +0100 Subject: [PATCH 06/14] Bind external native battlers on placement and side synchronization --- src/battle/BattleState.lua | 8 +++++++- tests/engine/gen2_battle_model_contract.lua | 12 ++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 11de665d4..9c683585c 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -3432,7 +3432,10 @@ end function BattleState:placeBattler(pos, battler) local side, flank = slotOf(pos) self.sides[side].battlers[flank] = battler - if battler then battler.position = pos end + if battler then + battler.position = pos + Gen2Read.bind(battler, self) + end if flank == 1 then if side == 1 then self.player = battler else self.enemy = battler end end @@ -3552,6 +3555,9 @@ function BattleState:syncSides() if pr then pr.position = BattleState.POS.PLAYER_RIGHT end if orr then orr.position = BattleState.POS.OPPONENT_RIGHT end end + for _, battler in activeBattlers(self) do + Gen2Read.bind(battler, self) + end end -- IS THIS SIDE STILL ARRIVING? diff --git a/tests/engine/gen2_battle_model_contract.lua b/tests/engine/gen2_battle_model_contract.lua index dd622c770..b16f8d48c 100644 --- a/tests/engine/gen2_battle_model_contract.lua +++ b/tests/engine/gen2_battle_model_contract.lua @@ -137,6 +137,18 @@ for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do T.neq(b.player,old,'native wrapper really changed') probe.scale=1 -- Native Transform keeps temporary stats on its wrapper, never the save. + -- Link/scaffold owners use the public factory without a screen argument, + -- then place/synchronize their actual wrappers on the native controller. + local placed=Screen.makeBattler(game.data,game.save.party[1],true,nil) + b:placeBattler(Screen.POS.PLAYER_LEFT,placed) + probe.scale=3 + T.eq(Read.stats(placed).attack,placed.curStats.attack*3,'native placement binds a public-factory wrapper') + local rebound=Screen.makeBattler(game.data,game.save.party[2],true,nil) + b.player=rebound + b:syncSides() + T.eq(Read.stats(rebound).attack,rebound.curStats.attack*3,'native side synchronization binds external wrappers') + T.eq(model.player,game.save.party[2],'external native replacement retains live model identity') + probe.scale=1 local source=b.enemy.curStats.attack Effects.primary.TRANSFORM_EFFECT(b,b.player,b.enemy) if supported then T.eq(model:battleStat(model.player,'attack'),source,'Transform uses actual native volatile stat')end From c7097ee16aeb8618cb8f0aafef02705856f047cc Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:29:36 +0100 Subject: [PATCH 07/14] Admit typed Gen2 events through upstream native battle presentation --- docs/native-gen2-event-queue.md | 31 ++ src/battle/BattleQueuePhase.lua | 15 + src/battle/BattleState.lua | 665 ++++++++++++++++------------ src/battle/Gen2BattleView.lua | 7 + src/battle/Gen2EventQueue.lua | 186 ++++++++ src/battle/Gen2HpPresentation.lua | 136 ++++++ src/battle/Gen2MovePresentation.lua | 66 +++ src/battle/MoveSelectionPhase.lua | 16 + src/link/LinkBattle.lua | 19 +- 9 files changed, 839 insertions(+), 302 deletions(-) create mode 100644 docs/native-gen2-event-queue.md create mode 100644 src/battle/BattleQueuePhase.lua create mode 100644 src/battle/Gen2EventQueue.lua create mode 100644 src/battle/Gen2HpPresentation.lua create mode 100644 src/battle/Gen2MovePresentation.lua create mode 100644 src/battle/MoveSelectionPhase.lua diff --git a/docs/native-gen2-event-queue.md b/docs/native-gen2-event-queue.md new file mode 100644 index 000000000..e80a6a00f --- /dev/null +++ b/docs/native-gen2-event-queue.md @@ -0,0 +1,31 @@ +# Native Gen2 typed event admission + +This change adapts the real upstream shared controller, on top of the native +model boundary, rather than substituting a fork battle controller. +`push`, `pushAll`, `pushFront` and the actual `advanceQueue` boundary share the +existing queue. Supported kinds are message, item, weather, stage, run, money, +win-text, move, damage, heal, status and text-pause. Unsupported kinds are +explicit errors, rather than silently discarded combat events. + +Native producers emit observable messages/move announcements. Typing, VM, +HP/status snapshots and sound completion remain owned by native presentation; +HP/status snapshots never rewrite combat HP/status or spend PP. A queue +observer runs at actual admission and sees the settled decision phase when an +empty Gen2 queue returns. Gen2 resolving/moves spellings and older stored +messages/moveSelect spellings reach the same update/draw/link owners. + +The move entry points run this upstream version's actual Gen2AnimPlayer. Its +parameter is installed in that VM's vars; its clock remains native. This +change does not fabricate the newer runner/OAM observation API around that +older VM, and does not claim the fork's later object renderer/animation parity. + +Still outside this incremental change: send/faint/identity/Transform/experience, +learning/catch/trainer/picker event consumers, the pure model constructor/turn +protocol, checkpoint integration absent from this upstream base, unchanged +client acceptance and Windows/macOS GPU/package qualification. + +Native queue and HP stepping retain the Gen1Recomp/upstream provenance noted +in their source files. Original native-owner integration contributions are +by Roxas2712; see the offered contribution licence: +https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md +Existing third-party rights and notices remain effective. diff --git a/src/battle/BattleQueuePhase.lua b/src/battle/BattleQueuePhase.lua new file mode 100644 index 000000000..e9925582c --- /dev/null +++ b/src/battle/BattleQueuePhase.lua @@ -0,0 +1,15 @@ +-- One native queue, with the public Gen2 resolution phase. Earlier shared +-- checkpoints retain their stored spelling until the next native update. +local Version = require("src.core.GameVersion") +local M = {} + +function M.name() + return Version.isGen2() and "resolving" or "messages" +end + +function M.active(screen) + return screen.phase == "messages" + or (screen.phase == "resolving" and Version.isGen2()) +end + +return M diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 9c683585c..764421688 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -39,6 +39,9 @@ local Gen3Battle = require("src.battle.Gen3Battle") local Gen4Battle = require("src.battle.Gen4Battle") local GameVersion = require("src.core.GameVersion") local Gen2BattleView = require('src.battle.Gen2BattleView') +local Gen2Queue = require('src.battle.Gen2EventQueue') +local BattleQueuePhase = require('src.battle.BattleQueuePhase') +local MoveSelectionPhase = require('src.battle.MoveSelectionPhase') local Gen2Read = require('src.battle.Gen2BattleRead') -- FORWARD-DECLARED, because they are defined next to the other doubles @@ -2246,6 +2249,7 @@ end -- --------------------------------------------------------------------- function BattleState:say(text) + if GameVersion.isGen2() then return self:push({kind='message',text=text}) end table.insert(self.queue, { text = text }) end @@ -2330,6 +2334,7 @@ end -- insert message right after the currently-executing queue item (the -- counter is reset by updateQueue before each fn item runs) function BattleState:sayNext(text) + if GameVersion.isGen2() then return Gen2Queue.afterCurrent(self,{kind='message',text=text}) end self.nextInsert = (self.nextInsert or 0) + 1 table.insert(self.queue, self.nextInsert, { text = text }) end @@ -2447,6 +2452,7 @@ end -- -- Returns true while animating. function BattleState:stepHPDrain() + if GameVersion.isGen2() then return require('src.battle.Gen2HpPresentation').drain(self) end local busy = false for _, b in activeBattlers(self) do if b and b.shownHP then @@ -2559,8 +2565,27 @@ end -- keeps the control chars out of the glyph stream. The box then types into -- a rolling 2-line window (self.shown) that scrolls when a 3rd line arrives -- instead of drawing it off-screen at y=144 (#216). +function BattleState:stepHpAnim() + return require('src.battle.Gen2HpPresentation').step(self) +end + +function BattleState:clearQueueMessage() + if GameVersion.isGen2() then self.message,self.messageTimer=nil,0 end +end + +function BattleState:stepPresentationWait() + if self.waitFrames and self.waitFrames>0 then + self.waitFrames=self.waitFrames-1 + return true + end + return false +end + function BattleState:startMessage(item) self.current = item + if GameVersion.isGen2() then + self.message,self.messageTimer=item.text or '',item.gen2Auto and 0 or 48 + end self.lines = {} self.total = 0 local text = item.text or "" @@ -2604,11 +2629,8 @@ function BattleState:beginMsgLine() self.shown[#self.shown + 1] = {} end -function BattleState:updateQueue() - if self.waitingUI then - if self.game.stack:top() ~= self then return true end - self.waitingUI = nil - end +function BattleState:updateQueuePresentation(textOnly) + if textOnly then return self:stepPresentationWait() end -- HOENN'S PARTICLES TICK BEFORE THE QUEUE'S OWN HOLD, and the whole -- reason the animations looked broken is that they used to tick after it. -- @@ -2675,9 +2697,11 @@ function BattleState:updateQueue() -- "messages" with nothing on screen for good -- seen once in a long -- HGSS playthrough run, at the start of a double battle. self.waitingSoundT = (self.waitingSoundT or 0) + 1 - if src and src.isPlaying and src:isPlaying() and self.waitingSoundT <= 240 then return true end - if self.waitingSoundT > 240 then - require("src.core.Logger").warn("battle: a sound never finished -- stopped waiting after 240 frames") + local maximum=type(src)=='table' and src.maxWait or 240 + if src and src.isPlaying and src:isPlaying() and self.waitingSoundT <= maximum then return true end + if self.waitingSoundT > maximum then + if type(src)=='table' and src.onTimeout then src.onTimeout() end + require("src.core.Logger").warn("battle: a sound never finished -- stopped waiting after %d frames",maximum) end self.waitingSound, self.waitingSoundT = nil, nil end @@ -2685,6 +2709,7 @@ function BattleState:updateQueue() if self.draining then if self:stepHPDrain() then return true end self.draining = nil + if GameVersion.isGen2() then require('src.battle.Gen2HpPresentation').retire(self) end if self.player then self.player.drainFloor = nil end if self.enemy then self.enemy.drainFloor = nil end end @@ -2693,7 +2718,276 @@ function BattleState:updateQueue() if self:stepExpBar() then return true end self.expFilling = nil end - if self.animPlaying then + return false +end + +local function completeQueue(self) + if self.afterQueue=='menu' then + self.phase='menu';self:clearQueueMessage() + elseif self.afterQueue=='moveSelect' then + self.phase=MoveSelectionPhase.name();self:clearQueueMessage() + elseif self.afterQueue=='finish' then self:finish() end +end + +-- The actual dequeue boundary, after native presentation gates release it. +function BattleState:advanceQueue() + local item = table.remove(self.queue, 1) + while item and item.nativeGen2Event do + item=Gen2Queue.activate(self,item) + if item==nil then return true end + if item==false then return self:advanceQueue() else break end + end + if not item then + self.nextInsert=nil + if GameVersion.isGen2() then completeQueue(self) end + return false + end + if item.fn then + self.nextInsert = 0 -- sayNext inserts right after this item + item.fn() + self.nextInsert=nil + self.current = nil + return true + end + if item.ui then + self.waitingUI = true + if GameVersion.isGen2() then self.phase='submenu' end + self.game.stack:push(item.ui()) + return true + end + if item.drain then + self.draining = true + if GameVersion.isGen2() then self._gen2HpDrainBattler=item.battler end + if item.battler then item.battler.drainFloor = item.stopAt end + return true + end + if item.expBar then + self.expFilling = true + return true + end + if item.wait then + self.waitFrames = item.wait + return true + end + if item.waitSound then + -- the source is fetched now, not when the row was queued, so the + -- act() that started the sound has already run + self.waitingSound = item.waitSound() + return true + end + if item.mimicSelect then + -- pause the queue on Mimic's copy menu (MoveSelectionMenu with + -- wMoveMenuType = 1 lists the enemy's moves; cursor starts on 1) + local ctx = item.mimicSelect + local rows = {} + for i, m in ipairs(ctx.target.curMoves) do + if m.id and m.pp ~= nil then rows[#rows + 1] = { slot = i, id = m.id } end + end + self.mimicMoves = rows + self.mimicIndex = 1 + self.mimicCtx = ctx + self.phase = "mimicSelect" + return true + end + -- animation queue rows: play the move sound and start the + -- subanimation (or just the coarse fx when animations are off). + -- item.hit carries the target's blink + damage sound, applied when + -- the animation ends (hitRow rows carry a hit with no animation -- + -- thrash/rage continuation turns that skip the announcement). + if item.anim or item.hitRow then + -- PlayMoveAnimation writes wAnimationID, calls Delay3, and only then + -- jumps to MoveAnimation (core.asm:6635-6640), so three frames pass + -- between the move's announcement and the first frame of its + -- animation. Put the row back and pay that first. + if item.anim and not item.animDelayed then + item.animDelayed = true + table.insert(self.queue, 1, item) + self.waitFrames = Timing.MOVE_ANIM_PRE + return true + end + local mdef = item.anim and self.data.moves[item.anim] + local anim = mdef and mdef.anim + if item.anim == "POOF_ANIM" then + -- the send-out poof plays SFX_BALL_POOF + require("src.core.Sound").play(self.data, "Ball_Poof") + elseif item.anim == "HIDEPIC_ANIM" then + self.enemyHidden = true -- SE_HIDE_ENEMY_MON_PIC + elseif item.anim == "SHOWPIC_ANIM" then + self.enemyHidden = false -- SE_SHOW_ENEMY_MON_PIC + end + -- ball/send-out anims ignore the OPTIONS toggle: PlayMoveAnimation + -- short-circuits to TossBallAnimation before its wOptions check + -- (engine/battle/animations.asm:415) + if item.anim and (self:animationsOn() or BALL_ANIMS[item.anim]) then + -- HOENN'S OWN ANIMATION, when the move has one. It does not take the + -- animPlaying path: that one belongs to the Game Boy players, which + -- carry their own per-row sounds, and this one does not -- so the + -- single-sound fallback below still runs and the move both looks and + -- sounds like itself. The queue is held with waitFrames instead, for + -- as long as the script's own delays plus the particles' life. + -- SINNOH FIRST, because `gen4Layout()` and `gen3Layout()` are + -- different questions and a Gen 4 battle must not fall into Emerald's + -- animator -- which would look up a Hoenn move index in a Hoenn table + -- and play whatever happened to be there. + -- BLACK AND WHITE'S player stands a double's attacker and defender + -- on their own slots (Gen5MoveAnimPlayer:setLayout) + if self.gen4Anim and self.gen4Anim.setLayout then + pcall(self.gen4Anim.setLayout, self.gen4Anim, self:isDouble() and "double" or "single", + item.attackerPosition, item.targetPosition) + end + if self:gen4Layout() and self.gen4Anim and item.anim + and self.gen4Anim:duration(item.anim, item.attackerIsPlayer) > 0 then + self.gen4AnimPlaying = true + -- WHICH SIDE IS ATTACKING, for the sound seam: a cry event names the side + -- and this is what turns that into a Pokemon. Set before the animation + -- runs, because `duration` above has already started it. + self.gen4AnimAttackerIsPlayer = item.attackerIsPlayer and true or false + -- `duration` RAN the program to measure this and then restarted it, + -- so the number is the program's own length rather than an estimate, + -- and the queue waits exactly that long. + self.waitFrames = math.max(self.waitFrames or 0, + self.gen4Anim:duration(item.anim, item.attackerIsPlayer)) + -- MAIN'S WIDER GEN 3 SIGNATURE, kept: `has`/`start` now take the + -- animation context and both battler positions. The Gen 4 arm above is + -- unaffected -- it never reaches this call -- so the two merge by + -- keeping the branch from Platinum and the arguments from main. + elseif self.gen3Anim and self.gen3Anim:has(item.anim, item.animContext) + and self.gen3Anim:start(item.anim, item.attackerIsPlayer, + item.attackerPosition, item.targetPosition, + item.animContext, self) then + self.gen3AnimPlaying = true + self.waitFrames = math.max(self.waitFrames or 0, + self.gen3Anim.total or 0) + elseif self.animPlayer then + local ok, started = pcall(self.animPlayer.start, self.animPlayer, + item.anim, item.attackerIsPlayer, + (item.shakes or item.ball) + and { shakes = item.shakes, ball = item.ball, + caught = item.caught, + -- Gen 2's toss script branches on the + -- ball's item index (wBattleAnimParam) + ballIndex = item.ball + and (self:itemDef(item.ball) or {}).index + or nil, + ballFlicker = item.ball + and self:ballFlicker(item.ball) or nil } + or nil) + -- a player that reports no data for the move (Gen 2 returns + -- false) falls through to the single-sound path below + self.animPlaying = ok and started ~= false + end + self.fx = self.fx or {} + -- HOW LONG THE COARSE REACTION LASTS. + -- + -- The reaction is a class this port chose; its LENGTH is the move's + -- own. Every one of these scripts still walks its delays, and the + -- import carries that total across (`anim.duration`), so EARTHQUAKE + -- heaves for as long as its script runs and HARDEN's flourish is over + -- in its own handful of frames. The old fixed 24 and 16 are what a + -- dataset imported before that stamp still gets. + local coarse = anim and tonumber(anim.duration) or nil + -- A REACTION HAS TO LAST LONG ENOUGH TO BE ONE. Thirteen of the + -- twenty-five carry a length; the shortest of them is a single frame, + -- because everything that script does happens inside its task and the + -- delays it walks are almost none. One frame of shake is not a + -- reaction, it is a glitch, so below one visible beat the port's own + -- minimum stands. + if coarse and coarse < BattleState.COARSE_ANIM_MIN then coarse = nil end + -- THE HEAVE FIRST, because it is the cartridge's own numbers and + -- the wobble below is this port's. EARTHQUAKE swings the field + -- thirteen pixels fifty times, every second frame; the coarse + -- fallback swung it two, for twenty-four frames. The program is + -- the same one the SE-driven shakes already run. + if anim and anim.heave and not self.animPlaying then + local h = anim.heave + local amplitude = tonumber(h.amplitude) or 0 + local every = math.max(1, tonumber(h.every) or 2) + local swings = math.max(1, tonumber(h.swings) or 1) + if amplitude > 0 then + local prog = {} + for i = 0, swings - 1 do + prog[#prog + 1] = { dx = (i % 2 == 0) and amplitude + or -amplitude, frames = every } + end + -- ...and back to where it started, as the task's own last act is + -- to put the register back + prog[#prog + 1] = { dx = 0, frames = every } + self.fx.shakeProg = prog + self.waitFrames = math.max(self.waitFrames or 0, + (swings + 1) * every) + end + end + if anim and anim.shake and not self.animPlaying then + self.fx.shake = coarse or 24 + end + if anim and anim.flash and not self.animPlaying then + self.fx.flash = coarse or 16 + end + -- IN HOENN the coarse reaction is the move's whole animation, not a + -- consolation for one that could not play: the moves that get it are + -- the ones whose every pixel is drawn by a task -- HARDEN, AGILITY, + -- EARTHQUAKE -- and there is nothing else coming. So the queue waits + -- for it, the way it waits for the particles; on a Game Boy dataset + -- (no Gen 3 player at all) the timing is untouched. + if self.gen3Anim and not self.gen3AnimPlaying and anim + and (anim.shake or anim.flash) and not self.animPlaying then + self.waitFrames = math.max(self.waitFrames or 0, + coarse or (anim.shake and 24 or 16)) + end + end + if self.animPlaying then + -- the animation rows carry their own sounds (PlayAnimation + -- plays each row's MoveSoundTable entry with its pitch/tempo + -- modifiers); which side the pic effects target follows the + -- attacker (hWhoseTurn) + self.animName = item.anim + self.animAttackerIsPlayer = item.attackerIsPlayer + self:resetPicFx() + for _, ev in ipairs(self.animPlayer:pollEffects()) do + self:applyAnimEffect(ev) -- frame-0 rows (first sound/effect) + end + self.pendingHit = item.hit + else + -- no subanimation player: keep the single-sound fallback (with + -- the move's pitch/tempo modifiers; GROWL/ROAR play the + -- attacker's cry -- GetMoveSound/IsCryMove) + if item.anim == "GROWL" or item.anim == "ROAR" then + local attacker = item.attackerPosition ~= nil + and self:battlerAt(item.attackerPosition) + or (item.attackerIsPlayer and self.player or self.enemy) + if attacker then + require("src.core.Sound").playMoveCry(self.data, attacker.mon.species, + anim and anim.tempo) + end + elseif anim and anim.sound then + local Sound = require("src.core.Sound") + if Sound.playMove then + Sound.playMove(self.data, anim) + else + Sound.play(self.data, anim.sound) + end + end + if item.hit then + self:applyHitFx(item.hit) + end + end + self.current = nil + return true + end + self:startMessage(item) + return true +end + +function BattleState:updateQueue() + if GameVersion.isGen2() and self.phase=='messages' then self.phase=BattleQueuePhase.name() end + if self.waitingUI then + if self.game.stack:top() ~= self then return true end + self.waitingUI = nil + if GameVersion.isGen2() and self.phase=='submenu' then self.phase=BattleQueuePhase.name() end + end + local typing=Gen2Queue.typing(self) + if self:updateQueuePresentation(typing) then return true end + if self.animPlaying and not typing then self.animPlayer:update() if self.animPlayer.pollEffects and self.applyAnimEffect then for _, ev in ipairs(self.animPlayer:pollEffects()) do @@ -2717,248 +3011,22 @@ function BattleState:updateQueue() -- again. Dig/Fly's charge hide and a genuinely invulnerable battler -- are the two that must survive, same as in resetPicFx. self:restoreHiddenPics() + if GameVersion.isGen2() and self:startPendingAfterAnim() then return true end -- the target's hit blink + damage sound follow the animation -- (pokered plays them after PlayMoveAnimation returns) if self.pendingHit then - self:applyHitFx(self.pendingHit) + if not (GameVersion.isGen2() and self.afterAnimPlayed) then + self:applyHitFx(self.pendingHit) + end self.pendingHit = nil end + if GameVersion.isGen2() and not Gen2Queue.isCurrent(self) then self.afterAnimPlayed=nil end end return true end if not self.current then - local item = table.remove(self.queue, 1) - if not item then return false end - if item.fn then - self.nextInsert = 0 -- sayNext inserts right after this item - item.fn() - self.current = nil - return true - end - if item.ui then - self.waitingUI = true - self.game.stack:push(item.ui()) - return true - end - if item.drain then - self.draining = true - if item.battler then item.battler.drainFloor = item.stopAt end - return true - end - if item.expBar then - self.expFilling = true - return true - end - if item.wait then - self.waitFrames = item.wait - return true - end - if item.waitSound then - -- the source is fetched now, not when the row was queued, so the - -- act() that started the sound has already run - self.waitingSound = item.waitSound() - return true - end - if item.mimicSelect then - -- pause the queue on Mimic's copy menu (MoveSelectionMenu with - -- wMoveMenuType = 1 lists the enemy's moves; cursor starts on 1) - local ctx = item.mimicSelect - local rows = {} - for i, m in ipairs(ctx.target.curMoves) do - if m.id and m.pp ~= nil then rows[#rows + 1] = { slot = i, id = m.id } end - end - self.mimicMoves = rows - self.mimicIndex = 1 - self.mimicCtx = ctx - self.phase = "mimicSelect" - return true - end - -- animation queue rows: play the move sound and start the - -- subanimation (or just the coarse fx when animations are off). - -- item.hit carries the target's blink + damage sound, applied when - -- the animation ends (hitRow rows carry a hit with no animation -- - -- thrash/rage continuation turns that skip the announcement). - if item.anim or item.hitRow then - -- PlayMoveAnimation writes wAnimationID, calls Delay3, and only then - -- jumps to MoveAnimation (core.asm:6635-6640), so three frames pass - -- between the move's announcement and the first frame of its - -- animation. Put the row back and pay that first. - if item.anim and not item.animDelayed then - item.animDelayed = true - table.insert(self.queue, 1, item) - self.waitFrames = Timing.MOVE_ANIM_PRE - return true - end - local mdef = item.anim and self.data.moves[item.anim] - local anim = mdef and mdef.anim - if item.anim == "POOF_ANIM" then - -- the send-out poof plays SFX_BALL_POOF - require("src.core.Sound").play(self.data, "Ball_Poof") - elseif item.anim == "HIDEPIC_ANIM" then - self.enemyHidden = true -- SE_HIDE_ENEMY_MON_PIC - elseif item.anim == "SHOWPIC_ANIM" then - self.enemyHidden = false -- SE_SHOW_ENEMY_MON_PIC - end - -- ball/send-out anims ignore the OPTIONS toggle: PlayMoveAnimation - -- short-circuits to TossBallAnimation before its wOptions check - -- (engine/battle/animations.asm:415) - if item.anim and (self:animationsOn() or BALL_ANIMS[item.anim]) then - -- HOENN'S OWN ANIMATION, when the move has one. It does not take the - -- animPlaying path: that one belongs to the Game Boy players, which - -- carry their own per-row sounds, and this one does not -- so the - -- single-sound fallback below still runs and the move both looks and - -- sounds like itself. The queue is held with waitFrames instead, for - -- as long as the script's own delays plus the particles' life. - -- SINNOH FIRST, because `gen4Layout()` and `gen3Layout()` are - -- different questions and a Gen 4 battle must not fall into Emerald's - -- animator -- which would look up a Hoenn move index in a Hoenn table - -- and play whatever happened to be there. - -- BLACK AND WHITE'S player stands a double's attacker and defender - -- on their own slots (Gen5MoveAnimPlayer:setLayout) - if self.gen4Anim and self.gen4Anim.setLayout then - pcall(self.gen4Anim.setLayout, self.gen4Anim, self:isDouble() and "double" or "single", - item.attackerPosition, item.targetPosition) - end - if self:gen4Layout() and self.gen4Anim and item.anim - and self.gen4Anim:duration(item.anim, item.attackerIsPlayer) > 0 then - self.gen4AnimPlaying = true - -- WHICH SIDE IS ATTACKING, for the sound seam: a cry event names the side - -- and this is what turns that into a Pokemon. Set before the animation - -- runs, because `duration` above has already started it. - self.gen4AnimAttackerIsPlayer = item.attackerIsPlayer and true or false - -- `duration` RAN the program to measure this and then restarted it, - -- so the number is the program's own length rather than an estimate, - -- and the queue waits exactly that long. - self.waitFrames = math.max(self.waitFrames or 0, - self.gen4Anim:duration(item.anim, item.attackerIsPlayer)) - -- MAIN'S WIDER GEN 3 SIGNATURE, kept: `has`/`start` now take the - -- animation context and both battler positions. The Gen 4 arm above is - -- unaffected -- it never reaches this call -- so the two merge by - -- keeping the branch from Platinum and the arguments from main. - elseif self.gen3Anim and self.gen3Anim:has(item.anim, item.animContext) - and self.gen3Anim:start(item.anim, item.attackerIsPlayer, - item.attackerPosition, item.targetPosition, - item.animContext, self) then - self.gen3AnimPlaying = true - self.waitFrames = math.max(self.waitFrames or 0, - self.gen3Anim.total or 0) - elseif self.animPlayer then - local ok, started = pcall(self.animPlayer.start, self.animPlayer, - item.anim, item.attackerIsPlayer, - (item.shakes or item.ball) - and { shakes = item.shakes, ball = item.ball, - caught = item.caught, - -- Gen 2's toss script branches on the - -- ball's item index (wBattleAnimParam) - ballIndex = item.ball - and (self:itemDef(item.ball) or {}).index - or nil, - ballFlicker = item.ball - and self:ballFlicker(item.ball) or nil } - or nil) - -- a player that reports no data for the move (Gen 2 returns - -- false) falls through to the single-sound path below - self.animPlaying = ok and started ~= false - end - self.fx = self.fx or {} - -- HOW LONG THE COARSE REACTION LASTS. - -- - -- The reaction is a class this port chose; its LENGTH is the move's - -- own. Every one of these scripts still walks its delays, and the - -- import carries that total across (`anim.duration`), so EARTHQUAKE - -- heaves for as long as its script runs and HARDEN's flourish is over - -- in its own handful of frames. The old fixed 24 and 16 are what a - -- dataset imported before that stamp still gets. - local coarse = anim and tonumber(anim.duration) or nil - -- A REACTION HAS TO LAST LONG ENOUGH TO BE ONE. Thirteen of the - -- twenty-five carry a length; the shortest of them is a single frame, - -- because everything that script does happens inside its task and the - -- delays it walks are almost none. One frame of shake is not a - -- reaction, it is a glitch, so below one visible beat the port's own - -- minimum stands. - if coarse and coarse < BattleState.COARSE_ANIM_MIN then coarse = nil end - -- THE HEAVE FIRST, because it is the cartridge's own numbers and - -- the wobble below is this port's. EARTHQUAKE swings the field - -- thirteen pixels fifty times, every second frame; the coarse - -- fallback swung it two, for twenty-four frames. The program is - -- the same one the SE-driven shakes already run. - if anim and anim.heave and not self.animPlaying then - local h = anim.heave - local amplitude = tonumber(h.amplitude) or 0 - local every = math.max(1, tonumber(h.every) or 2) - local swings = math.max(1, tonumber(h.swings) or 1) - if amplitude > 0 then - local prog = {} - for i = 0, swings - 1 do - prog[#prog + 1] = { dx = (i % 2 == 0) and amplitude - or -amplitude, frames = every } - end - -- ...and back to where it started, as the task's own last act is - -- to put the register back - prog[#prog + 1] = { dx = 0, frames = every } - self.fx.shakeProg = prog - self.waitFrames = math.max(self.waitFrames or 0, - (swings + 1) * every) - end - end - if anim and anim.shake and not self.animPlaying then - self.fx.shake = coarse or 24 - end - if anim and anim.flash and not self.animPlaying then - self.fx.flash = coarse or 16 - end - -- IN HOENN the coarse reaction is the move's whole animation, not a - -- consolation for one that could not play: the moves that get it are - -- the ones whose every pixel is drawn by a task -- HARDEN, AGILITY, - -- EARTHQUAKE -- and there is nothing else coming. So the queue waits - -- for it, the way it waits for the particles; on a Game Boy dataset - -- (no Gen 3 player at all) the timing is untouched. - if self.gen3Anim and not self.gen3AnimPlaying and anim - and (anim.shake or anim.flash) and not self.animPlaying then - self.waitFrames = math.max(self.waitFrames or 0, - coarse or (anim.shake and 24 or 16)) - end - end - if self.animPlaying then - -- the animation rows carry their own sounds (PlayAnimation - -- plays each row's MoveSoundTable entry with its pitch/tempo - -- modifiers); which side the pic effects target follows the - -- attacker (hWhoseTurn) - self.animName = item.anim - self.animAttackerIsPlayer = item.attackerIsPlayer - self:resetPicFx() - for _, ev in ipairs(self.animPlayer:pollEffects()) do - self:applyAnimEffect(ev) -- frame-0 rows (first sound/effect) - end - self.pendingHit = item.hit - else - -- no subanimation player: keep the single-sound fallback (with - -- the move's pitch/tempo modifiers; GROWL/ROAR play the - -- attacker's cry -- GetMoveSound/IsCryMove) - if item.anim == "GROWL" or item.anim == "ROAR" then - local attacker = item.attackerPosition ~= nil - and self:battlerAt(item.attackerPosition) - or (item.attackerIsPlayer and self.player or self.enemy) - if attacker then - require("src.core.Sound").playMoveCry(self.data, attacker.mon.species, - anim and anim.tempo) - end - elseif anim and anim.sound then - local Sound = require("src.core.Sound") - if Sound.playMove then - Sound.playMove(self.data, anim) - else - Sound.play(self.data, anim.sound) - end - end - if item.hit then - self:applyHitFx(item.hit) - end - end - self.current = nil - return true - end - self:startMessage(item) + if not self:advanceQueue() then return false end + if not self.current then return true end end local input = self.game.input -- a \v CONT wait holds the box until A/B, then scrolls the next line in @@ -3012,6 +3080,13 @@ function BattleState:updateQueue() end else local item = self.current + if item and item.gen2Auto then + if item.gen2Pause and not item.gen2Paused then + item.gen2Paused=true;self.waitFrames=item.gen2Pause;return true + end + self.current=nil;self.msgPrompt=nil;self.messageTimer=0 + return true + end -- TrainerAboutToUseText ends in `done` then DisplayTextBoxID: YES/NO -- overlays the still-visible "Will … change POKéMON?" page. if item and item.choice and not item.choiceOpen then @@ -3022,6 +3097,7 @@ function BattleState:updateQueue() self.game.stack:push(ChoiceBox.new(self.game, function(yes) local fn = item.choice battle.current = nil + if GameVersion.isGen2() then battle.messageTimer=0 end fn(yes) end)) return true @@ -3047,6 +3123,7 @@ function BattleState:updateQueue() elseif input:wasPressed("a") or input:wasPressed("b") then self.msgPrompt = nil self.current = nil + if GameVersion.isGen2() then self.messageTimer=0 end end end end @@ -3876,7 +3953,7 @@ function BattleState:enter() end) self:markParticipant() end - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" self:syncSides() -- THE FIRST SEND-OUT COUNTS TOO. ABILITYEFFECT_ON_SWITCHIN runs the @@ -4240,7 +4317,7 @@ function BattleState:touchpressed(_, px, py) if not (GameVersion.isGen4() or GameVersion.isGen5()) then return false end local over if self.phase == "menu" then over = "action" - elseif self.phase == "moveSelect" then over = "moves" + elseif MoveSelectionPhase.active(self) then over = "moves" elseif self.phase == "targetSelect" then over = "target" else return false end @@ -4326,7 +4403,7 @@ function BattleState:update(dt) -- cartridges have no second screen and SecondScreen.mode answers "off" for -- them regardless, but the gate says so where a reader will look. if input and input:wasPressed("l") and self:gen4Layout() - and (self.phase == "menu" or self.phase == "moveSelect") then + and (self.phase == "menu" or MoveSelectionPhase.active(self)) then local okSS, SecondScreen = pcall(require, "src.ui.SecondScreen") if okSS and SecondScreen.mode(self.game) ~= "off" then SecondScreen.toggleStow(self.game) @@ -4342,7 +4419,7 @@ function BattleState:update(dt) if self.gen4TouchPress then self.gen4TouchPress = nil elseif input and self:gen4Layout() and not self.gen4CursorActive - and (self.phase == "menu" or self.phase == "moveSelect" or self.phase == "targetSelect") then + and (self.phase == "menu" or MoveSelectionPhase.active(self) or self.phase == "targetSelect") then local presentation = Gen4Battle.menuPresentation(self, self.phase) if presentation == "bottom" then for _, k in ipairs({ "a", "b", "x", "y", "up", "down", "left", "right" }) do @@ -4367,7 +4444,7 @@ function BattleState:update(dt) end end - if self.phase == "messages" then + if BattleQueuePhase.active(self) or (GameVersion.isGen2() and self.phase=="submenu") then -- Nothing queued starts until the silhouettes have finished sliding in: -- SlidePlayerAndEnemySilhouettesOnScreen ends with `jpfar -- PrintBeginningBattleText` (engine/battle/core.asm:100), so the enemy @@ -4377,12 +4454,8 @@ function BattleState:update(dt) -- counts introSlide down, and a headless caller driving updateQueue on -- its own has no slide to wait for. if (self.introSlide or 0) > 0 then return end - if not self:updateQueue() then - if self.afterQueue == "menu" then - self.phase = "menu" - elseif self.afterQueue == "finish" then - self:finish() - end + if not self:updateQueue() and not GameVersion.isGen2() then + completeQueue(self) end return end @@ -4411,7 +4484,7 @@ function BattleState:update(dt) self:updatePokedudeMenu() return end - if self.pokedudeDemo and self.phase == "moveSelect" then + if self.pokedudeDemo and MoveSelectionPhase.active(self) then self:updatePokedudeMoveSelect() return end @@ -4455,7 +4528,7 @@ function BattleState:update(dt) if self.phase == "menu" and self.safari then if self.safari.balls <= 0 then self:say(Strings("PA: You're out of\nSAFARI BALLs!\nGame over!")) - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.result = "run" self.afterQueue = "finish" return @@ -4576,7 +4649,7 @@ function BattleState:update(dt) end if choice == "fight" and self.ghost then self:say(Strings("%s is too\nscared to move!", self.player.name)) - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" self:act(function() self:executeAction(self.enemy, self.player, self:enemyAction()) @@ -4597,7 +4670,7 @@ function BattleState:update(dt) self:chooseAction({ id = "STRUGGLE", pp = 1, struggle = true }) return end - self.phase = "moveSelect" + self.phase = MoveSelectionPhase.name() self.moveIndex = math.min(self.moveIndex, #who.curMoves) self.moveSwapIndex = nil elseif choice == "run" then @@ -4611,7 +4684,7 @@ function BattleState:update(dt) return end - if self.phase == "moveSelect" then + if MoveSelectionPhase.active(self) then local chooser = self:menuBattler() local moves = chooser.curMoves -- The wide layouts lay the four slots out as a 2x2 grid, so all four @@ -4644,7 +4717,7 @@ function BattleState:update(dt) local mvDef = mv and self.data.moves[mv.id] if chooser.disabledSlot == self.moveIndex then self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!")) - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" -- TAUNT and TORMENT refuse the move at SELECTION rather than after the -- fact, the way DISABLE already does, so the player is never told to @@ -4652,26 +4725,26 @@ function BattleState:update(dt) elseif self.bw and require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true) then -- BLACK AND WHITE: the Choice lock, Imprison, Gravity, Heal Block self:say(require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true)) - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" elseif self:tauntBlocks(chooser, mvDef) then self:say(Strings("%s can't use\n%s after the\ntaunt!", chooser.name, mvDef and mvDef.name or mv.id)) - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" elseif self:tormentBlocks(chooser, mvDef) then self:say(Strings("%s can't use the\nsame move twice in\na row due to the\nTORMENT!", chooser.name)) - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" elseif self.g4fx and require("src.battle.Gen4MoveEffects").menuRefusal(self, chooser, mvDef) then -- PLATINUM / HGSS: Gravity and Heal Block (BattleSystem_CanUseMove) self:say(require("src.battle.Gen4MoveEffects").menuRefusal(self, chooser, mvDef)) - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" elseif mv.pp <= 0 then self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" elseif Targeting.needsChoice(self, self:choosingBattler(), mvDef) then -- WHICH OF THE TWO, when the move takes one and there are two. @@ -4698,7 +4771,7 @@ function BattleState:update(dt) if self.phase == "targetSelect" then local list = self.targetChoices or {} if #list <= 1 then - self.phase = "moveSelect" + self.phase = MoveSelectionPhase.name() return end if input:wasPressed("left") or input:wasPressed("up") then @@ -4707,7 +4780,7 @@ function BattleState:update(dt) self.targetIndex = self.targetIndex < #list and self.targetIndex + 1 or 1 elseif input:wasPressed("b") then self.pendingMove, self.targetChoices = nil, nil - self.phase = "moveSelect" + self.phase = MoveSelectionPhase.name() elseif input:wasPressed("a") then local mv = self.pendingMove local pick = list[self.targetIndex] @@ -4739,7 +4812,7 @@ function BattleState:update(dt) local pick = moves[self.mimicIndex] local ctx = self.mimicCtx self.mimicMoves, self.mimicCtx = nil, nil - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.nextInsert = 0 -- the copy's anim + text go to the queue head self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot) end @@ -4880,7 +4953,7 @@ function BattleState:gen3TutorialBag() return true end if require("src.core.GameVersion").isGen4() then - self.phase, self.afterQueue = "messages", "menu" + self.phase, self.afterQueue = BattleQueuePhase.name(), "menu" self:ui(function() local bag = require("src.ui.Gen4BagMenu").new(self.game, { pocket = "POKé BALLS" }) bag.rows = { { id = 4, label = "POKé BALL", qty = 20 } } @@ -4902,7 +4975,7 @@ function BattleState:gen3TutorialBag() return false end local game = self.game - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" self:ui(function() local bag @@ -4929,7 +5002,7 @@ function BattleState:openOldManBag() if self:gen3TutorialBag() then return end local ListMenu = require("src.ui.ListMenu") local game = self.game - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" self:ui(function() local list @@ -4963,7 +5036,7 @@ end -- because DisplayBattleMenu swapped wPlayerName (core.asm:2024-2037); -- no ball is consumed (.done returns early, :576-578). function BattleState:oldManThrow() - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "finish" self.result = "run" -- nothing is kept; wBattleResult only ends the demo local DsDemo = require("src.battle.CatchTutorialDemo") @@ -5009,7 +5082,7 @@ function BattleState:queuePokedudeVoices(indices) end if queued then pd.timer = 0 - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" end return queued @@ -5197,7 +5270,7 @@ function BattleState:updatePokedudeMenu() -- control to the player with the temporary party still installed. self.result = self.result or "run" self.afterQueue = "finish" - self.phase = "messages" + self.phase = BattleQueuePhase.name() return end @@ -5215,7 +5288,7 @@ function BattleState:updatePokedudeMenu() self.moveIndex = math.max(1, math.min(step.playerMove or 1, #self.player.curMoves)) pd.selectingMove = true - self.phase = "moveSelect" + self.phase = MoveSelectionPhase.name() elseif step.action == "switch" then pd.awaiting = true self:openPokedudeTarget(step, true) @@ -5372,7 +5445,7 @@ function BattleState:pokedudeUseAntidote(step) local pd = self.pokedudeDemo if not pd then return end pd.enemyMove = step.enemyMove - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" self:say(Strings("POKé DUDE used\nANTIDOTE!")) local result, messages = require("src.inventory.ItemEffects") @@ -5789,7 +5862,7 @@ function BattleState:resolveTurn(playerAction) if entry[1] then self.turnActions[entry[1]] = entry[3] end end - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" -- PLATINUM / HGSS: a TRICK ROOM used this turn resorts everyone still @@ -5866,11 +5939,11 @@ function BattleState:resolveSwitch(newMon) if self.player and self.player.ingrained and self.player.mon.hp > 0 then self:say(Strings("%s anchored\nitself with its\nroots!", displayName(self.player))) - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" return end - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" -- BLACK AND WHITE: the foe chooses first, and a Pursuit catches the -- Pokemon leaving (src/battle/Gen5MoveRules.lua) @@ -8254,7 +8327,11 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima self.moveAnimRow = nil if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then - self:sayNext(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name)) + local text=self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name) + if GameVersion.isGen2() then + Gen2Queue.afterCurrent(self,{kind='move',text=text,move=move.id, + side=user.isPlayer and 'player' or 'enemy',deferAnim=true}) + else self:sayNext(text) end -- the move's animation plays right after the announcement; the -- damage path attaches the target's hit blink to this row so the -- blink follows the animation (pokered's order). Mimic is the @@ -9530,7 +9607,7 @@ end -- out with no free enemy move. function BattleState:openReplacementMenu() local game = self.game - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" local function onSwitch(mon) if Party.isEgg(mon) then @@ -9599,7 +9676,7 @@ end -- engine/items/item_effects.asm. function BattleState:safariAction(choice) if self.gen4Safari then return self:gen4SafariAction(choice) end - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" local st = self.safari local playerName = self.game.save.player.name @@ -9669,7 +9746,7 @@ function BattleState:gen4SafariAction(choice) end local playerName = self.game.save.player.name local foe = self.enemy.name - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" if choice == "run" then @@ -9871,7 +9948,7 @@ end -- Gen 1 escape formula (engine/battle/core.asm TryRunningFromBattle) function BattleState:tryRun() - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" -- A wild battle the script says cannot be escaped. There is exactly one in -- Emerald -- the first -- and the roll is skipped rather than rigged, so a @@ -9950,7 +10027,7 @@ function BattleState:tryRun() end function BattleState:openItems() - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" -- BATTLE_TYPE_FRONTIER (all but the Pyramid): "Items can't be used now." if self.noItems then @@ -10588,12 +10665,12 @@ function BattleState:bugContestBall() -- kept throwing past twenty, so refuse here as well -- one guard for the -- last ball of a battle, one for the run. if BugContest.ballsLeft(save) <= 0 then - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" self:say(Strings("You have no\nPARK BALLs left!")) return end - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" BugContest.useBall(save) self:say(self:romText("_ItemUseText001", "%s used\n%s!", @@ -10680,7 +10757,7 @@ function BattleState:storeContestMon() end function BattleState:openParty() - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" self:ui(function() return self:buildScreen(Gen4Battle.partyScreenId(self) or "PartyMenu", { @@ -10816,7 +10893,7 @@ function BattleState:finish() if type(text) == "string" and text ~= "" then pd.voiceDone[index] = true self:say(text) - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "finish" return end @@ -10832,7 +10909,7 @@ function BattleState:finish() self.game.save.player.name, self.payDay)) self.payDay = nil self.afterQueue = "finish" - self.phase = "messages" + self.phase = BattleQueuePhase.name() return end -- Invariant: a battle can never hand the overworld a party with nothing @@ -12200,7 +12277,7 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) local g = love.graphics local clipY = not skipMenuClip and (self.phase == "mimicSelect" and 56 - or self.phase == "moveSelect" and 64) + or MoveSelectionPhase.active(self) and 64) or nil local clipped, cs1, cs2, cs3, cs4 if clipY and g.getScissor and g.intersectScissor then @@ -12658,7 +12735,7 @@ function BattleState:drawTextArea() -- The move list and Mimic's copy menu keep the solid paper (see -- WORLD_WINDOW_STYLE); everything else in here is the dialogue box and the -- battle menu, which are what goes to glass over a world backdrop. - local dense = self.phase == "moveSelect" or self.phase == "mimicSelect" + local dense = MoveSelectionPhase.active(self) or self.phase == "mimicSelect" Font.pushStyle(not dense and self:windowStyle() or nil) self:drawTextAreaInner() Font.popStyle() @@ -12685,7 +12762,7 @@ end function BattleState:drawTextAreaInner() Font.drawBox(0, 12, 20, 6) love.graphics.setColor(0, 0, 0, 1) - if self.phase == "messages" and (self.current or self.animPlaying) then + if BattleQueuePhase.active(self) and (self.current or self.animPlaying) then -- during the move animation self.current is nil but shown still holds -- the "used X!" lines; keep drawing them like pokered, whose move -- animations only touch sprites and never the textbox tilemap (#296) @@ -12768,7 +12845,7 @@ function BattleState:drawTextAreaInner() Font.draw(Strings("ITEM"), 80, 128); Font.draw(Strings("RUN"), 128, 128) Font.drawCode(0xED, (col == 0 and 72 or 120), 112 + row * 16) end - elseif self.phase == "moveSelect" then + elseif MoveSelectionPhase.active(self) then -- pokered MoveSelectionMenu: move list in a box at (4,12) 16x6, -- names at column 6 from row 13, cursor at column 5. PrintMenuItem: -- the TYPE/PP box at (0,8) 11x5, with "TYPE/" at (1,9), the type at @@ -12973,5 +13050,7 @@ function BattleState:drawClassic() end end +require('src.battle.Gen2MovePresentation').install(BattleState) +Gen2Queue.install(BattleState) Gen2BattleView.install(BattleState) return BattleState diff --git a/src/battle/Gen2BattleView.lua b/src/battle/Gen2BattleView.lua index 06605b8e1..54d8a1679 100644 --- a/src/battle/Gen2BattleView.lua +++ b/src/battle/Gen2BattleView.lua @@ -13,6 +13,13 @@ local function indexOf(party, mon) end function View.bind(screen) + require('src.battle.Gen2HpPresentation').bind(screen) + screen.shownStatus=setmetatable({}, { + __index=function(_,side) + local b=screen[side];return b and (b.shownStatus or false) + end, + __newindex=function()error('native Gen2 displayed status is queue-owned',2)end, + }) -- Bind once; callers read the current native catalog without copying a -- move, caching a definition, or switching the controller's moveset. local readers = { diff --git a/src/battle/Gen2EventQueue.lua b/src/battle/Gen2EventQueue.lua new file mode 100644 index 000000000..ea954eadd --- /dev/null +++ b/src/battle/Gen2EventQueue.lua @@ -0,0 +1,186 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Typed Gen2 presentation events admitted by the existing native queue. +-- This module never resolves a command, changes HP/PP, or advances a clock. +local Version = require('src.core.GameVersion') +local Hp = require('src.battle.Gen2HpPresentation') +local Queue = {} +local textKinds = { + message=true, item=true, weather=true, stage=true, run=true, + money=true, ['win-text']=true, +} +local function finite(value) + return type(value)=='number' and value==value and math.abs(value)=0 and event.hp%1==0, 'invalid native Gen2 event HP') + end + if event.status~=nil then + assert(kind=='status' and (event.status==false or scalar(event.status)), + 'invalid native Gen2 event status') + end + for _,key in ipairs({'sfx','move','moveAnim','anim'})do + assert(event[key]==nil or event[key]==false and key=='anim' or scalar(event[key]), + 'invalid native Gen2 event '..key) + end + assert(event.cry==nil or type(event.cry)=='table' and scalar(event.cry.species), + 'invalid native Gen2 event cry') +end + +local function check(screen) + assert(Version.isGen2() and type(screen.queue)=='table', + 'native Gen2 events require an initialized Gen2 battle') +end +function Queue.push(screen, event) + check(screen);Queue.validate(event) + event.nativeGen2Event=true + if screen._gen2InsertNext then + screen.nextInsert=(screen.nextInsert or 0)+1 + table.insert(screen.queue,screen.nextInsert,event) + else + screen.queue[#screen.queue+1]=event + end +end + +local function validateAll(screen, events) + check(screen);assert(type(events)=='table', 'native Gen2 events must be a list') + for _,event in ipairs(events)do Queue.validate(event)end +end +function Queue.pushAll(screen, events) + validateAll(screen,events) + for _,event in ipairs(events)do screen:push(event)end +end +function Queue.pushFront(screen, events) + validateAll(screen,events) + for index=#events,1,-1 do + local event=events[index];event.nativeGen2Event=true + table.insert(screen.queue,1,event) + end +end + +-- Native producers still use nextInsert. Decorations of push see those +-- events too, and any additional rows share that same insertion cursor. +function Queue.afterCurrent(screen,event) + local before=screen._gen2InsertNext + screen._gen2InsertNext=true + local ok,why=pcall(screen.push,screen,event) + screen._gen2InsertNext=before + if not ok then error(why,0)end +end + +function Queue.isCurrent(screen) + return screen.current and screen.current.gen2SourceEvent~=nil +end +function Queue.typing(screen) + return Queue.isCurrent(screen) and ((screen.charIndex or 0)<(screen.total or 0) + or (screen.lineIndex or 0)<#(screen.lines or {}) or screen.msgWaiting==true) +end + +local function sound(screen,event) + if event.cry and event.text then screen:playCry(event.cry)end + if not (event.sfx and event.text) then return end + screen:playSfx(event.sfx) + if event.waitSfx then + local Sound=require('src.core.Sound') + screen.waitingSound={ + isPlaying=function()return Sound.isPlaying(event.sfx)end, + maxWait=180, + onTimeout=function()Sound.stop(event.sfx)end, + } + screen.waitingSoundT=0 + end +end + +-- Called only after the real queue removes the event. Future HP/status +-- snapshots affect its displayed battler; the actual party stays untouched. +function Queue.activate(screen,event) + local kind=event.kind + if kind=='text-pause' then + screen:clearQueueMessage() + screen.shown,screen.msgPrompt,screen.msgWaiting={},nil,nil + local input=screen.game.input + if input and input.isDown and (input:isDown('a') or input:isDown('b'))then return false end + screen.waitFrames=30 + return nil + end + if kind=='damage' or kind=='heal' then + local battler=side(screen,event.side) + assert(not event.mon or event.mon==battler.mon, 'stale native Gen2 HP event target') + if event.hp~=nil then + Hp.target(screen,battler,event.hp) + screen._gen2HpDrainBattler=battler + screen.draining=true + end + elseif kind=='status' then + local battler=side(screen,event.side) + assert(not event.mon or event.mon==battler.mon, 'stale native Gen2 status event target') + battler.shownStatus=event.status or false + end + local animation=event.moveAnim or (kind=='move' and not event.deferAnim and event.move) + if animation and not event.missed then + screen:animForMove(animation,event.side,event.animParam,event.effectiveness,event.afterAnim) + elseif kind=='damage' and event.anim~=false then + local from=event.animSide or (event.side=='enemy' and 'player' or 'enemy') + if event.animMove then + screen:animForMove(event.animMove,from) + else + local id=event.anim or (event.side=='enemy' and 'ANIM_ENEMY_DAMAGE' or 'ANIM_PLAYER_DAMAGE') + if screen.afterAnimPlayed and (id=='ANIM_ENEMY_DAMAGE' or id=='ANIM_PLAYER_DAMAGE')then + screen.afterAnimPlayed=nil + else + screen:playHitSound(event.effectiveness) + screen:animForId(id,from) + end + end + elseif kind=='heal' and event.anim then + screen:animForMove(event.anim,event.side) + elseif kind~='move' then screen.afterAnimPlayed=nil end + sound(screen,event) + if event.text==nil then return nil end + local text=event.text + if kind=='win-text' then text=require('src.render.TextBox').substitute(screen.game,text)end + return { + text=text,gen2SourceEvent=event, + gen2Auto=kind=='move' or event.textPause==true, + gen2Pause=kind=='move' and (event.missed or event.animDelay) and 40 + or event.textPause and 30 or nil, + } +end + +function Queue.install(Screen) + Screen.push,Screen.pushAll,Screen.pushFront=Queue.push,Queue.pushAll,Queue.pushFront + function Screen:playSfx(name) + local data=self.game.data + if data.audio and data.audio.sfx and data.audio.sfx[name]then + return require('src.core.Sound').play(data,name) + end + end + function Screen:playCry(mon) + local data=self.game.data + if mon and data.audio and data.audio.cries and data.audio.cries[mon.species]then + return require('src.core.Sound').playCry(data,mon.species) + end + end +end +return Queue diff --git a/src/battle/Gen2HpPresentation.lua b/src/battle/Gen2HpPresentation.lua new file mode 100644 index 000000000..fb6a5c766 --- /dev/null +++ b/src/battle/Gen2HpPresentation.lua @@ -0,0 +1,136 @@ +-- Native GSC HP presentation over the real shared-engine battlers. +-- Pixel stepping adapts Gen1Recomp src/battle/gen2/HpBar.lua and +-- src/ui/gen2/BattleState.lua; retain their provenance under NOTICE.md. +-- Native-owner integration: Roxas2712 (LICENSE-ROXAS2712.md). +local Hp = {} +local pending = setmetatable({}, {__mode='k'}) +local admissions = setmetatable({}, {__mode='k'}) +local targets = setmetatable({}, {__mode='k'}) +local function battler(screen, side) + if side=='player' or side=='enemy' then return screen[side] end +end +local function finite(value) + return type(value)=='number' and value==value and math.abs(value)=256 then product=math.floor(product/4);divisor=math.max(1,math.floor(divisor/4))end + return math.min(48,math.max(1,math.floor(product/divisor))) +end +function Hp.shown(screen, mon, side) + local shown=screen.shownHp and screen.shownHp[side] + if shown~=nil then return shown end + return mon and mon.hp or 0 +end +function Hp.fill(screen, mon, side) + local anim=screen.hpAnim + if anim and anim.side==side and anim.bar then return anim.bar.px end + local maximum=mon and (mon.maxHp or mon.stats and mon.stats.hp) or 0 + return Hp.pixels(Hp.shown(screen,mon,side),maximum) +end +local function newBar(from,to,maximum) + -- A wide presentation may have left a fractional value when its owner + -- changed. The cartridge loop advances integers and must still terminate. + from=from>to and math.ceil(from) or math.floor(from) + return {hp=from,to=to,maximum=maximum,px=Hp.pixels(from,maximum), + toPx=Hp.pixels(to,maximum),dir=to=48,low=math.min(from,to),high=math.max(from,to)} +end +local function stepBar(bar) + if bar.long then + while bar.hp~=bar.to do + bar.hp=bar.hp+bar.dir + local px=Hp.pixels(bar.hp,bar.maximum) + if px~=bar.px then bar.px=px;return false end + end + return true + end + if bar.px==bar.toPx then return true end + bar.px=bar.px+bar.dir + local hp + if bar.px>=48 then hp=bar.maximum + elseif bar.px<=0 then hp=0 + else hp=math.floor(bar.maximum*bar.px/48)+1;hp=math.max(bar.low,math.min(bar.high,hp))end + bar.hp=hp + return false +end +function Hp.bind(screen) + screen.shownHp=setmetatable({}, { + __index=function(_,side)local b=battler(screen,side);return b and b.shownHP end, + __newindex=function(_,side,value) + local active=admissions[screen];local b=battler(screen,side) + assert(active and active.side==side and b==active.battler and b.mon==active.mon, + 'native Gen2 shown HP is owned by stepHPDrain/stepHpAnim') + local maximum=math.max(1,b.mon.stats.hp) + assert(finite(value) and value>=0 and value<=maximum,'invalid native Gen2 displayed HP') + b.shownHP=value + end, + }) +end +-- This is the actual native display step, called through the public screen +-- method while its queue owns the HP row. It never changes battle damage. +function Hp.step(screen) + local anim=screen.hpAnim;local active=admissions[screen] + if not anim then return false end + assert(active and active.anim==anim,'native Gen2 HP step has no admitted row') + if not anim.bar then + anim.bar=newBar(screen.shownHp[anim.side],anim.to,active.mon.stats.hp) + end + if (anim.hold or 0)>0 then anim.hold=anim.hold-1;return true end + if stepBar(anim.bar)then + screen.shownHp[anim.side]=anim.to;screen.hpAnim=nil;return false + end + screen.shownHp[anim.side]=anim.bar.hp;anim.hold=1;return true +end +function Hp.target(screen,b,goal) + assert(finite(goal) and goal>=0 and goal<=b.mon.stats.hp, + 'invalid native Gen2 displayed HP goal') + targets[screen]={battler=b,mon=b.mon,hp=goal} +end +local function goalOf(screen,b) + local target=targets[screen] + if target and target.battler==b and target.mon==b.mon then return target.hp end + local goal=b.mon.hp + if b.drainFloor and b.drainFloor>goal and b.shownHP>=b.drainFloor then goal=b.drainFloor end + return goal +end +function Hp.drain(screen) + local selected=screen._gen2HpDrainBattler + local side + for _,candidate in ipairs({'player','enemy'})do + local b=battler(screen,candidate) + if b and b.shownHP~=nil and (not selected or b==selected) + and (b.shownHP~=goalOf(screen,b) or pending[screen] and pending[screen].battler==b)then + side=candidate;break + end + end + if not side then screen.hpAnim=nil;pending[screen]=nil;return false end + local b=battler(screen,side);local goal=goalOf(screen,b);local active=pending[screen] + if not active or active.battler~=b or active.mon~=b.mon or active.goal~=goal + or screen.hpAnim~=active.anim then + local anim={side=side,to=goal} + active={side=side,battler=b,mon=b.mon,goal=goal,anim=anim} + pending[screen]=active;screen.hpAnim=anim + end + admissions[screen]=active + local ok,why=pcall(screen.stepHpAnim,screen) + admissions[screen]=nil + if not ok then error(why,0)end + local anim=screen.hpAnim + if anim==active.anim and anim.bar and anim.bar.hp~=b.shownHP then + -- A wider HUD decorated this beat after (or instead of) native stepping. + -- Its display number is authoritative for presentation. Discard the old + -- pixel cursor so returning to the cartridge HUD cannot jump backward. + anim.bar,anim.hold=nil,nil + end + -- A decorator cannot release a queued damage beat before its display has + -- reached that beat's authoritative stop. No second timer or damage loop. + if b.shownHP==goal and screen.hpAnim==nil then pending[screen]=nil;return false end + return true +end +function Hp.retire(screen) + pending[screen],admissions[screen],targets[screen]=nil,nil,nil + screen.hpAnim=nil;screen._gen2HpDrainBattler=nil +end +return Hp diff --git a/src/battle/Gen2MovePresentation.lua b/src/battle/Gen2MovePresentation.lua new file mode 100644 index 000000000..8239fb410 --- /dev/null +++ b/src/battle/Gen2MovePresentation.lua @@ -0,0 +1,66 @@ +-- Native Gen2 presentation entry points over the existing battle queue/VM. +-- Public side/after-animation names follow Gen1Recomp's Gen2 BattleState; +-- battle decisions, damage, PP, queue consumption and time stay native. +local Version=require('src.core.GameVersion') +local M={} + +function M.start(screen,id,isPlayer,opts) + local player=screen.animPlayer + if not (Version.isGen2() and player and player.anims.gen2 + and player:scriptFor(id)) then return false end + if player:start(id,isPlayer,opts)==false then return false end + screen.animPlaying=true + screen.animName,screen.animAttackerIsPlayer=id,isPlayer + screen:resetPicFx() + -- This upstream VM carries its actual parameter in vars. No separate + -- public runner/OAM observation is fabricated around the older player. + if opts and opts.param~=nil then player.vars.param=opts.param end + for _,event in ipairs(player:pollEffects())do screen:applyAnimEffect(event)end + return true +end + +function M.install(Screen) + function Screen:turnFor(side) return side=='enemy' and 1 or 0 end + function Screen:afterAnimFor(side,kind) + if kind=='statdown'then + return side=='player'and 'ANIM_ENEMY_STAT_DOWN'or 'ANIM_WOBBLE' + end + if kind=='damage'then + return side=='player'and 'ANIM_ENEMY_DAMAGE'or 'ANIM_PLAYER_DAMAGE' + end + end + function Screen:playHitSound(effectiveness) + if not effectiveness or effectiveness==0 then return end + local name=effectiveness>10 and 'Super_Effective' + or effectiveness<10 and 'Not_Very_Effective'or 'Damage' + require('src.core.Sound').play(self.data,name) + end + function Screen:animForMove(moveId,side,param,effectiveness,afterAnim) + local player=self.animPlayer + if not (Version.isGen2() and self:animationsOn() and player + and player.anims.moveAnims and player.anims.moveAnims[moveId]) then return false end + local started=M.start(self,moveId,side~='enemy',{param=param or 0}) + if started then + local name=self:afterAnimFor(side,afterAnim) + self.pendingAfterAnim=name and {name=name,side=side, + effectiveness=afterAnim=='damage'and effectiveness or nil}or nil + self.afterAnimPlayed=nil + end + return started + end + function Screen:animForId(id,side,param) + return M.start(self,id,side~='enemy',{param=param or 0}) + end + function Screen:startPendingAfterAnim() + local pending=self.pendingAfterAnim + if not pending then return false end + self.pendingAfterAnim=nil + if self:animForId(pending.name,pending.side)then + self:playHitSound(pending.effectiveness) + self.afterAnimPlayed=true + return true + end + return false + end +end +return M diff --git a/src/battle/MoveSelectionPhase.lua b/src/battle/MoveSelectionPhase.lua new file mode 100644 index 000000000..2c9a3ef22 --- /dev/null +++ b/src/battle/MoveSelectionPhase.lua @@ -0,0 +1,16 @@ +-- One native move-selection state, with the public Gen2 screen spelling. +-- Accept earlier shared-controller checkpoints without a second controller +-- or a temporary phase rewrite around presentation callbacks. +local Version = require("src.core.GameVersion") +local M = {} + +function M.name() + return Version.isGen2() and "moves" or "moveSelect" +end + +function M.active(screen) + return screen.phase == "moveSelect" + or (screen.phase == "moves" and Version.isGen2()) +end + +return M diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index 21881d63f..18ad2d79f 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -1,3 +1,4 @@ +local BattleQueuePhase=require('src.battle.BattleQueuePhase') -- Link battles over the peer-to-peer link (src/link/Net.lua), -- lockstep-simulated like the real link cable: BOTH sides run the -- full battle engine (BattleState) locally @@ -271,7 +272,7 @@ function LinkBattle.new(game, net, opts) if s.linkEnded then return end s.result = "draw" s.afterQueue = "finish" - s.phase = "messages" + s.phase = BattleQueuePhase.name() if text then s:say(text) end end @@ -282,7 +283,7 @@ function LinkBattle.new(game, net, opts) if s.linkEnded then return end s.result = result s.afterQueue = "finish" - s.phase = "messages" + s.phase = BattleQueuePhase.name() if text then s:say(text) end end @@ -395,7 +396,7 @@ function LinkBattle.new(game, net, opts) endAsDraw(s, Strings("%s ran from\nthe battle!", who)) return end - s.phase = "messages" + s.phase = BattleQueuePhase.name() s.afterQueue = "linkNext" s.turnCount = (s.turnCount or 0) + 1 @@ -514,7 +515,7 @@ function LinkBattle.new(game, net, opts) -- the party menu must offer the clamped link copies self.openParty = function(s) - s.phase = "messages" + s.phase = BattleQueuePhase.name() s.afterQueue = "menu" s:ui(function() return s:buildScreen("PartyMenu", { @@ -535,7 +536,7 @@ function LinkBattle.new(game, net, opts) self.openItems = function(s) s:say(Strings("Items can't be\nused in a link\nbattle!")) - s.phase = "messages" + s.phase = BattleQueuePhase.name() s.afterQueue = "menu" end @@ -617,7 +618,7 @@ function LinkBattle.new(game, net, opts) s:tickFx() return -- the other side is still choosing end - if s.phase == "messages" and s.afterQueue == "linkNext" then + if BattleQueuePhase.active(s) and s.afterQueue == "linkNext" then s:tickFx() if not s:updateQueue() then s.afterQueue = "menu" @@ -734,7 +735,7 @@ function LinkBattle.newSpectator(game, net, opts) if s.linkEnded then return end s.result = s.result or "ended" s.afterQueue = "finish" - s.phase = "messages" + s.phase = BattleQueuePhase.name() if text then s:say(text) end end @@ -780,7 +781,7 @@ function LinkBattle.newSpectator(game, net, opts) endSpectate(s, "The match ended.") return end - s.phase = "messages" + s.phase = BattleQueuePhase.name() s.afterQueue = "linkNext" s.turnCount = (s.turnCount or 0) + 1 @@ -901,7 +902,7 @@ function LinkBattle.newSpectator(game, net, opts) -- same as the real-participant loop: every path that skips baseUpdate -- still has to advance the presentational clock, and a spectator sits in -- waitBoth between every single turn - if s.phase == "messages" and s.afterQueue == "linkNext" then + if BattleQueuePhase.active(s) and s.afterQueue == "linkNext" then s:tickFx() if not s:updateQueue() then s.afterQueue = "waitBoth" From 048ece3d1fa33b70ab4f79fc811026a9221e1fa4 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:29:48 +0100 Subject: [PATCH 08/14] Track ROM-free upstream typed event SDK regression fixture --- tests/engine/gen2_typed_event_queue.lua | 281 ++++++++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 tests/engine/gen2_typed_event_queue.lua diff --git a/tests/engine/gen2_typed_event_queue.lua b/tests/engine/gen2_typed_event_queue.lua new file mode 100644 index 000000000..8c8d3bac3 --- /dev/null +++ b/tests/engine/gen2_typed_event_queue.lua @@ -0,0 +1,281 @@ +-- Exercise typed events through the real SDK and actual native queue/HP/VM. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Screen=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local Sound=require('src.core.Sound') +local previous=Version.get() +local function fixture(version) + Version.set(version) + local data=T.fixtures.fresh();data.constants.gen=Version.generation() + data.battle_anims={gen2=true,moveAnims={FIX_TACKLE=1},scripts={ + [1]={entry=1,code={{op='delay',frames=4},{op='ret'}}}, + }} + local run=T.sdk.loadMods({'mods/event_queue_probe'},{data=data,fs=T.sdk.memfs({ + ['mods/event_queue_probe/manifest.json']=[[{ + "id":"event_queue_probe","name":"Queue probe","version":"1.0.0", + "api":2,"entry":"main.lua","affects_link":false + }]], + ['mods/event_queue_probe/main.lua']=[[ + local mod=... + local Screen=require('src.ui.gen2.BattleState') + local push=Screen.push + mod.exports.rows={} + Screen.push=function(screen,event) + mod.exports.rows[#mod.exports.rows+1]=event + return push(screen,event) + end + mod.exports.admissions={} + local advance=Screen.advanceQueue + if advance then + Screen.advanceQueue=function(screen,...) + local entry={row=screen.queue[1],before=screen.phase} + mod.exports.admissions[#mod.exports.admissions+1]=entry + local result=advance(screen,...) + entry.after=screen.phase + return result + end + end + mod.exports.sounds={} + mod.events:on('sound.played',function(event) + mod.exports.sounds[#mod.exports.sounds+1]=event + end) + ]], + })}) + T.eq(#run.errors,0,'public queue observer loads') + require('src.render.Font').load(data) + local save=Save.newGame() + save.options.textSpeed=1 + save.options.battleAnimations=true + save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',18)} + local input={pressed={},held={}} + function input:wasPressed(key)return self.pressed[key]==true end + function input:isDown(key)return self.held[key]==true end + local stack={states={}} + function stack:top()return self.states[#self.states]end + function stack:push(state)self.states[#self.states+1]=state end + function stack:pop()return table.remove(self.states)end + local game={data=data,save=save,input=input,stack=stack} + local b=Screen.newWild(game,'FIXMON_C',23) + stack.states={b} + b.phase,b.afterQueue,b.queue,b.current,b.introSlide='resolving','menu',{},nil,0 + b.sendingOut,b.showPlayerBack=false,false + local probe=assert(run.loader.exports.event_queue_probe);probe.rows={} + local function step(key) + input.pressed=key and {[key]=true}or{} + b:update(1/60) + input.pressed={} + end + local function untilTrue(predicate,limit) + for _=1,limit or 1200 do if predicate()then return true end;step()end + return predicate() + end + local function clear() + b.queue,b.current,b.waitFrames,b.waitingSound={},nil,nil,nil + b.draining=nil;require('src.battle.Gen2HpPresentation').retire(b) + b.phase,b.afterQueue,b.nextInsert='resolving','menu',nil + b:clearQueueMessage() + b.animPlaying,b.pendingAfterAnim,b.anim=false,nil,nil + probe.admissions={} + end + return b,game,run,probe,step,untilTrue,clear +end + +for _,edition in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do + local b,game,run,probe,step,wait,clear=fixture(edition) + local ownHP,pp=b.player.mon.hp,b.player.curMoves[1].pp + local queue=b.queue + b:say('Native tail') + T.eq(probe.rows[1] and probe.rows[1].kind,'message','ordinary native producers use public push') + b:pushFront({{kind='message',text='First'},{kind='message',text='Second'}}) + b:pushAll({{kind='message',text='Last'}}) + T.eq(b.queue,queue,'native queue identity is preserved') + T.eq(#b.queue,4,'one native queue owns all rows') + T.eq(b.queue[1].text,'First','front batch retains order') + T.eq(b.queue[2].text,'Second','front batch retains second row') + T.eq(b.queue[3].text,'Native tail','existing native row remains behind prefix') + T.eq(b.queue[4].text,'Last','append batch remains after existing rows') + local before=#b.queue + T.raises(function()b:pushAll({{kind='message',text='No half batch'},{kind='send'}})end, + 'unsupported native Gen2 event','unsupported semantic event is explicit') + T.eq(#b.queue,before,'invalid batch cannot append half its rows') + T.raises(function()b:push({kind='damage',side='sideways',hp=2})end,'side','invalid side rejected') + T.raises(function()b:push({kind='heal',side='enemy',hp=0/0})end,'HP','NaN target rejected') + T.check(wait(function()return b.msgPrompt and(b.msgPromptWait or 0)<=0 end),'native first page types and prompts') + local current=b.current + T.eq(#probe.admissions,1,'observer runs only at actual queue admission') + T.eq(probe.admissions[1] and probe.admissions[1].row,current.gen2SourceEvent, + 'admission observer sees the actual source row') + for _=1,70 do step()end + T.eq(#probe.admissions,1,'typing and prompt cannot expose a future queue row') + T.eq(b.current,current,'normal message never times out') + T.eq(b.message,'First','public message follows native row') + step('a') + T.check(wait(function()return b.message=='Second' end),'actual native A releases only first row') + T.eq(#probe.admissions,2,'next row is observed after the real prompt releases') + clear() + b.afterQueue='moveSelect' + step() + T.eq(b.phase,'moves','empty Gen2 queue reaches its native move decision') + T.eq(probe.admissions[1] and probe.admissions[1].after,'moves', + 'queue observer sees the settled move decision on return') + clear() + local finish=b.finish;local finishes=0 + b.finish=function(self)finishes=finishes+1;self.phase='finished'end + b.afterQueue='finish';step() + T.eq(finishes,1,'native queue completion finishes exactly once') + T.eq(probe.admissions[1] and probe.admissions[1].after,'finished', + 'queue observer sees native finish on return') + b.finish=finish;clear() + + b:act(function()b:sayNext('Inserted one');b:sayNext('Inserted two')end) + b:say('Existing tail') + step() + T.eq(b.queue[1].text,'Inserted one','native insertion cursor owns first decorated producer') + T.eq(b.queue[2].text,'Inserted two','native insertion cursor owns second decorated producer') + T.eq(b.queue[3].text,'Existing tail','native tail is not overwritten') + clear() + + local move={kind='move',text='A move!',move='FIX_TACKLE',side='player'} + b:push(move) + step() + T.check(b.current and b.current.gen2SourceEvent==move,'typed row retains source event provenance') + T.check(b.animPlaying,'actual native animation VM starts at admission') + local frames=b.animPlayer.ranFrames + T.eq(#probe.admissions,1,'move has one native admission') + step() + T.eq(#probe.admissions,1,'active move typer cannot advance queue observer') + T.eq(b.animPlayer.ranFrames,frames,'native typer owns time before VM stepping') + T.check(wait(function()return b.phase=='menu' end),'move line and VM complete without injected A/B') + T.check(b.animPlayer.ranFrames>frames,'real native VM clock eventually advances') + T.eq(b.messageTimer,0,'auto line has no independent message hold') + T.eq(probe.admissions[#probe.admissions] and probe.admissions[#probe.admissions].after,'menu', + 'empty native queue returns the camera to the settled menu phase') + clear() + + b:push({kind='message',text='Timed',textPause=true}) + T.check(wait(function()return b.current and b.current.gen2Paused end),'typed timed line reaches native wait') + T.eq(b.waitFrames,30,'source text-pause uses native 30-frame wait') + T.check(wait(function()return b.phase=='menu' end),'timed line retires without command input') + clear() + b:pushAll({{kind='text-pause'},{kind='message',text='After pause'}}) + step();T.eq(b.waitFrames,30,'standalone pause uses existing native hold') + for _=1,30 do step()end + T.eq(b.message,nil,'no future row becomes visible during pause') + step();T.eq(b.message,'After pause','future row starts after native wait') + clear();game.input.held.a=true + b:pushAll({{kind='text-pause'},{kind='message',text='Held skip'}}) + step() + T.eq(b.waitFrames,nil,'held button skips text pause') + T.eq(b.message,'Held skip','held skip admits next row through same queue') + T.eq(#probe.admissions,2,'held pause skip exposes both actual admissions') + T.eq(probe.admissions[2] and probe.admissions[2].row.text,'Held skip', + 'recursive native admission observes the row after a skipped pause') + game.input.held.a=nil;clear() + + local enemy=b.enemy + enemy.mon.stats.hp,enemy.mon.hp,enemy.shownHP=60,15,20 + b:pushAll({{kind='heal',side='enemy',mon=enemy.mon,hp=45}, + {kind='damage',side='enemy',mon=enemy.mon,hp=15,anim=false}}) + local reached=false + for _=1,1200 do + step() + if enemy.shownHP==45 then reached=true end + T.eq(enemy.mon.hp,15,'presentation snapshot cannot overwrite resolved combat HP') + if b.phase=='menu'then break end + end + T.check(reached,'queued heal reaches its own snapshot despite later damage') + T.eq(enemy.shownHP,15,'subsequent damage reaches next snapshot') + T.eq(b.hpAnim,nil,'same native HP owner retires') + clear() + enemy.mon.status='PSN' + b:push({kind='status',side='enemy',mon=enemy.mon,status='BRN'}) + step() + T.eq(enemy.shownStatus,'BRN','native battler receives queued status display') + T.eq(b.shownStatus and b.shownStatus.enemy,'BRN','source HUD reads same native status') + T.eq(enemy.mon.status,'PSN','status display never writes saved combat status') + b:push({kind='status',side='enemy',status=false}) + step() + T.eq(b.shownStatus and b.shownStatus.enemy,false,'explicit false clears displayed status') + T.eq(enemy.mon.status,'PSN','clearing display does not cure a Pokemon') + T.eq(b.player.mon.hp,ownHP,'typed events leave own HP untouched') + T.eq(b.player.curMoves[1].pp,pp,'typed presentation cannot spend PP') + clear();probe.rows={} + enemy.mon.hp,enemy.mon.stats.hp,enemy.mon.status=1000,1000,nil + local slot=b.player.curMoves[1];local beforePP=slot.pp + b.rng=function(lo)return lo or 0 end + b:performMove(b.player,enemy,slot) + local announced + for _,event in ipairs(probe.rows)do if event.kind=='move'then announced=event;break end end + T.check(announced~=nil,'actual native move production reaches decorated push') + T.eq(announced and announced.move,slot.id,'actual announcement retains native move identity') + T.eq(announced and announced.deferAnim,true,'native move retains its already queued animation owner') + local animations=0 + for _,row in ipairs(b.queue)do if row.anim==slot.id then animations=animations+1 end end + T.eq(animations,1,'typed observation cannot queue another native move animation') + T.eq(slot.pp,beforePP-1,'actual move still spends native PP once') + T.check(enemy.mon.hp<1000,'actual move still applies native damage') + require('src.mods.Loader').teardown();run.release() +end + +-- Device boundary only: all sound lookup, play/stop, event attribution and +-- queue waiting use the actual native Sound service. +do + local beforeAudio=love.audio + local sources={} + local Source={};Source.__index=Source + function Source:play()self.playing=true end + function Source:stop()self.playing=false end + function Source:isPlaying()return self.playing end + function Source:setVolume()end + function Source:setPitch()end + function Source:setLooping()end + function Source:getDuration()return 0.1 end + love.audio={newSource=function(file) + local src=setmetatable({file=file},Source);sources[#sources+1]=src;return src + end} + local b,game,run,probe,step,wait,clear=fixture('crystal') + local name='FIX_TYPED_QUEUE_CUE' + game.data.audio={sfx={[name]={file='assets/typed-queue.wav'}}, + cries={FIXMON_A={file='assets/typed-queue-cry.wav'}}} + b:push({kind='message',text='Cue',sfx=name,waitSfx=true}) + T.eq(#probe.sounds,0,'enqueuing does not play a future sound') + step() + T.eq(#probe.sounds,1,'actual native sound event fires at queue admission') + T.eq(probe.sounds[1] and probe.sounds[1].name,name,'actual native sound uses catalog key') + T.check(Sound.isPlaying(name),'native source really starts') + for _=1,20 do step('a')end + T.check(b.current~=nil,'input cannot dismiss a line over its active sound') + Sound.stop(name) + T.check(wait(function()return b.msgPrompt and(b.msgPromptWait or 0)<=0 end), + 'native sound completion releases message prompt') + step('a');clear() + b:push({kind='message',text='Timeout',sfx=name,waitSfx=true}) + step() + T.check(Sound.isPlaying(name),'second actual cue restarts') + T.check(wait(function()return b.waitingSound==nil end,250),'native wait is bounded for a stuck source') + T.eq(Sound.isPlaying(name),false,'timed-out source is stopped by the native sound owner') + clear() + local n=#probe.sounds + b:push({kind='message',text='Cry',cry=b.player.mon}) + step() + T.eq(#probe.sounds,n+1,'queued cry goes through actual native sound bus') + T.eq(probe.sounds[#probe.sounds] and probe.sounds[#probe.sounds].kind,'cry','native cry event retains kind') + require('src.mods.Loader').teardown();run.release() + love.audio=beforeAudio +end + +for _,edition in ipairs({'red','firered','emerald'})do + Version.set(edition) + local data=T.fixtures.fresh();require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',20)} + local b=Screen.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',20) + b.queue={};b:say('Legacy row') + T.eq(b.queue[1].text,'Legacy row','other generations retain their native text row') + T.eq(b.queue[1].kind,nil,'other generations acquire no typed Gen2 event') + T.eq(b.queue[1].nativeGen2Event,nil,'other generations acquire no Gen2 admission') +end +Version.set(previous) +T.finish('native Gen2 typed event queue and snapshot presentation') From d43c753a31e2667245c850126a608549c9dc5c6a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:30:42 +0100 Subject: [PATCH 09/14] Exercise old upstream consumers in their actual native queue phase --- tests/engine/gen2_typed_event_queue.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/engine/gen2_typed_event_queue.lua b/tests/engine/gen2_typed_event_queue.lua index 8c8d3bac3..1d79c6847 100644 --- a/tests/engine/gen2_typed_event_queue.lua +++ b/tests/engine/gen2_typed_event_queue.lua @@ -60,7 +60,7 @@ local function fixture(version) local game={data=data,save=save,input=input,stack=stack} local b=Screen.newWild(game,'FIXMON_C',23) stack.states={b} - b.phase,b.afterQueue,b.queue,b.current,b.introSlide='resolving','menu',{},nil,0 + b.phase,b.afterQueue,b.queue,b.current,b.introSlide=Screen.advanceQueue and 'resolving' or 'messages','menu',{},nil,0 b.sendingOut,b.showPlayerBack=false,false local probe=assert(run.loader.exports.event_queue_probe);probe.rows={} local function step(key) @@ -75,7 +75,7 @@ local function fixture(version) local function clear() b.queue,b.current,b.waitFrames,b.waitingSound={},nil,nil,nil b.draining=nil;require('src.battle.Gen2HpPresentation').retire(b) - b.phase,b.afterQueue,b.nextInsert='resolving','menu',nil + b.phase,b.afterQueue,b.nextInsert=Screen.advanceQueue and 'resolving' or 'messages','menu',nil b:clearQueueMessage() b.animPlaying,b.pendingAfterAnim,b.anim=false,nil,nil probe.admissions={} From afadd377fcdca5bd487125306b44b74eee603ae4 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:32:55 +0100 Subject: [PATCH 10/14] Preserve native queue admission through void-return decorators --- src/battle/BattleState.lua | 7 +++++-- tests/engine/gen2_typed_event_queue.lua | 11 ++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 764421688..9d403915c 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -3025,8 +3025,11 @@ function BattleState:updateQueue() return true end if not self.current then - if not self:advanceQueue() then return false end - if not self.current then return true end + -- Gen1 screen decorators may intentionally return no value. Admission + -- is observed through native queue/current state, never a new return ABI. + local hadEvent=self.queue[1]~=nil + self:advanceQueue() + if not self.current then return hadEvent end end local input = self.game.input -- a \v CONT wait holds the box until A/B, then scrolls the next line in diff --git a/tests/engine/gen2_typed_event_queue.lua b/tests/engine/gen2_typed_event_queue.lua index 1d79c6847..1adae8ecf 100644 --- a/tests/engine/gen2_typed_event_queue.lua +++ b/tests/engine/gen2_typed_event_queue.lua @@ -35,7 +35,7 @@ local function fixture(version) mod.exports.admissions[#mod.exports.admissions+1]=entry local result=advance(screen,...) entry.after=screen.phase - return result + if not mod.exports.dropReturn then return result end end end mod.exports.sounds={} @@ -129,6 +129,15 @@ for _,edition in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do T.eq(probe.admissions[1] and probe.admissions[1].after,'finished', 'queue observer sees native finish on return') b.finish=finish;clear() + probe.dropReturn=true + b:say('Void observer') + T.check(wait(function()return b.msgPrompt and (b.msgPromptWait or 0)<=0 end), + 'void-return admission decorator still allows the native message to type') + step('a') + T.check(wait(function()return b.phase=='menu' end), + 'native queue completion does not require an observer return value') + T.eq(#b.queue,0,'void-return observer leaves the real queue drained') + probe.dropReturn=nil;clear() b:act(function()b:sayNext('Inserted one');b:sayNext('Inserted two')end) b:say('Existing tail') From f46b3c00e7afbbefb8f7bc4099c651a50e5eebd2 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:33:18 +0100 Subject: [PATCH 11/14] Report missing baseline VM progress without aborting the counterexample --- tests/engine/gen2_typed_event_queue.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/engine/gen2_typed_event_queue.lua b/tests/engine/gen2_typed_event_queue.lua index 1adae8ecf..4befef842 100644 --- a/tests/engine/gen2_typed_event_queue.lua +++ b/tests/engine/gen2_typed_event_queue.lua @@ -152,13 +152,13 @@ for _,edition in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do step() T.check(b.current and b.current.gen2SourceEvent==move,'typed row retains source event provenance') T.check(b.animPlaying,'actual native animation VM starts at admission') - local frames=b.animPlayer.ranFrames + local frames=b.animPlayer.ranFrames or 0 T.eq(#probe.admissions,1,'move has one native admission') step() T.eq(#probe.admissions,1,'active move typer cannot advance queue observer') T.eq(b.animPlayer.ranFrames,frames,'native typer owns time before VM stepping') T.check(wait(function()return b.phase=='menu' end),'move line and VM complete without injected A/B') - T.check(b.animPlayer.ranFrames>frames,'real native VM clock eventually advances') + T.check((b.animPlayer.ranFrames or 0)>frames,'real native VM clock eventually advances') T.eq(b.messageTimer,0,'auto line has no independent message hold') T.eq(probe.admissions[#probe.admissions] and probe.admissions[#probe.admissions].after,'menu', 'empty native queue returns the camera to the settled menu phase') From cf366d924db89c283e4f7645fefcbb46b3400194 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:34:20 +0100 Subject: [PATCH 12/14] Render canonical Gen2 queue and move phases in the native wide layout --- src/battle/WideBattle.lua | 6 ++++-- tests/engine/gen2_typed_event_queue.lua | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index b8a4a4ca5..4cf25adf4 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -11,6 +11,8 @@ -- with their status lower right, a full-width message window, a split -- prompt/command window, and a 2x2 move menu with an attached PP/type panel. +local BattleQueuePhase = require("src.battle.BattleQueuePhase") +local MoveSelectionPhase = require("src.battle.MoveSelectionPhase") local Font = require("src.render.Font") local HudTiles = require("src.render.HudTiles") local PaletteFX = require("src.render.PaletteFX") @@ -359,11 +361,11 @@ local function drawMoveMenu(battle) end local function drawTextArea(battle) - if battle.phase == "messages" and (battle.current or battle.animPlaying) then + if BattleQueuePhase.active(battle) and (battle.current or battle.animPlaying) then drawMessageBox(battle) elseif battle.phase == "menu" then drawCommandMenu(battle) - elseif battle.phase == "moveSelect" then + elseif MoveSelectionPhase.active(battle) then drawMoveMenu(battle) elseif battle.phase == "mimicSelect" then drawMoveGrid(battle, battle.mimicMoves, battle.mimicIndex) diff --git a/tests/engine/gen2_typed_event_queue.lua b/tests/engine/gen2_typed_event_queue.lua index 4befef842..c814fd48c 100644 --- a/tests/engine/gen2_typed_event_queue.lua +++ b/tests/engine/gen2_typed_event_queue.lua @@ -105,6 +105,17 @@ for _,edition in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do T.raises(function()b:push({kind='heal',side='enemy',hp=0/0})end,'HP','NaN target rejected') T.check(wait(function()return b.msgPrompt and(b.msgPromptWait or 0)<=0 end),'native first page types and prompts') local current=b.current + local Font=require('src.render.Font') + local drawCodes=Font.drawCodes;local rowsDrawn=0 + Font.drawCodes=function(codes,...) + for _,row in ipairs(b.shown)do if codes==row then rowsDrawn=rowsDrawn+1;break end end + return drawCodes(codes,...) + end + local chars=b.charIndex + require('src.battle.WideBattle').draw(b) + Font.drawCodes=drawCodes + T.check(rowsDrawn>0,'wide renderer draws the actual current Gen2 queue text') + T.eq(b.charIndex,chars,'wide rendering cannot advance the native typer') T.eq(#probe.admissions,1,'observer runs only at actual queue admission') T.eq(probe.admissions[1] and probe.admissions[1].row,current.gen2SourceEvent, 'admission observer sees the actual source row') @@ -119,6 +130,15 @@ for _,edition in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do b.afterQueue='moveSelect' step() T.eq(b.phase,'moves','empty Gen2 queue reaches its native move decision') + local draw=Font.draw;local named=false + local name=b.data.moves[b.player.curMoves[1].id].name + Font.draw=function(text,...) + if text==name then named=true end + return draw(text,...) + end + require('src.battle.WideBattle').draw(b) + Font.draw=draw + T.check(named,'wide renderer draws the native move decision in its actual Gen2 phase') T.eq(probe.admissions[1] and probe.admissions[1].after,'moves', 'queue observer sees the settled move decision on return') clear() From 8f35b95e356194d0e2356fe552a1378421fe8387 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:34:39 +0100 Subject: [PATCH 13/14] Observe the actual per-glyph upstream wide text renderer --- tests/engine/gen2_typed_event_queue.lua | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/engine/gen2_typed_event_queue.lua b/tests/engine/gen2_typed_event_queue.lua index c814fd48c..9842b15ea 100644 --- a/tests/engine/gen2_typed_event_queue.lua +++ b/tests/engine/gen2_typed_event_queue.lua @@ -106,14 +106,14 @@ for _,edition in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do T.check(wait(function()return b.msgPrompt and(b.msgPromptWait or 0)<=0 end),'native first page types and prompts') local current=b.current local Font=require('src.render.Font') - local drawCodes=Font.drawCodes;local rowsDrawn=0 - Font.drawCodes=function(codes,...) - for _,row in ipairs(b.shown)do if codes==row then rowsDrawn=rowsDrawn+1;break end end - return drawCodes(codes,...) + local drawCode=Font.drawCode;local rowsDrawn=0 + Font.drawCode=function(code,x,y,...) + if x==8 and y==112 and b.shown[1] and code==b.shown[1][1] then rowsDrawn=rowsDrawn+1 end + return drawCode(code,x,y,...) end local chars=b.charIndex require('src.battle.WideBattle').draw(b) - Font.drawCodes=drawCodes + Font.drawCode=drawCode T.check(rowsDrawn>0,'wide renderer draws the actual current Gen2 queue text') T.eq(b.charIndex,chars,'wide rendering cannot advance the native typer') T.eq(#probe.admissions,1,'observer runs only at actual queue admission') From 6f68264b70a5265f1c17b1008b90fd728bce4bd0 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:48:58 +0100 Subject: [PATCH 14/14] Scope void-return admission compatibility to native Gen2 --- src/battle/BattleState.lua | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 9d403915c..5b43c7134 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -3025,11 +3025,14 @@ function BattleState:updateQueue() return true end if not self.current then - -- Gen1 screen decorators may intentionally return no value. Admission - -- is observed through native queue/current state, never a new return ABI. + -- Gen2 screen decorators may intentionally return no value. Other + -- generations keep their existing native queue completion result. local hadEvent=self.queue[1]~=nil - self:advanceQueue() - if not self.current then return hadEvent end + local busy=self:advanceQueue() + if not self.current then + if GameVersion.isGen2() then return hadEvent end + return busy + end end local input = self.game.input -- a \v CONT wait holds the box until A/B, then scrolls the next line in