diff --git a/LICENSE-ROXAS2712.md b/LICENSE-ROXAS2712.md new file mode 100644 index 000000000..fbcffd47c --- /dev/null +++ b/LICENSE-ROXAS2712.md @@ -0,0 +1,88 @@ +# Roxas2712 — Fork Contributions + +Version 1.0 · Effective 25 October 2026 + +Copyright © 2026 Roxas2712, to the extent of rights held. +All rights reserved except as expressly granted below or already granted. + +## 1. Scope and ownership + +This notice applies only to original contributions made by Roxas2712 +(GitHub: Roxas2712) to `Roxas2712/Gen2Recomped`, including original additions +within modified files, insofar as he owns the relevant rights. Git history, +pull-request diffs and source provenance identify contributions; a commit +author or a whole-file change alone does not establish ownership of every +part of that file. + +It does not claim ownership of Cedric's work, Gen1Recomp / BOIS CLUB GAMES +work, other contributors' work, third-party dependencies, or ROM-derived +content. Their applicable licences and notices remain in force. It does not +grant permission to distribute the complete combined fork where another +rights holder's permission is required. + +## 2. General permission and reserved rights + +For contributions covered by this notice and not already licensed more +broadly, you may read and study the published source and make private, +non-commercial modifications and builds for your own use. This grants no +general permission to redistribute, sublicense, sell, or offer those +contributions as a hosted service. Other uses require Roxas2712's +written permission, except for the specific permission below and rights +available independently under applicable law. + +Keep the relevant copyright, licence and attribution notices. Do not +represent Roxas2712's contributions as exclusively authored by someone +else. Public source availability alone does not put them in the public +domain or grant an open-source licence. + +## 3. Specific permission for Cedric / UNDERdecoded + +Roxas2712 expressly grants Cedric (Ced; GitHub: UNDERdecoded / +UNDERdecodedHD) a non-exclusive, worldwide, perpetual, royalty-free +permission to use, reproduce, integrate, modify and distribute the covered +contributions that Roxas2712 submits or expressly offers through pull +requests to `UNDERdecoded/Gen2Recomped`, for Cedric's Gen2Recomped / +RetroRecompiled project. This includes its renamed continuation maintained +by Cedric, its official builds, and its commercial operation. + +Cedric may license the incorporated contributions to recipients as part of +that project, subject to preserving this permission's applicable credit and +notice requirements. This permission is not a copyright assignment, is not +exclusive, and does not cover unpublished work, separate repositories, +ModCompass service operation, credentials, or content not offered in those +pull requests. Roxas2712 retains his own rights to his contributions. + +For uses relying on this permission: + +- retain the relevant source copyright and licence notices; +- give visible credit in the project's README or equivalent public project + page, and in the application's existing About / Credits view, if present; +- use at least the following credit, or a substantially equivalent one: + + > Contributions by Roxas2712 — + > https://github.com/Roxas2712 + +A maintained consolidated contributor credit is sufficient; a separate +credit on every screen or for every change is not required. Adapted +contributions must not be attributed exclusively to another author. Other +contributors' credits must also remain intact. + +## 4. Earlier licences and permissions remain effective + +This notice does not revoke, narrow, or retrospectively add conditions to +any permission already validly granted, including MIT / GPL grants, +separate agreements, or permissions arising from previously submitted +pull requests. Where an earlier grant permits a use independently of this +notice, its existing terms govern that use. For such earlier uses, the +visible credit above is requested to the extent it is not already required. + +For subsequent contributions, this is the default statement of Roxas2712's own reserved rights and the specific PR permission, subject to any +applicable existing licence obligations or separately agreed terms. It +does not change another rights holder's licence or guarantee compatibility +of a particular combination of code. + +## 5. No warranty + +To the extent permitted by applicable law, these contributions are provided +as is, without warranty. No support obligation, endorsement, or rights to +third-party code, game assets, names, or trademarks are granted. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 8f8e1409c..42d79538c 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -2604,6 +2604,37 @@ function BattleState:beginMsgLine() self.shown[#self.shown + 1] = {} end +function BattleState:runQueueAction(item) + self.nextInsert=0 + item.fn() + self.current=nil + local sequence=GameVersion.isGen3()and package.loaded['src.core.game3.battle.switch_seq'] + if sequence then sequence._nativeFinish(self,item)end +end + +function BattleState:stepGen3Ball(anim) + anim:update() + if anim.sound then + require("src.core.Sound").playId(self.data, anim.sound) + end + -- the trainer is gone by the time his Pokemon is out of the ball + -- ...unless the trainer's own walk-off is still running (#407): the + -- cartridge frees that sprite at the end of the fifty frames, not when + -- the ball opens, and cutting it short hides most of the throw + if anim.sendOut == "player" and self.showPlayerBack + and anim.phase ~= "throw" + and not (self.backWalkOff and self.backWalkOff > 0) then + self.showPlayerBack = false + self:slidePic("back") + end + if anim:isDone() then + self.gen3BallPlaying = false + -- a caught Pokemon leaves the ball resting on screen through the text, + -- exactly as the Game Boy chain does + if not anim.caught then self.gen3Ball = nil end + end +end + function BattleState:updateQueue() if self.waitingUI then if self.game.stack:top() ~= self then return true end @@ -2637,28 +2668,12 @@ function BattleState:updateQueue() -- reason: its frames are counted where the hold is counted, or it would -- stand still for exactly as long as it is meant to be moving. if self.gen3BallPlaying and self.gen3Ball then - local anim = self.gen3Ball - anim:update() - if anim.sound then - require("src.core.Sound").playId(self.data, anim.sound) - end - -- the trainer is gone by the time his Pokemon is out of the ball - -- ...unless the trainer's own walk-off is still running (#407): the - -- cartridge frees that sprite at the end of the fifty frames, not when - -- the ball opens, and cutting it short hides most of the throw - if anim.sendOut == "player" and self.showPlayerBack - and anim.phase ~= "throw" - and not (self.backWalkOff and self.backWalkOff > 0) then - self.showPlayerBack = false - self:slidePic("back") - end - if anim:isDone() then - self.gen3BallPlaying = false - -- a caught Pokemon leaves the ball resting on screen through the text, - -- exactly as the Game Boy chain does - if not anim.caught then self.gen3Ball = nil end - end + local capture=GameVersion.isGen3()and package.loaded['src.core.game3.battle.catch_seq'] + if capture then capture._nativeBallStep(self,self.gen3Ball,BattleState.stepGen3Ball) + else self:stepGen3Ball(self.gen3Ball)end end + local capture=GameVersion.isGen3()and package.loaded['src.core.game3.battle.catch_seq'] + if capture and capture._holdsNativeQueue(self)then return true end -- a queued hold (faint slide, hit blink) counts down before the next row if self.waitFrames and self.waitFrames > 0 then self.waitFrames = self.waitFrames - 1 @@ -2730,9 +2745,19 @@ function BattleState:updateQueue() 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 + local sequence=GameVersion.isGen3()and package.loaded['src.core.game3.battle.switch_seq'] + if item._sourceCatchStep and GameVersion.isGen3()then sequence=package.loaded['src.core.game3.battle.catch_seq']end + if sequence and(item._sourceSwitchStep or item._sourceCatchStep)then + local consumed=false + local ok,why=pcall(sequence._nativeStep,self,item,function() + consumed=true + self:runQueueAction(item) + return true + end) + -- A held or failing source callback cannot discard a native action. + if not consumed then table.insert(self.queue,1,item)end + if not ok then error(why,0)end + else self:runQueueAction(item)end return true end if item.ui then @@ -3806,6 +3831,7 @@ function BattleState:enter() self.enemySendingOut = false self:startGrowIn(self.enemy) end) + if GameVersion.isGen3()then self.queue[#self.queue]._sourceIntroKind='opponent_sendout'end self:shinyAnimAppend(self.enemy, true) queueEnemyCry() elseif self.kind == "link" then @@ -3819,6 +3845,7 @@ function BattleState:enter() self.enemySendingOut = false self:startGrowIn(self.enemy) end) + if GameVersion.isGen3()then self.queue[#self.queue]._sourceIntroKind='opponent_sendout'end self:shinyAnimAppend(self.enemy, true) queueEnemyCry() end @@ -3858,6 +3885,7 @@ function BattleState:enter() -- then the POOF plays and the mon appears with its cry -- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry) table.insert(self.queue, { anim = "POOF_ANIM", attackerIsPlayer = false }) + if GameVersion.isGen3()then self.queue[#self.queue]._sourceIntroKind='player_throw'end self:shinyAnimAppend(self.player, false) self:act(function() self.sendingOut = false @@ -3884,6 +3912,8 @@ function BattleState:enter() self:abilitySwitchIn(self.player) end }) end + local intro=GameVersion.isGen3()and package.loaded['src.core.game3.battle.intro_seq'] + if intro then intro._nativeBind(self)end Runtime.emit("battle.started", { battle = self, kind = self:battleKind(), trainerId = self.trainer and self.trainer.id, @@ -3895,6 +3925,12 @@ end -- any pop (finish, script teardown) must silence the alarm loop -- (end_of_battle.asm clears wLowHealthAlarm when a battle ends) function BattleState:exit() + local intro=package.loaded['src.core.game3.battle.intro_seq'] + if intro then intro._nativeRelease(self)end + local capture=package.loaded['src.core.game3.battle.catch_seq'] + if capture then capture._nativeRelease(self)end + local sequence=package.loaded['src.core.game3.battle.switch_seq'] + if sequence then sequence._nativeRelease(self)end -- Teachy TV temporarily replaces the player's party exactly like -- SavePlayerParty/LoadPlayerParty in teachy_tv.c. finish() normally -- restores it before the pop, but exit is the safety net for a forced state @@ -5941,6 +5977,7 @@ function BattleState:switchPlayerInto(pos, newMon) self:markParticipant() self.sendingOut = true self:sayNext(self:sendOutText(incoming.name)) + local sendMessage=self.queue[self.nextInsert] self:animNext("POOF_ANIM", false) self:shinyAnim(incoming, false) self:actNext(function() @@ -5950,9 +5987,12 @@ function BattleState:switchPlayerInto(pos, newMon) require("src.core.Sound").playCry(self.data, incoming.mon.species) HeldItems.onEntry(self, incoming) end) + local sendEntry=self.queue[self.nextInsert] -- ABILITYEFFECT_ON_SWITCHIN, after the send-out and its cry: an -- INTIMIDATE drops the foe's attack now, before the free move below self:actNext(function() self:abilitySwitchIn(incoming) end) + local sequence=GameVersion.isGen3()and package.loaded['src.core.game3.battle.switch_seq'] + if sequence then sequence._nativeBind(self,pos,sendMessage,sendEntry,self.queue[self.nextInsert])end return incoming end @@ -12963,4 +13003,12 @@ function BattleState:drawClassic() end end +-- Source capture callbacks borrow the actual native request builder. +local throwBallNative=BattleState.throwBall +function BattleState:throwBall(ball) + local source=GameVersion.isGen3()and package.loaded['src.core.game3.battle.catch_seq'] + if source then return source._nativeBegin(self,throwBallNative,ball)end + return throwBallNative(self,ball) +end + return BattleState diff --git a/src/compat/gen1/NativeBallObservation.lua b/src/compat/gen1/NativeBallObservation.lua new file mode 100644 index 000000000..2947bb874 --- /dev/null +++ b/src/compat/gen1/NativeBallObservation.lua @@ -0,0 +1,22 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Shared scalar projection of the actual native ball, not another timeline. +local M={keys={'visible','x','y','frame','ballId','side','battler','rot','scale','alpha'}} +function M.read(screen,ball,key) + if key=='visible'then return not ball.done or ball.caught==true end + if key=='x'then return ball.x+(ball.shakeOffset or 0)end + if key=='y'then return ball.y end + if key=='frame'then return math.max(0,(ball.ballFrame or 1)-1)end + if key=='ballId'then return ball.ball end + if key=='battler'or key=='side'then + local id=1 + for i=0,3 do + local b=i==0 and screen.player or i==1 and screen.enemy or screen:battlerAt(i) + if b and ball.battler==b then id=i;break end + end + return key=='battler'and id or(id%2==0 and'player'or'enemy') + end + if key=='rot'then return 0 end -- The existing native painter is unrotated. + if key=='scale'then return 1 end -- Native ball cells draw at their own size. + if key=='alpha'then return 1-(ball.whiteout or 0)end +end +return M diff --git a/src/core/game3/battle/catch_seq.lua b/src/core/game3/battle/catch_seq.lua new file mode 100644 index 000000000..bde4d1488 --- /dev/null +++ b/src/core/game3/battle/catch_seq.lua @@ -0,0 +1,163 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Capture remains a native request, rule calculation, ball timeline and store. +-- Source callbacks borrow request/throw/ball phases; they never roll a catch. +local Battle=require('src.core.game3.battle') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local Call=require('src.compat.gen1.SourceCall') +local M={} +local screens=setmetatable({},{__mode='k'}) +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +local function touch(c) + local host=package.loaded['src.mods.Runtime'] + local who=host and host.currentMod or require('src.core.Game3')._callbackOwner() + if who then c.users[who]=true end +end +function M._nativeRelease(screen) + local c=screen._sourceCatchSequence + if c then c.view,c.ball,c.message=nil,nil,nil;c.active=false end + screen._sourceCatchSequence=nil;screens[screen]=nil +end +local function current() + local screen=Battle._screen();local c=screen and screen._sourceCatchSequence + if c and c.view~=Battle.getState()then M._nativeRelease(screen);return screen end + if c then touch(c)end + return screen,c +end +local function begin()return Boundary.invoke('battle.catch.begin')end +local function update()return Boundary.invoke('battle.catch.update')end +M.begin,M.update=begin,update +function M.busy()local _,c=current();return c and c.active or false end +function M.result()local _,c=current();return c and c.result end +local function eligible(screen) + return screen.kind=='wild'and not screen.ghost and not screen.noCatch + and require('src.battle.Gen3BallAnim').record(screen.data)~=nil +end +local function bind(screen,ball,first) + if not eligible(screen)then return end + local entry=screen.queue[#screen.queue] + if not entry or not entry.fn or #screen.queue0 or screen.gen3BallPlaying==true end + if key=='_ball'then return c.publicBall end + if key=='_ballId'then return c.ballId end + if key=='_target'then return c.target end + if key=='_headless'then return screen.headless==true end +end,__newindex=function(t,key,value) + if observations[key]then error('native capture observation is read-only: '..key,2)end + rawset(t,key,value) +end}) +M._service={release=function() + for screen in pairs(screens)do M._nativeRelease(screen)end +end,releaseOwner=function(_,who) + for screen in pairs(screens)do + local c=screen._sourceCatchSequence + if c and c.users[who]then M._nativeRelease(screen)end + end +end} +require('src.core.game3.asset_stream').register(M._service) +return M diff --git a/src/core/game3/battle/intro_seq.lua b/src/core/game3/battle/intro_seq.lua new file mode 100644 index 000000000..5ee942519 --- /dev/null +++ b/src/core/game3/battle/intro_seq.lua @@ -0,0 +1,156 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Presentation observations over the actual native entry queue and slide. +-- There is no independent sequencer, command builder or animation clock. +local Battle=require('src.core.game3.battle') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local Call=require('src.compat.gen1.SourceCall') +local M={} +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +local function copy(value) + if type(value)~='table'then return value end + local result={};for key,item in pairs(value)do result[key]=copy(item)end;return result +end +local function synchronize(screen,token) + if (screen.introSlide or 0)>0 or(screen.frlgIntro and screen.frlgIntro.hold or 0)>0 then token.index=1;return end + local index=token.rows[screen.current] + if not index then for _,row in ipairs(screen.queue or{})do + index=token.rows[row];if index then break end + end end + if index then token.index=index + elseif not screen.current and #(screen.queue or{})==0 then screen._sourceIntroSequence=nil end +end +local function current() + local screen=Battle._screen();local token=screen and screen._sourceIntroSequence + if not token then return end + local view=Battle.getState() + if token.view and token.view~=view then screen._sourceIntroSequence=nil;return end + token.view=view;synchronize(screen,token) + if screen._sourceIntroSequence~=token then return end + local owner=Call.owner(M,'update');if owner then token.users[owner]=true end + return screen,token +end +local function admitted(screen,token,top) + local live,record=current() + return live==screen and record==token and screen.game.stack:top()==(top or screen) +end +function M._nativeBind(screen) + if screen.phase~='messages'or screen.afterQueue~='menu'or not screen.introBalls then return end + local hold=screen.frlgIntro and screen.frlgIntro.hold or 0 + local slide=screen.introSlide or 0 + local token={index=1,rows={},steps={{kind='bgslide',data={frames=hold+slide, + slideFrames=slide,unlockAt=hold}}},users={}} + for _,row in ipairs(screen.queue)do + local kind=row._sourceIntroKind or(row.text~=nil and'msg'or row.wait and'wait' + or row.fn and'native_action'or row.anim and'native_animation'or'native_wait') + local data={} + if row.text~=nil then data.text=row.text end + if row.wait then data.frames=row.wait end + if kind=='opponent_sendout'then data.ids={1} end + if kind=='player_throw'then data.ids={0} end + token.steps[#token.steps+1]={kind=kind,data=data};token.rows[row]=#token.steps + end + token.canonical=copy(token.steps) + screen._sourceIntroSequence=token +end +function M._nativeRelease(screen)screen._sourceIntroSequence=nil end +function M.introText(state) + local screen=Battle._screen(state);return screen and screen.introText +end +function M.sendOutText(state,side) + local screen=Battle._screen(state);if not screen then return end + if side=='player'then return screen.player and screen:sendOutText(screen.player.name)end + if side=='enemy'then + local _,token=current();if not token then return end + for i,step in ipairs(token.canonical)do + if step.kind=='opponent_sendout'then + local previous=token.canonical[i-1] + if previous and previous.kind=='msg'then return previous.data.text end + end + end + end +end +local function update()return Boundary.invoke('battle.intro.update')end +M.update=update +function M.busy()return current()~=nil end +function M._nativeUpdate(screen,dt,body) + local live,token=current() + if live~=screen then return body(screen,dt)end + local stack,top=screen.game.stack,screen.game.stack:top() + if top~=screen then return false,'native battle is covered'end + local function native() + if screen.game.stack~=stack or not admitted(screen,token,top)then return false,'native intro admission changed'end + token.started=true + local result=pack(body(screen,dt)) + if screen._sourceIntroSequence==token then synchronize(screen,token)end + return unpack(result,1,result.n) + end + if M.update==update then return native()end + return Boundary.dispatch('battle.intro.update',native,function() + local owner=Call.owner(M,'update');if owner then token.users[owner]=true end + return Call.call(M,'update',dt) + end) +end +local function same(a,b,depth) + if type(a)~=type(b)then return false end + if type(a)~='table'then return a==b end + if(depth or 0)>4 then return false end + for key,value in pairs(a)do if not same(value,b[key],(depth or 0)+1)then return false end end + for key in pairs(b)do if a[key]==nil then return false end end + return true +end +local function replaceSteps(screen,token,value) + assert(not token.started and token.index==1,'native intro has already started') + assert(type(value)=='table'and #value==#token.canonical,'native intro steps cannot be inserted or removed') + for i,step in ipairs(token.canonical)do + local nextStep=value[i] + assert(type(nextStep)=='table'and nextStep.kind==step.kind and type(nextStep.data)=='table','native intro step identity changed') + if i>1 then assert(same(nextStep.data,step.data),'native intro queue data is read-only')end + end + local d=value[1].data;local original=token.canonical[1].data + for _,key in ipairs{'frames','slideFrames','unlockAt'}do + assert(type(d[key])=='number'and d[key]%1==0 and d[key]>=0 and d[key]<=original[key], + 'intro slide may only shorten its native presentation') + end + assert(d.frames>=1 and d.frames==d.slideFrames+d.unlockAt,'inconsistent native intro slide duration') + for key,v in pairs(d)do + assert(key=='frames'or key=='slideFrames'or key=='unlockAt' + or(key=='playerFrom'or key=='enemyFrom'or key=='enemyMonFrom')and v==0,'unsupported native intro slide option') + end + screen.introSlide=d.slideFrames + if screen.frlgIntro then screen.frlgIntro.hold=d.unlockAt end + -- Keep detached scalar observations, never source closures or queue rows. + token.steps[1].data={frames=d.frames,slideFrames=d.slideFrames,unlockAt=d.unlockAt} +end +local observed={_st=true,_steps=true,_i=true,_waiting=true,_waitingMsg=true} +setmetatable(M,{__index=function(_,key) + if not observed[key]then return end + local screen,token=current();if not token then return end + if key=='_st'then return token.view end + if key=='_steps'then return token.steps end + if key=='_i'then return token.index end + if key=='_waitingMsg'then return screen.current~=nil and screen.current.text~=nil end + if key=='_waiting'then return screen.current~=nil or screen.waitingUI==true + or screen.gen3BallPlaying==true or(screen.waitFrames or 0)>0 end +end,__newindex=function(t,key,value) + if not observed[key]then rawset(t,key,value);return end + local screen,token=current() + assert(screen and admitted(screen,token),'native intro does not own presentation') + if key=='_steps'then return replaceSteps(screen,token,value)end + -- The only writable cursor operation acknowledges an already fully shown + -- native send-out line. It cannot skip an action, a prompt or unread text. + local step=token.canonical[token.index] + local message=screen.current + assert(step and step.kind=='msg'and message and message.text==M.sendOutText(token.view,'player') + and(screen.charIndex or 0)>=(screen.total or math.huge)and not screen.scrollPx, + 'native send-out message is not complete') + if key=='_waitingMsg'or key=='_waiting'then + assert(value==false,'native intro wait may only acknowledge a completed message');return + end + assert(key=='_i'and value==token.index+1,'native intro cursor cannot skip queue steps') + screen.current=nil;synchronize(screen,token) +end}) +M._service={release=function()local screen=Battle._screen();if screen then M._nativeRelease(screen)end end, + releaseOwner=function(_,owner)local screen,token=current();if token and token.users[owner]then M._nativeRelease(screen)end end} +require('src.core.game3.asset_stream').register(M._service) +return M diff --git a/src/core/game3/battle/switch_seq.lua b/src/core/game3/battle/switch_seq.lua new file mode 100644 index 000000000..2d979a914 --- /dev/null +++ b/src/core/game3/battle/switch_seq.lua @@ -0,0 +1,63 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Observe tagged native send-out rows and borrow their existing action once. +-- The native queue retains its messages, animation waits, rules and clock. +-- Public step identities contain only presentation scalars, never native rows. +local Battle=require('src.core.game3.battle') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local Call=require('src.compat.gen1.SourceCall') +local M={} +local function current() + local screen=Battle._screen() + return screen,screen and screen._sourceSwitchSequence +end +local function update()return Boundary.invoke('battle.switch.update')end +M.update=update +function M.busy()local _,sequence=current();return sequence~=nil end +function M._nativeBind(screen,id,message,entry,ending) + local side=id%2==0 and'player'or'enemy' + local token={index=1,message=message,steps={ + {kind='msg_sendout',data={id=id,side=side}}, + {kind=side=='player'and'sendout_player'or'sendout_enemy',data={id=id,side=side}}, + }} + screen._sourceSwitchSequence=token + entry._sourceSwitchStep=token + ending._sourceSwitchEnd=token + return token +end +function M._nativeFinish(screen,row) + if row._sourceSwitchEnd and screen._sourceSwitchSequence==row._sourceSwitchEnd then + screen._sourceSwitchSequence=nil + end +end +function M._nativeRelease(screen)screen._sourceSwitchSequence=nil end +function M._nativeStep(screen,row,body) + local token=row._sourceSwitchStep + if not token or screen._sourceSwitchSequence~=token then return body()end + token.index=2 + if M.update==update or Battle._screen()~=screen then return body()end + local stack=screen.game.stack;local top=stack:top() + if top~=screen then return false,'native battle is covered'end + local view=Battle.getState() + return Boundary.dispatch('battle.switch.update',function() + if Battle._screen()~=screen or Battle.getState()~=view + or screen.game.stack~=stack or stack:top()~=top then + return false,'native switch admission changed' + end + return body() + end,function()return Call.call(M,'update')end) +end +local observed={_st=true,_steps=true,_i=true,_waiting=true,_waitingMsg=true} +setmetatable(M,{__index=function(_,key) + if not observed[key]then return nil end + local screen,token=current();if not token then return nil end + if key=='_st'then return Battle.getState()end + if key=='_steps'then return token.steps end + if key=='_i'then return token.index end + if key=='_waitingMsg'then return screen.current==token.message end + if key=='_waiting'then return screen.waitingUI==true or screen.gen3BallPlaying==true + or screen.gen3AnimPlaying==true or(screen.waitFrames or 0)>0 end +end,__newindex=function(t,key,value) + if observed[key]then error('native switch observation is read-only: '..key,2)end + rawset(t,key,value) +end}) +return M diff --git a/tests/engine/native_capture_sequence.lua b/tests/engine/native_capture_sequence.lua new file mode 100644 index 000000000..4f3997e8c --- /dev/null +++ b/tests/engine/native_capture_sequence.lua @@ -0,0 +1,118 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Catch=require('src.core.game3.battle.catch_seq') +local Battle=require('src.core.game3.battle') +local G=require('src.core.Game3') +local begin,update=Catch.begin,Catch.update +local original=package.loaded['src.core.Game'] +T.eq(begin(4),nil);T.eq(update(),nil);T.eq(Catch.busy(),false);T.eq(Catch.result(),nil) +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + for _,success in ipairs{false,true}do + local f=require('tools.battle-source-runtime.fixture').new(edition);local game=f.game + package.loaded['src.core.Game']=game + game.data.constants.itemOrder={[4]='POKE_BALL'};game.data.items.POKE_BALL={index=4,name='POKE BALL'} + game.data.constants.gen3BallAnim={timing={},sounds={},itemToBall={[4]=0}} + local b=f.fresh('TACKLE').battle;b.enter=false;game.stack:push(b) + b.phase='messages';b.queue={};b.current=nil;b.nextInsert=nil + local rolls=0;b.rng=function(lo,hi)rolls=rolls+1;return success and(lo or 0)or(hi or lo or 0)end + local attempts=0;local attempt=b.catchAttempt + b.catchAttempt=function(self,...)attempts=attempts+1;return attempt(self,...)end + local notifications=0 + Catch.begin=function(item) + notifications=notifications+1;T.eq(item,4);begin(item);return begin(item) + end + -- The real bag may still cover combat while its native request is built. + game.stack:push({});b:throwBall('POKE_BALL');game.stack:pop() + T.eq(notifications,1);T.eq(#b.queue,2,'one native message and one native action') + T.eq(attempts,0,'the native request has not rolled the catch yet') + T.eq(Catch.busy(),true);T.eq(Catch._ballId,4);T.eq(Catch._target,1) + T.eq(Catch._st,Battle.getState());T.eq(Catch._headless,false) + local steps=Catch._steps;T.eq(steps,Catch._steps);T.eq(Catch._i,1) + T.eq(steps[2].kind,'throw');T.eq(steps[2].fn,nil);T.eq(Catch._ball,nil) + b.current=b.queue[1];T.eq(Catch._waitingMsg,true);b.current=nil + local entry=b.queue[2];b.queue={entry};local before=rolls + Catch.update=function()return false end + b:updateQueue();T.eq(b.queue[1],entry);T.eq(Catch._i,2);T.eq(attempts,0);T.eq(rolls,before) + Catch.update=function()error('capture source before throw')end + T.raises(function()b:updateQueue()end,'capture source before throw');T.eq(b.queue[1],entry);T.eq(attempts,0) + Catch.update=function()game.stack:push({});local a,why=update();game.stack:pop();return a,why end + b:updateQueue();T.eq(b.queue[1],entry);T.eq(attempts,0) + local nativeEntry=entry.fn;entry.fn=function()error('native capture failure')end + Catch.update=function()pcall(update);return true end + T.raises(function()b:updateQueue()end,'native capture failure');T.eq(#b.queue,0) + entry.fn=nativeEntry;b.queue={entry};b.nextInsert=nil + Catch.update=function()update();return update()end + if success then b:updateQueue() + else + Catch.update=function()update();update();error('capture source after native throw')end + T.raises(function()b:updateQueue()end,'capture source after native throw') + end + T.neq(b.queue[1],entry,'consumed native capture cannot be reinserted after an error') + T.eq(attempts,1,'repeated base calls roll the actual native catch once') + local anim=b.gen3Ball;T.neq(anim,nil);T.eq(anim.caught,success) + T.eq(Catch._i,3);T.eq(Catch._steps,steps);T.eq(Catch.result(),nil) + local ball=Catch._ball;local stage=ball.stage + T.eq(stage.visible,true);T.eq(stage.x,anim.x);T.eq(stage.y,anim.y);T.eq(ball.dead,false) + T.raises(function()stage.x=1 end,'read-only');T.raises(function()Catch._target=3 end,'read-only') + local queued=#b.queue;local hp=b.enemy.mon.hp;local party=#game.save.party + local frame=b.frame;b.waitFrames=100 + Catch.update=function()return false end + b:updateQueuePresentation();T.eq(anim.frame,0);T.eq(b.waitFrames,100,'held ball retains the native queue wait') + T.eq(#b.queue,queued);T.eq(b.enemy.mon.hp,hp);T.eq(#game.save.party,party);T.eq(attempts,1) + Catch.update=function()error('capture source during flight')end + T.raises(function()b:updateQueuePresentation()end,'capture source during flight') + T.eq(anim.frame,0);T.eq(b.waitFrames,100) + Catch.update=function()update();return update()end + b:updateQueuePresentation();T.eq(anim.frame,1);T.eq(b.waitFrames,99) + T.eq(stage,ball.stage,'native ball stage keeps its presentation identity') + T.eq(stage.x,anim.x+(anim.shakeOffset or 0));T.eq(stage.y,anim.y);T.eq(b.frame,frame) + for _=1,2000 do if not b.gen3BallPlaying then break end;b:updateMovePresentation()end + T.eq(b.gen3BallPlaying,false);T.eq(Catch.busy(),false);T.eq(Catch._steps,nil) + T.eq(Catch.result(),success and'caught'or'fail_catch');T.eq(attempts,1) + T.eq(#game.save.party,party,'presentation never stores the caught mon') + T.eq(stage.visible,success,'only the actually caught native ball rests on screen') + Catch.begin,Catch.update=begin,update + game.stack:pop();T.eq(Catch.result(),nil);T.eq(stage.visible,false);T.eq(ball.dead,true) + T.eq(b._sourceCatchSequence,nil);Catch._service.release();Battle._service.release();f.close();G.release() + end +end +for _,unsupported in ipairs{'missing_art','ghost','noCatch','trainer'}do + local f=require('tools.battle-source-runtime.fixture').new('firered');local game=f.game + package.loaded['src.core.Game']=game + game.data.constants.gen3BallAnim={timing={},sounds={}} + local b=f.fresh('TACKLE').battle;b.enter=false;game.stack:push(b);b.queue={};b.current=nil + if unsupported=='missing_art'then game.data.constants.gen3BallAnim=nil + elseif unsupported=='trainer'then b.kind='trainer' + else b[unsupported]=true end + Catch.begin=function()error('unsupported capture must retain its native request')end + b:throwBall('POKE_BALL');T.check(#b.queue>0);T.eq(Catch.busy(),false);T.eq(Catch._steps,nil) + Catch.begin=begin;game.stack:pop();Catch._service.release();Battle._service.release();f.close();G.release() +end +Catch.begin,Catch.update=begin,update;package.loaded['src.core.Game']=original +local function retiredBall() + local f=require('tools.battle-source-runtime.fixture').new('firered');local game=f.game + package.loaded['src.core.Game']=game;game.data.constants.gen3BallAnim={timing={},sounds={}} + local b=f.fresh('TACKLE').battle;b.enter=false;game.stack:push(b);b.queue={};b.current=nil + b:throwBall('POKE_BALL');local entry=b.queue[#b.queue];b.queue={entry};b:updateQueue() + local anim=b.gen3Ball;local ball=Catch._ball;T.neq(ball,nil) + local kept={Catch._steps,ball,ball.stage} + G._withCallbackOwner('capture_owner',function()T.eq(Catch.busy(),true)end) + Catch._service.releaseOwner(nil,'unrelated_capture_owner');T.eq(Catch.busy(),true) + local frame=anim.frame;local count=#b.queue;local party=#game.save.party + Catch._service.releaseOwner(nil,'capture_owner') + T.eq(Catch.busy(),false);T.eq(b.gen3Ball,anim);T.eq(b.gen3BallPlaying,true) + T.eq(anim.frame,frame);T.eq(#b.queue,count);T.eq(#game.save.party,party) + b:updateMovePresentation();T.eq(anim.frame,frame+1,'owner teardown preserves the actual native ball') + T.eq(ball.dead,true);T.eq(ball.stage.visible,false) + local weak=setmetatable({game,game.save,game.data,b,anim},{__mode='v'}) + game.stack:pop();Catch._service.release();Battle._service.release();f.close();G.release() + package.loaded['src.core.Game']=original + return kept,weak +end +local retained,weak=retiredBall() +require('src.render.Font').load(T.fixtures.fresh()) +for _=1,4 do collectgarbage('collect')end +for i=1,5 do T.eq(weak[i],nil,'retained capture observations release native owner '..i)end +T.eq(retained[1][2].kind,'throw');T.eq(retained[2].dead,true);T.eq(retained[3].visible,false) +T.finish('native capture request, throw/ball gates, queue waits and result ownership') diff --git a/tests/engine/native_intro_sequence.lua b/tests/engine/native_intro_sequence.lua new file mode 100644 index 000000000..02f2a39dc --- /dev/null +++ b/tests/engine/native_intro_sequence.lua @@ -0,0 +1,171 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Intro=require('src.core.game3.battle.intro_seq') +local Battle=require('src.core.game3.battle') +local G=require('src.core.Game3') +local base=Intro.update;local original=package.loaded['src.core.Game'] +local function pack(...)return{n=select('#',...),...}end +local function fresh(edition,kind) + local f=require('tools.battle-source-runtime.fixture').new(edition) + package.loaded['src.core.Game']=f.game + local b=f.fresh('TACKLE').battle + if kind then b.kind=kind end + f.game.stack:push(b) + return f,b +end +local function close(f) + f.game.stack:pop();Intro._service.release();Battle._service.release();f.close();G.release() +end +local function proposal(steps) + local out={} + for i,step in ipairs(steps)do out[i]={kind=step.kind,data={}} + for key,value in pairs(step.data)do + if type(value)=='table'then local values={};for n,item in ipairs(value)do values[n]=item end;value=values end + out[i].data[key]=value + end + end + return out +end +T.eq(base(),nil);T.eq(Intro.busy(),false);T.eq(Intro.introText({}),nil);T.eq(Intro.sendOutText({},'player'),nil) +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + -- Real native enter builds every message, action, sound, shiny and throw + -- row. The source step list must not retain a row, function or battler. + local f,b=fresh(edition) + local game=f.game;local view=Battle.getState();local steps=Intro._steps + T.eq(Intro._st,view);T.eq(Intro.busy(),true);T.eq(Intro._i,1);T.eq(Intro._waiting,false) + T.eq(steps,Intro._steps);T.eq(steps[1].kind,'bgslide') + T.eq(steps[1].data.slideFrames,b.introSlide) + T.eq(steps[1].data.unlockAt,b.frlgIntro and b.frlgIntro.hold or 0) + T.eq(Intro.introText(view),b.introText);T.eq(Intro.sendOutText(view,'player'),b:sendOutText(b.player.name)) + local throw,go + for _,step in ipairs(steps)do + T.eq(step.fn,nil);T.eq(step.data.mon,nil) + if step.kind=='player_throw'then throw=step end + if step.kind=='msg'and step.data.text==Intro.sendOutText(view,'player')then go=step end + end + T.neq(throw,nil);T.eq(throw.data.ids[1],0);T.neq(go,nil) + T.eq(#steps,#b.queue+1,'only actual native entry rows are described') + local queued={};for i,row in ipairs(b.queue)do queued[i]=row end + local tooLong=proposal(steps);tooLong[1].data.slideFrames=100000 + T.raises(function()Intro._steps=tooLong end,'shorten') + local changed=proposal(steps);changed[2].kind='player_throw' + T.raises(function()Intro._steps=changed end,'identity') + local unread=proposal(steps);unread[#unread].data.injected=true + T.raises(function()Intro._steps=unread end,'read-only') + local oldFrames,oldSlide=steps[1].data.frames,steps[1].data.slideFrames + steps[1].data.frames,steps[1].data.slideFrames=100000,100000 + T.raises(function()Intro._steps=proposal(steps)end,'shorten') + steps[1].data.frames,steps[1].data.slideFrames=oldFrames,oldSlide + local fast=proposal(steps);fast[1].data={frames=1,slideFrames=1,unlockAt=0, + playerFrom=0,enemyFrom=0,enemyMonFrom=0} + Intro._steps=fast;T.eq(b.introSlide,1);T.eq(b.frlgIntro and b.frlgIntro.hold or 0,0) + for i,row in ipairs(b.queue)do T.eq(row,queued[i],'shortening never rebuilds the native queue')end + local frame=b.frame;local hp,pp=b.player.mon.hp,b.player.mon.moves[1].pp + Intro.update=function()return false end + b:update(1/60);T.eq(b.frame,frame);T.eq(b.introSlide,1);T.eq(Intro._i,1) + Intro.update=function()base();return base()end + b:update(1/60);T.eq(b.frame,frame+1,'multiple base calls borrow one native frame') + T.eq(b.introSlide,0);T.eq(b.player.mon.hp,hp);T.eq(b.player.mon.moves[1].pp,pp) + T.raises(function()Intro._steps=fast end,'already started') + T.eq(base(),nil,'source cannot advance native time outside its callback') + Intro.update=function()error('intro source before native')end + frame=b.frame;T.raises(function()b:update(1/60)end,'intro source before native');T.eq(b.frame,frame) + Intro.update=function()base();error('intro source after native')end + T.raises(function()b:update(1/60)end,'intro source after native');T.eq(b.frame,frame+1) + local tick=b.tickFx;b.tickFx=function()error('native intro failed')end + Intro.update=function()pcall(base);return true end + T.raises(function()b:update(1/60)end,'native intro failed');b.tickFx=tick + local rejected + Intro.update=function()game.stack:push({});local a,why=base();rejected=why;game.stack:pop();return a,why end + frame=b.frame;local a=b:update(1/60);T.eq(a,false);T.eq(rejected,'native intro admission changed');T.eq(b.frame,frame) + Intro.update=function()error('covered battle must not call intro wrapper')end + game.stack:push({});b:update(1/60);game.stack:pop();T.eq(b.frame,frame) + local seams=require('src.mods.ModSeams');local owner=G._callbackOwner() + Intro.update=function()T.eq(G._callbackOwner(),'intro_owner');return base()end + seams._journal[#seams._journal+1]={tbl=Intro,key='update',after=Intro.update,owner='intro_owner'} + b:update(1/60);table.remove(seams._journal);T.eq(G._callbackOwner(),owner) + Intro._service.releaseOwner(nil,'unrelated');T.eq(Intro.busy(),true) + Intro._service.releaseOwner(nil,'intro_owner');T.eq(Intro.busy(),false) + Intro.update=function()error('retired observers cannot hold the native entry')end + frame=b.frame;b:update(1/60);T.eq(b.frame,frame+1) + Intro.update=base;close(f);T.eq(Intro._steps,nil);T.eq(Intro._st,nil) + -- Native module-absent/default/wrapped entry traces have the same clock, + -- slide, queue and text typing state through the initial FRLG hold. + local traces={} + for _,mode in ipairs{'absent','default','wrapped'}do + if mode=='absent'then package.loaded['src.core.game3.battle.intro_seq']=nil + else package.loaded['src.core.game3.battle.intro_seq']=Intro end + Intro.update=mode=='wrapped'and function()base();return base()end or base + local current,screen=fresh(edition);local rows={} + for _=1,45 do screen:update(1/60);rows[#rows+1]=table.concat({screen.frame,screen.introSlide, + screen.frlgIntro and screen.frlgIntro.hold or 0,#screen.queue,screen.charIndex or -1},':')end + traces[mode]=table.concat(rows,'|');close(current) + end + T.eq(traces.default,traces.absent,edition..' default entry trace') + T.eq(traces.wrapped,traces.absent,edition..' wrapped entry trace') + package.loaded['src.core.game3.battle.intro_seq']=Intro;Intro.update=base + local entry,screen=fresh(edition,'trainer') + local enemyMessage=Intro.sendOutText(Battle.getState(),'enemy');local enemyStep + for _,step in ipairs(Intro._steps)do if step.kind=='opponent_sendout'then enemyStep=step end end + T.neq(enemyMessage,nil);T.neq(enemyStep,nil);T.eq(enemyStep.data.ids[1],1) + local values=pack(Intro._nativeUpdate(screen,1/60,function()return nil,7,nil end)) + T.eq(values.n,3);T.eq(values[1],nil);T.eq(values[2],7);T.eq(values[3],nil) + -- Public metadata is detached: mutating it cannot widen the real native + -- slide budget or turn a message into an executable action. + local live=Intro._steps;live[1].data.frames=100000;live[1].data.slideFrames=100000 + T.raises(function()Intro._steps=proposal(live)end,'already started') + local goRow,throwRow + for _,row in ipairs(screen.queue)do + if row.text==Intro.sendOutText(Battle.getState(),'player')then goRow=row end + if row._sourceIntroKind=='player_throw'then throwRow=row end + end + T.neq(goRow,nil);T.neq(throwRow,nil) + screen.introSlide=0;if screen.frlgIntro then screen.frlgIntro.hold=0 end + screen.queue={throwRow};screen:startMessage(goRow) + local cursor=Intro._i + T.raises(function()Intro._waitingMsg=false end,'not complete') + screen.charIndex=screen.total;screen.scrollPx=nil + T.raises(function()Intro._i=cursor+2 end,'cannot skip') + Intro._waitingMsg=false;Intro._waiting=false;Intro._i=cursor+1 + T.eq(screen.current,nil);T.eq(screen.queue[1],throwRow);T.eq(#screen.queue,1) + T.eq(Intro._steps[Intro._i].kind,'player_throw','acknowledgment does not consume the native throw') + local admission + Intro.update=function() + local saved=entry.game.save;entry.game.save={} + local ok,why=base();admission=why;entry.game.save=saved;return ok,why + end + local frame=screen.frame;screen:update(1/60) + T.eq(admission,'native intro admission changed');T.eq(screen.frame,frame) + T.eq(Intro._steps,nil,'a replaced cohort retires native intro observations') + Intro.update=base + close(entry) +end +-- Exercise the real clock/UI composition too. Intro holds must leave the +-- already-owned presentation task alive, and repeated pump/base calls must +-- not tick either native owner twice. +local Anim=require('src.core.game3.battle.anim') +local Ui=require('src.core.game3.battle.ui');local pump=Ui.pump +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + local f,b=fresh(edition);local samples=0 + Anim.tweenStage(3,function()samples=samples+1 end) + Ui.pump=function(...)pump(...);return pump(...)end + Intro.update=function()base();return base()end + local frame=b.frame;b:update(1/60);T.eq(b.frame,frame+1);T.eq(samples,1) + Intro.update=function()return false end + b:update(1/60);T.eq(b.frame,frame+1);T.eq(samples,2) + Intro.update=base;b:update(1/60);T.eq(b.frame,frame+2);T.eq(samples,3) + Ui.pump=pump;Intro.update=base;close(f);Anim._service.release() +end +-- Retained public steps cannot keep a completed native entry/session alive. +local function retired() + local f,b=fresh('firered');local steps=Intro._steps;local game=f.game + local weak=setmetatable({game,game.save,game.data,b,b._sourceIntroSequence},{__mode='v'}) + close(f);package.loaded['src.core.Game']=original + return steps,weak +end +local retained,weak=retired();require('src.render.Font').load(T.fixtures.fresh()) +for _=1,4 do collectgarbage('collect')end +for i=1,5 do T.eq(weak[i],nil,'retained entry steps release owner '..i)end +T.eq(retained[1].kind,'bgslide');Intro.update=base;package.loaded['src.core.Game']=original +T.finish('actual native intro rows, presentation shortening, shared update and observer retirement') diff --git a/tests/engine/native_switch_sequence.lua b/tests/engine/native_switch_sequence.lua new file mode 100644 index 000000000..0789f8a2b --- /dev/null +++ b/tests/engine/native_switch_sequence.lua @@ -0,0 +1,107 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Battle=require('src.core.game3.battle') +local Switch=require('src.core.game3.battle.switch_seq') +local G=require('src.core.Game3') +local base=Switch.update;local original=package.loaded['src.core.Game'] +T.eq(base(),nil,'source cannot invent a native queue phase') +T.eq(Switch.busy(),false) +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + local f=require('tools.battle-source-runtime.fixture').new(edition) + local game=f.game;package.loaded['src.core.Game']=game + local b=f.fresh('TACKLE').battle;b.enter=false;game.stack:push(b) + -- Keep the real controller and native constructor. The incoming party + -- member is synthetic imported data, never a player save or ROM cache. + local mon=require('src.pokemon.Pokemon').new(f.data,'CHARMANDER',30,function(lo)return lo or 0 end) + game.save.party[2]=mon;b.queue={};b.current=nil;b.nextInsert=nil + local incoming=b:switchPlayerInto(0,mon) + T.eq(incoming.mon,mon);T.eq(b.player,incoming,'native logical replacement remains synchronous') + T.eq(Switch._st,Battle.getState());T.eq(Switch._i,1);T.eq(Switch.busy(),true) + local steps=Switch._steps;local token=b._sourceSwitchSequence + T.eq(steps,Switch._steps,'step identity survives observation') + T.eq(steps[1].kind,'msg_sendout');T.eq(steps[1].data.id,0);T.eq(steps[1].data.side,'player') + T.eq(steps[2].kind,'sendout_player');T.eq(steps[2].data.id,0) + T.eq(steps[2].fn,nil);T.eq(steps[2].data.mon,nil,'public steps retain no native actor') + local entry,ending + for _,row in ipairs(b.queue)do + if row._sourceSwitchStep then entry=row end + if row._sourceSwitchEnd then ending=row end + end + T.neq(entry,nil);T.neq(ending,nil);T.eq(b.queue[1],token.message) + b.current=token.message;T.eq(Switch._waitingMsg,true);b.current=nil + T.eq(Switch._waitingMsg,false);b.waitFrames=1;T.eq(Switch._waiting,true);b.waitFrames=0 + T.raises(function()Switch._i=9 end,'read-only') + local nativeGrow,cry,held=0,0,0 + local grow=b.startGrowIn;local Sound=require('src.core.Sound');local sound=Sound.playCry + local Held=require('src.battle.HeldItems');local onEntry=Held.onEntry + b.startGrowIn=function(self,actor)T.eq(self,b);T.eq(actor,incoming);nativeGrow=nativeGrow+1 end + Sound.playCry=function(data,species)T.eq(data,f.data);T.eq(species,mon.species);cry=cry+1 end + Held.onEntry=function(screen,actor)T.eq(screen,b);T.eq(actor,incoming);held=held+1 end + entry.fn() + b.startGrowIn=grow;Sound.playCry=sound;Held.onEntry=onEntry + T.eq(nativeGrow,1,'tagged row is the actual native send-out action');T.eq(cry,1);T.eq(held,1) + -- The hook belongs immediately before the existing grow-in/ball action. + -- Retain that exact row but substitute its side effects to count dispatches. + local calls=0;entry.fn=function()calls=calls+1 end + b.queue={entry};b.waitFrames=0;b.animPlaying=false;b.gen3BallPlaying=false;b.gen3AnimPlaying=false + b.waitingUI=nil;b.waitingSound=nil;b.draining=nil;b.expFilling=nil + local frame,hp,pp=b.frame,mon.hp,mon.moves[1]and mon.moves[1].pp + Switch.update=function()return false end + b:updateQueue();T.eq(calls,0);T.eq(b.queue[1],entry,'hold preserves the exact native row') + T.eq(Switch._i,2);T.eq(Switch._steps,steps);T.eq(b.frame,frame);T.eq(mon.hp,hp) + T.eq(mon.moves[1]and mon.moves[1].pp,pp) + Switch.update=function()base();return base()end + b:updateQueue();T.eq(calls,1,'repeated source calls consume native action once');T.eq(#b.queue,0) + T.eq(base(),nil);T.eq(calls,1,'source call between frames has no command clock') + -- Errors before consumption retain the row; errors after it cannot replay. + b.queue={entry};Switch.update=function()error('before native switch')end + T.raises(function()b:updateQueue()end,'before native switch');T.eq(b.queue[1],entry);T.eq(calls,1) + Switch.update=function()base();error('after native switch')end + T.raises(function()b:updateQueue()end,'after native switch');T.eq(#b.queue,0);T.eq(calls,2) + b.queue={entry};entry.fn=function()calls=calls+1;error('native switch failed')end + Switch.update=function()pcall(base);return true end + T.raises(function()b:updateQueue()end,'native switch failed');T.eq(calls,3);T.eq(#b.queue,0) + b.nextInsert=nil;entry.fn=function()calls=calls+1 end;b.queue={entry} + Switch.update=function()game.stack:push({});local a,why=base();game.stack:pop();return a,why end + b:updateQueue();T.eq(calls,3);T.eq(b.queue[1],entry,'covered callback cannot consume') + Switch.update=function()return base()end + game.stack:push({});b:updateQueue();game.stack:pop() + T.eq(calls,3);T.eq(b.queue[1],entry,'already covered battle cannot dispatch source action') + local seams=require('src.mods.ModSeams');local owner=G._callbackOwner() + Switch.update=function()T.eq(G._callbackOwner(),'switch_owner');return base()end + seams._journal[#seams._journal+1]={tbl=Switch,key='update',after=Switch.update,owner='switch_owner'} + b:updateQueue();T.eq(calls,4);T.eq(G._callbackOwner(),owner);table.remove(seams._journal) + b.queue={entry} + Switch.update=function() + local save=game.save;game.save={};local a,why=base();game.save=save;return a,why + end + b:updateQueue();T.eq(calls,4);T.eq(b.queue[1],entry,'replaced native session cannot consume the prior action') + -- Completion only clears its own admission, never a later queued switch. + local message2,entry2,end2={},{fn=function()end},{fn=function()end} + local nextToken=Switch._nativeBind(b,3,message2,entry2,end2) + T.eq(Switch._steps[2].kind,'sendout_enemy');T.eq(Switch._steps[2].data.id,3) + Switch._nativeFinish(b,ending);T.eq(b._sourceSwitchSequence,nextToken) + b.queue={end2};Switch.update=base;b:updateQueue() + T.eq(Switch.busy(),false);T.eq(Switch._steps,nil) + Switch._nativeBind(b,0,message2,entry2,end2);game.stack:pop() + T.eq(b._sourceSwitchSequence,nil,'native exit retires switch observations') + T.eq(Switch.busy(),false);T.eq(Switch._st,nil);T.eq(steps[2].data.id,0) + Battle._service.release();f.close();G.release() +end +-- Ordinary queue functions retain their existing insertion order and native +-- owner in the other generations, with no switch facade admission. +for _,edition in ipairs{'red','crystal','platinum','black'}do + local f=require('tools.battle-source-runtime.fixture').new('firered') + local b=f.fresh('TACKLE').battle + require('src.core.GameVersion').set(edition) + local order={};b.current=nil;b.queue={{fn=function() + order[#order+1]='first';b:actNext(function()order[#order+1]='inserted'end) + end},{fn=function()order[#order+1]='last'end}} + b:updateQueue();b:updateQueue();b:updateQueue() + T.eq(table.concat(order,','),'first,inserted,last',edition..' native queue order') + T.eq(b.current,nil);T.eq(Switch.busy(),false) + f.close();G.release() +end +Switch.update=base;package.loaded['src.core.Game']=original +T.finish('native switch queue admission, stable steps, holds, errors and once-only action dispatch')