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/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/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 8f8e1409c..5b43c7134 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -38,6 +38,11 @@ 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 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 -- helpers two thousand lines down but read from `stepHPDrain` up here. A @@ -981,7 +986,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 +998,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 +1055,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 +1170,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 +1374,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 +1431,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 +1555,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 +1745,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 +1839,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 +1850,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 +1877,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 +1935,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 +2170,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 @@ -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,28 @@ 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 + -- Gen2 screen decorators may intentionally return no value. Other + -- generations keep their existing native queue completion result. + local hadEvent=self.queue[1]~=nil + local busy=self:advanceQueue() + if not self.current then + if GameVersion.isGen2() then return hadEvent end + return busy 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) end local input = self.game.input -- a \v CONT wait holds the box until A/B, then scrolls the next line in @@ -3012,6 +3086,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 +3103,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 +3129,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 @@ -3432,7 +3515,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 +3638,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? @@ -3616,7 +3705,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 @@ -3869,7 +3959,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 @@ -4233,7 +4323,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 @@ -4319,7 +4409,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) @@ -4335,7 +4425,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 @@ -4360,7 +4450,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 @@ -4370,12 +4460,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 @@ -4404,7 +4490,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 @@ -4448,7 +4534,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 @@ -4569,7 +4655,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()) @@ -4590,7 +4676,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 @@ -4604,7 +4690,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 @@ -4637,7 +4723,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 @@ -4645,26 +4731,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. @@ -4691,7 +4777,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 @@ -4700,7 +4786,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] @@ -4732,7 +4818,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 @@ -4873,7 +4959,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 } } @@ -4895,7 +4981,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 @@ -4922,7 +5008,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 @@ -4956,7 +5042,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") @@ -5002,7 +5088,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 @@ -5190,7 +5276,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 @@ -5208,7 +5294,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) @@ -5365,7 +5451,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") @@ -5782,7 +5868,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 @@ -5859,11 +5945,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) @@ -5904,7 +5990,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 +6105,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 +6318,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 +7871,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 +8206,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 @@ -8247,7 +8333,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 @@ -8786,7 +8876,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 +8918,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 +9215,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 +9253,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", { @@ -9523,7 +9613,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 @@ -9536,7 +9626,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", { @@ -9592,7 +9682,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 @@ -9662,7 +9752,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 @@ -9764,7 +9854,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 @@ -9864,7 +9954,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 @@ -9943,7 +10033,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 @@ -10581,12 +10671,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!", @@ -10673,7 +10763,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", { @@ -10800,6 +10890,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 @@ -10808,7 +10899,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 @@ -10824,7 +10915,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 @@ -10860,8 +10951,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', {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 -- (home/overworld.asm:22, :749-753) = GBFadeInFromWhite. This is the one @@ -12190,7 +12283,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 @@ -12648,7 +12741,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() @@ -12675,7 +12768,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) @@ -12758,7 +12851,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 @@ -12963,4 +13056,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/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..54d8a1679 --- /dev/null +++ b/src/battle/Gen2BattleView.lua @@ -0,0 +1,68 @@ +-- 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) + 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 = { + 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 or screen._gen2Complete == 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/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/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/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/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/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/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua new file mode 100644 index 000000000..8f00ffd0f --- /dev/null +++ b/src/battle/gen2/Battle.lua @@ -0,0 +1,70 @@ +-- 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) + -- 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 + +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. +function Model:endBattle(outcome, details) + local screen = owner(self) + -- 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 + if not (details and details.nativeNotification) then screen.result = outcome end + 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/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" diff --git a/tests/engine/gen2_battle_model_contract.lua b/tests/engine/gen2_battle_model_contract.lua new file mode 100644 index 000000000..b16f8d48c --- /dev/null +++ b/tests/engine/gen2_battle_model_contract.lua @@ -0,0 +1,193 @@ +-- 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(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 + 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. + -- 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 + 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'})[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 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') + 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') diff --git a/tests/engine/gen2_typed_event_queue.lua b/tests/engine/gen2_typed_event_queue.lua new file mode 100644 index 000000000..9842b15ea --- /dev/null +++ b/tests/engine/gen2_typed_event_queue.lua @@ -0,0 +1,310 @@ +-- 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 + if not mod.exports.dropReturn then return result end + 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=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) + 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=Screen.advanceQueue and 'resolving' or 'messages','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 + local Font=require('src.render.Font') + 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.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') + 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') + 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() + 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() + 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') + 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 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 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') + 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')