From 84f4eb4b0e6886814c952cb489600522a194b82f Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 11:30:13 +0100 Subject: [PATCH 1/9] Bind source GBA battle views and presentation phases to native controllers --- .../gba-battle-source-boundary.md | 55 +++++ src/battle/BattleState.lua | 35 +++ src/compat/gen1/BattleIds.lua | 40 ++++ src/compat/gen1/BattleRequests.lua | 48 ++++ src/compat/gen1/BattleRuntime.lua | 75 +++++++ src/compat/gen1/BattleView.lua | 210 ++++++++++++++++++ src/core/game3/battle.lua | 132 +++++++++++ src/core/game3/battle/bg.lua | 25 +++ src/core/game3/battle/ui.lua | 69 ++++++ src/core/game3/mon_anim.lua | 85 +++++++ src/core/game3/pokemon.lua | 175 +++++++++++++++ tools/battle-source-runtime/fixture.lua | 47 ++++ tools/battle-source-runtime/host/conf.lua | 6 + tools/battle-source-runtime/host/main.lua | 67 ++++++ 14 files changed, 1069 insertions(+) create mode 100644 docs/qa/native-foundation/gba-battle-source-boundary.md create mode 100644 src/compat/gen1/BattleIds.lua create mode 100644 src/compat/gen1/BattleRequests.lua create mode 100644 src/compat/gen1/BattleRuntime.lua create mode 100644 src/compat/gen1/BattleView.lua create mode 100644 src/core/game3/battle.lua create mode 100644 src/core/game3/battle/bg.lua create mode 100644 src/core/game3/battle/ui.lua create mode 100644 src/core/game3/mon_anim.lua create mode 100644 src/core/game3/pokemon.lua create mode 100644 tools/battle-source-runtime/fixture.lua create mode 100644 tools/battle-source-runtime/host/conf.lua create mode 100644 tools/battle-source-runtime/host/main.lua diff --git a/docs/qa/native-foundation/gba-battle-source-boundary.md b/docs/qa/native-foundation/gba-battle-source-boundary.md new file mode 100644 index 000000000..5575fba5e --- /dev/null +++ b/docs/qa/native-foundation/gba-battle-source-boundary.md @@ -0,0 +1,55 @@ +# Native GBA battle source boundary + +This is an incremental presentation and observation bridge over the existing +`BattleState`. It does not establish complete unchanged-package boot or play. + +## Implemented boundaries + +- The admitted native stack owns battle identity, input, combat, RNG and exit. +- Stable source observations translate cartridge species/move/item/ability/type + identifiers and flattened Pokemon fields. Trainer class and trainer id remain + different fields. HP/status changes and PP transactions reach native Pokemon; + unsupported move replacements fail before an update. +- Native start notifications, update, UI pump, whole-screen drawing and the + background pass have independent scopes. Repeated base calls consume each + native body once. Calls outside an update scope cannot tick combat. +- Covered battles remain observable, but cannot update or pop their cover. + Mod retirement invalidates its source views without cancelling native combat. + Completed views keep detached observations and reject further native writes. +- Basic source wild/trainer requests reuse the native encounter admission and + cleanup provider when present. Authored source parties, extended options, + headless simulation and source-controlled link termination remain unsupported. + Constructor-request acceptance is not yet independently qualified here. +- Pokemon observations use native imported records. Height and weight convert + metres/kilograms to cartridge decimetres/hectograms. Static pictures are owned + by the source service; native animation frames remain borrowed. +- Separate presentation players reuse native `MonAnim`/`PicAnim` timing. Their + timing is the existing native implementation, not a proof of exact source + sprite callbacks. They cannot consume combat commands. + +## Qualification + +The focused engine suites cover all five registered GBA editions using +synthetic imported data and actual native constructors, stack admissions and +exit. They exercise repeated base calls, covered states, UI input/draw scopes, +owner/data/edition retirement, numeric identity, PP and detached final views. +View-only collection checks isolate this bridge from the host's global facade +cache; they do not certify the entire host's Game/save collection policy. + +`tools/battle-source-runtime/host` draws the actual native `Gen3Battle` on a +GPU. It compares default and wrapped pixels, replaces only the field pass, +checks that the native command-area pixels remain identical, and checks +graphics restoration after a failing callback. Artwork and battle records are +synthetic; these are not original-package or cartridge gameplay checks. + +## Remaining presentation work + +The UI module deliberately does not advertise the complete sprite/particle +presentation protocol. Native animation VM, effect, healthbox, trainer, ball, +text, catch, switch and intro presentation interfaces still need their own +bindings. So do complete party/save collection schemas and authored source +Pokemon operations. Original-package initialization, real play sequences, +source effect timing/art parity and physical target acceptance remain open. + +The last unchanged-package loader probe advances beyond battle, Pokemon, +motion and UI imports to the missing `src.core.game3.battle.anim` interface. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 297e84c74..c90feb417 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -53,6 +53,8 @@ local TEXT_AREA_ROWS = { 112, 128 } local BattleState = {} BattleState.__index = BattleState BattleState.isOpaque = true +-- Native source observations identify this admitted controller directly. +BattleState.isBattleState = true -- pokered prints the battle lines itself (engine/battle/core.asm and the -- move-effect banks), and the importer extracts every one of them, so the @@ -12855,4 +12857,37 @@ function BattleState:drawClassic() end end +-- Source wrappers observe the real GBA owner. Unrequested modules and other +-- generations keep the direct native path; there is no additional clock. +do + local update, draw, field, exit, enter = BattleState.update, BattleState.draw, + BattleState.drawBattleField, BattleState.exit, BattleState.enter + BattleState._drawNative = draw + function BattleState:enter() + enter(self) + local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle'] + if source then return source._nativeEnter(self) end + end + function BattleState:update(dt) + local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle'] + if source then return source._nativeUpdate(self, dt, update) end + return update(self, dt) + end + function BattleState:draw() + local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle'] + if source then return source._nativeDraw(self, draw) end + return draw(self) + end + function BattleState:drawBattleField() + local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle'] + if source then return source._nativeField(self, field) end + return field(self) + end + function BattleState:exit() + local source = package.loaded['src.core.game3.battle'] + if source then source._nativeExit(self) end + return exit(self) + end +end + return BattleState diff --git a/src/compat/gen1/BattleIds.lua b/src/compat/gen1/BattleIds.lua new file mode 100644 index 000000000..f2cb29d1b --- /dev/null +++ b/src/compat/gen1/BattleIds.lua @@ -0,0 +1,40 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Cartridge indices belong to this imported dataset, including sparse species +-- slots. Portable names and National Dex numbers are not substitutes for them. +local M={} +local schema={pokemon={'speciesOrder','pokemon'},moves={'moveOrder','moves'},items={'itemOrder','items'}, + abilities={'abilityOrder','abilities',1},types={'typeOrder','types',1},trainers={'trainerOrder','trainers'}} +function M.new(data) + local indexes={} + local function index(kind) + if indexes[kind]then return indexes[kind]end + local spec=assert(schema[kind],'unknown battle identifier kind') + local constants=data.constants or{};local rows=data[spec[2]]or constants[spec[2]]or{} + local order=constants[spec[1]]or{};local forward,reverse={},{} + local function add(number,key) + if type(number)~='number'or number<0 or number%1~=0 then return end + if forward[number]~=nil and forward[number]~=key then forward[number]=false else forward[number]=key end + if reverse[key]~=nil and reverse[key]~=number then reverse[key]=false else reverse[key]=number end + end + for number,key in pairs(order)do if type(number)=='number'and rows[key]~=nil then add(number-(spec[3]or 0),key)end end + for key,row in pairs(rows)do + if type(row)=='table'and type(row.index)=='number'and order[row.index+(spec[3]or 0)]==nil then add(row.index,key)end + end + local result={forward=forward,reverse=reverse,rows=rows};indexes[kind]=result;return result + end + return { + read=function(_,kind,key) + if key==nil or key==false then return(kind=='items'or kind=='abilities')and 0 or nil end + local i=index(kind);local n=i.reverse[key] + if n~=nil then return n or nil end + if type(key)=='number'and i.rows[key]then return key end + end, + native=function(_,kind,value) + if value==nil or value==0 and(kind=='items'or kind=='abilities')then return nil end + local i=index(kind) + if type(value)=='number'then return i.forward[value]or nil end + if i.rows[value]then return value end + end, + } +end +return M diff --git a/src/compat/gen1/BattleRequests.lua b/src/compat/gen1/BattleRequests.lua new file mode 100644 index 000000000..9d9e24837 --- /dev/null +++ b/src/compat/gen1/BattleRequests.lua @@ -0,0 +1,48 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Queued native encounters reuse the host's admission, trainer flags, +-- afterBattle cleanup and callback cancellation. Unsupported source-owned +-- party simulations are rejected before constructing or rolling a Pokemon. +local Native=require('src.compat.gen1.NativeSession') +local Ids=require('src.compat.gen1.BattleIds') +local Version=require('src.core.GameVersion') +local R={} +local fields={wild=true,foe=true,trainerId=true,onDone=true} +function R.start(opts) + if type(opts)~='table'then return false,'invalid battle options'end + for key in pairs(opts)do if not fields[key]then return false,'native battle option unavailable: '..tostring(key)end end + if opts.onDone~=nil and type(opts.onDone)~='function'then return false,'invalid battle continuation'end + local game,why=Native.resolve();if not game then return false,why end + local Host=require('src.mods.Runtime');local facade=require('src.core.Game3') + local owner=Host.currentMod or facade._callbackOwner() + local loader=game.mods;local mod=loader and loader.mods and loader.mods[owner] + if not(mod and loader.hooks and type(loader.hooks.wrap)=='function')then return false,'battle request has no live mod owner'end + local save,data,edition=game.save,game.data,Version.get() + local function licensed() + return game.mods==loader and game.save==save and game.data==data and Version.get()==edition + and loader.mods[owner]==mod and mod.enabled~=false and not mod.failed + end + local function current()return Native.game()end + local function world(g)return{overworld=function()return Native.world(g)end}end + local available,admission=pcall(require,'src.mods.Api3WildBattle') + if not available then return false,'native encounter admission provider unavailable: '..tostring(admission)end + local start=admission.new + if opts.wild==true then + local foe=opts.foe + if type(foe)~='table'then return false,'wild battle requires a species and level'end + for key in pairs(foe)do if key~='species'and key~='level'then return false,'authored wild Pokemon field unavailable: '..tostring(key)end end + local species=Ids.new(data):native('pokemon',foe.species) + if not species then return false,'unknown cartridge species'end + if type(foe.level)~='number'or foe.level%1~=0 or foe.level<1 or foe.level>100 then return false,'invalid wild level'end + -- Resolve has already validated the cartridge key; the native plan avoids + -- interpreting it again as a portable species name. + local plan={resolve=function()return species end,construct=function(g,key) + return require('src.battle.BattleState').newWild(g,key,foe.level) + end} + return start(loader,owner,current,world,licensed,require,nil,plan)(species,foe.level,opts.onDone) + end + if opts.foe~=nil then return false,'trainer requests require the imported trainer id'end + if opts.trainerId==nil then return false,'native trainer id required'end + return start(loader,owner,current,world,licensed,require,nil, + require('src.mods.ScriptBattles').nativeTrainer)(opts.trainerId,1,opts.onDone) +end +return R diff --git a/src/compat/gen1/BattleRuntime.lua b/src/compat/gen1/BattleRuntime.lua new file mode 100644 index 000000000..94701a9c7 --- /dev/null +++ b/src/compat/gen1/BattleRuntime.lua @@ -0,0 +1,75 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Observe real native stack admissions. Completed source views keep no save, +-- controller, imported dataset or borrowed GPU resource alive. +local Native=require('src.compat.gen1.NativeSession') +local Version=require('src.core.GameVersion') +local Views=require('src.compat.gen1.BattleView') +local R={};R.__index=R +local function owner() + local host=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + return host and host.currentMod or facade and facade._callbackOwner() +end +local function cohort(c) + return c.game.data==c.data and c.game.save==c.save and c.game.mods==c.loader + and c.game.stack==c.stack and Version.get()==c.edition +end +local function member(c) + if c.retired or not cohort(c)then return false end + for _,state in ipairs(c.stack.states or{})do if state==c.screen then return true end end + return false +end +function R.new()return setmetatable({records=setmetatable({},{__mode='kv'})},R)end +function R:retire(c,completed) + if not c or c.retired then return end + c.views:retire();c.retired=true + self.records[c.screen]=nil + if self.current==c then self.current=nil end + if completed then + self.last=c.views.view + self.lastSession=setmetatable({c.game,c.data,c.save,c.stack,c.loader},{__mode='v'}) + self.lastEdition=c.edition + end +end +function R:bind(screen) + local game=screen and Native.game(screen.game) + if not(game and game==Native.game()and screen.isBattleState and screen.data==game.data and game.stack and game.save)then return end + local c=self.records[screen] + if c and member(c)then local who=owner();if who then c.users[who]=true end;return c end + if c then self:retire(c,false)end + c={screen=screen,game=game,data=game.data,save=game.save,stack=game.stack,loader=game.mods,edition=Version.get(),users={}} + if not member(c)then return end + if self.current and self.current~=c then self:retire(self.current,false)end + c.views=Views.new(game,screen,function()return member(c)end) + local who=owner();if who then c.users[who]=true end + self.records[screen]=c;self.current=c;self.last=nil + return c +end +function R:active(value) + local game=Native.game(value) + if value~=nil and game~=Native.game()then return nil end + local last=self.lastSession + if last and(not game or last[1]~=game or last[2]~=game.data or last[3]~=game.save + or last[4]~=game.stack or last[5]~=game.mods or self.lastEdition~=Version.get())then + self.last,self.lastSession,self.lastEdition=nil,nil,nil + end + if self.current and(not member(self.current)or self.current.game~=game)then self:retire(self.current,false)end + if not game then return end + for i=#(game.stack and game.stack.states or{}),1,-1 do + local state=game.stack.states[i] + if state.isBattleState and state.game==game then return self:bind(state)end + end +end +function R:exit(screen) + local c=self.records[screen] + if c then self:retire(c,cohort(c))end +end +function R:release() + if self.current then self:retire(self.current,false)end + self.last,self.lastSession,self.lastEdition=nil,nil,nil +end +function R:poll()self:active()end +function R:releaseOwner(who) + if self.current and self.current.users[who]then self:retire(self.current,false)end +end +R.member=member +return R diff --git a/src/compat/gen1/BattleView.lua b/src/compat/gen1/BattleView.lua new file mode 100644 index 000000000..526bfe393 --- /dev/null +++ b/src/compat/gen1/BattleView.lua @@ -0,0 +1,210 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Live, typed source observations over one admitted native battle. Views own +-- their arrays; they never lend the native controller/save under source names. +local Ids=require('src.compat.gen1.BattleIds') +local M={} +local statNames={attack='attack',defense='defense',speed='speed',spAtk='spatk',spDef='spdef',hp='hp'} +local statusBits={PSN=8,BRN=16,FRZ=32,PAR=64,TOX=128} +local function status(mon) + if mon.status=='SLP'then return math.max(1,math.min(7,tonumber(mon.sleepTurns)or 1))end + if mon.status=='PSN'and mon.toxic then return 128 end + return statusBits[mon.status]or 0 +end +local function integer(v,lo,hi)return type(v)=='number'and v==v and v%1==0 and v>=lo and v<=hi end +local monKeys={'species','nickname','level','hp','maxHp','attack','defense','speed','spAtk','spDef', + 'moves','pp','maxPp','item','status','ability','personality','friendship','isEgg','otId','otSecretId'} +local battlerKeys={'id','side','flank','mon','species','type1','type2','ability','item','status','fainted','stages','partyIndex'} +local stateKeys={'player','enemy','battlers','party','playerParty','enemyParty','turn','wild','double','over','result', + 'weather','weatherTurns','session','playerIndex','enemyIndex','trainerClass','trainerName','trainerPicId', + 'link','safari','tutorial','oldManTutorial','pokedude','firstBattle','ghostBattle','kinds','rng'} +local reserved={} +for _,key in ipairs(stateKeys)do reserved[key]=true end +function M.new(game,screen,valid) + local e={context={game=game,screen=screen,data=game.data},entries=setmetatable({},{__mode='k'}), + caches={},arrays={},valid=valid,retired=false} + local ids=Ids.new(game.data) + local function at(pos) + local s=e.context and e.context.screen;if not s then return end + if pos==0 then return s.player elseif pos==1 then return s.enemy end + return s.battlerAt and s:battlerAt(pos) + end + local read + local function collection(name,values,convert) + local a=e.arrays[name] + if not a then a={view={}};e.arrays[name]=a end + local out=a.view;local old=#out + for i,value in ipairs(values or{})do out[i]=convert and convert(value,i)or value end + for i=#(values or{})+1,old do out[i]=nil end + return out + end + local function proxy(kind,raw) + if not raw then return nil end + local cache=e.caches[kind];if not cache then cache=setmetatable({},{__mode='kv'});e.caches[kind]=cache end + local existing=cache[raw];if existing then return existing end + local d={kind=kind,raw=raw};local view={};cache[raw]=view;e.entries[view]=d + setmetatable(view,{__index=function(_,key) + if d.snapshot then return d.snapshot[key]end + if not e.context or not e.valid()then return nil end + return read(d,key) + end,__newindex=function(t,key,value) + assert(e.context and e.valid(),'native battle view retired') + if kind=='mon'and(key=='hp'or key=='nickname'or key=='item'or key=='status')then + local mon=d.raw + if key=='hp'then assert(integer(value,0,mon.stats.hp),'invalid native HP');mon.hp=value + elseif key=='nickname'then assert(type(value)=='string'and#value<=32,'invalid native nickname');mon.nickname=value + elseif key=='item'then + local item=ids:native('items',value);assert(value==0 or item,'unknown native held item');mon.item=item + else + assert(integer(value,0,255),'invalid native status') + local inverse={[8]='PSN',[16]='BRN',[32]='FRZ',[64]='PAR',[128]='PSN'} + assert(value==0 or value<=7 or inverse[value],'unsupported combined native status') + mon.status=value>0 and(value<=7 and'SLP'or inverse[value])or nil + mon.sleepTurns=value>0 and value<=7 and value or nil;mon.toxic=value==128 or nil + end + elseif kind=='state'and not reserved[key]then + -- Presentation extensions belong to this view; they cannot replace the + -- controller's command queue, party aliases or completion result. + rawset(t,key,value) + else error('native battle '..kind..' '..tostring(key)..' is not directly writable',2)end + end}) + return view + end + local function partyIndex(party,mon)for i,value in ipairs(party or{})do if value==mon then return i end end end + local function party()local c=e.context;return c.screen.playerParty or c.game.save.party or{}end + local function moveArrays(mon) + local d=e.moveArrays and e.moveArrays[mon] + if not d then + e.moveArrays=e.moveArrays or{};d={moves={},pp={},maxPp={},baseline={}};e.moveArrays[mon]=d + end + -- PP edits use an explicit native transaction before the next native body. + if not d.pending then + for _,key in ipairs{'moves','pp','maxPp'}do for i in pairs(d[key])do d[key][i]=nil end end + d.baseline={};d.ids={};d.maximum={} + for i,mv in ipairs(mon.moves or{})do + d.moves[i]=ids:read('moves',mv.id);d.pp[i]=mv.pp;d.baseline[i]=mv.pp + local def=(e.context.data.moves or{})[mv.id] + d.maxPp[i]=def and def.pp and def.pp+math.floor(def.pp/5)*(mv.ppUps or 0)or nil + d.ids[i]=d.moves[i];d.maximum[i]=d.maxPp[i] + end + d.pending=true + end + return d + end + read=function(d,key) + local raw=d.raw;local c=e.context + if d.kind=='mon'then + if key=='species'then return ids:read('pokemon',raw.species)end + if key=='item'then return ids:read('items',raw.item)end + if key=='ability'then + local b;for pos=0,3 do local actual=at(pos);if actual and actual.mon==raw then b=actual;break end end + return ids:read('abilities',b and b.ability or raw.ability) + end + if key=='hp'then return raw.hp end + if key=='maxHp'then return raw.stats and raw.stats.hp end + if statNames[key]then return raw.stats and raw.stats[statNames[key]]end + if key=='status'then return status(raw)end + if key=='moves'or key=='pp'or key=='maxPp'then return moveArrays(raw)[key]end + if key=='otId'then return raw.otId or raw.otID end + if key=='otSecretId'then return raw.otSecretId or raw.secretId end + if key=='friendship'then return raw.friendship or raw.happiness end + if key=='nickname'or key=='level'or key=='personality'or key=='isEgg'then return raw[key]end + elseif d.kind=='battler'then + local pos=raw.position + if pos==nil then for i=0,3 do if at(i)==raw then pos=i;break end end end + if key=='id'then return pos end + if key=='side'then return pos and(pos%2==0 and'player'or'enemy')end + if key=='flank'then return pos and(pos<2 and'left'or'right')end + if key=='mon'then return proxy('mon',raw.mon)end + if key=='species'then return ids:read('pokemon',raw.species or raw.mon.species)end + if key=='ability'then return ids:read('abilities',raw.ability)end + if key=='item'then return ids:read('items',raw.mon.item)end + if key=='status'then return status(raw.mon)end + if key=='fainted'then return raw.fainted==true or raw.mon.hp<=0 end + if key=='partyIndex'then return partyIndex(pos and pos%2==0 and party()or c.screen.enemyParty,raw.mon)or 1 end + if key=='stages'then return proxy('stages',raw.stages or{})end + if key=='type1'or key=='type2'then return ids:read('types',(raw.curTypes or{})[key=='type1'and 1 or 2])end + elseif d.kind=='stages'then return raw[statNames[key]or key] + elseif d.kind=='state'then + if key=='player'or key=='enemy'then return proxy('battler',raw[key])end + if key=='battlers'then + e.battlers=e.battlers or{};for pos=0,3 do e.battlers[pos]=proxy('battler',at(pos))end;return e.battlers + end + if key=='party'or key=='playerParty'then return collection('party',party(),function(mon)return proxy('mon',mon)end)end + if key=='enemyParty'then + e.wildParty=e.wildParty or{};e.wildParty[1]=raw.enemy and raw.enemy.mon + return collection('enemyParty',raw.enemyParty or e.wildParty,function(mon)return proxy('mon',mon)end) + end + if key=='turn'then return raw.turnCount or 0 end + if key=='wild'then return raw.kind=='wild'end + if key=='double'then return raw.isDouble and raw:isDouble()or raw.double==true end + if key=='over'then return raw.result~=nil or raw.dead==true end + if key=='result'then return raw.result end + if key=='weather'then return raw.field and raw.field.weather end + if key=='weatherTurns'then return raw.field and raw.field.weatherTurns end + if key=='playerIndex'then return partyIndex(party(),raw.player and raw.player.mon)end + if key=='enemyIndex'then return raw.enemyIndex or(raw.enemy and 1)end + if key=='trainerClass'then return raw.trainer and raw.trainer.class or raw.trainerClass end + if key=='trainerName'then return raw.trainerName or raw.trainer and raw.trainer.name end + if key=='trainerPicId'then return raw.trainer and(raw.trainer.picIndex or raw.trainer.index)end + if key=='session'then return require('src.core.Game3').forNative(c.game).session end + if key=='link'then return raw.kind=='link'end + if key=='safari'then return raw.safari~=nil and raw.safari~=false end + if key=='ghostBattle'then return raw.ghost==true end + if key=='tutorial'or key=='oldManTutorial'then return raw.demo==true or raw.dudeDemo==true end + if key=='pokedude'then return raw.pokedudeDemo~=nil and raw.pokedudeDemo~=false end + if key=='firstBattle'then return raw.firstBattle==true end + if key=='kinds'then + e.kinds=e.kinds or{};e.kinds.tutorial=raw.pokedudeDemo and'pokedude'or(raw.demo and'oldMan'or nil);return e.kinds + end + if key=='rng'then return e.rng end + end + end + e.view=proxy('state',screen) + e.rng=function(...)assert(e.context and e.valid(),'native battle RNG retired');return e.context.screen.rng(...)end + function e:flush() + if not self.context or not self.valid()then return false end + local writes={} + for mon,d in pairs(self.moveArrays or{})do + for i,value in pairs(d.moves)do assert(value==d.ids[i],'source move replacement requires native move learning')end + for i,value in pairs(d.ids)do assert(d.moves[i]==value,'source move replacement requires native move learning')end + for i,value in pairs(d.maxPp)do assert(value==d.maximum[i],'source maximum PP is read-only')end + for i,value in pairs(d.maximum)do assert(d.maxPp[i]==value,'source maximum PP is read-only')end + for i in pairs(d.baseline)do assert(d.pp[i]~=nil,'source PP slot cannot be removed')end + for i,value in pairs(d.pp)do + assert(d.baseline[i]~=nil,'unknown source PP slot') + if value~=d.baseline[i]then + local mv=mon.moves and mon.moves[i];local maximum=d.maxPp[i] + assert(mv and mv.pp==d.baseline[i],'native move PP changed during source edit') + assert(integer(value,0,maximum or 99),'invalid source move PP') + writes[#writes+1]={move=mv,value=value} + end + end + end + for _,write in ipairs(writes)do write.move.pp=write.value end + for mon,d in pairs(self.moveArrays or{})do d.pending=false;moveArrays(mon)end + return true + end + function e:retire() + if self.retired then return end + for mon,d in pairs(self.moveArrays or{})do d.pending=false;moveArrays(mon)end + -- Populate only declared observations. No native queue, functions, save or + -- data table is copied into the completed record or retained by its views. + local seen={} + repeat + local pending=false + for view,d in pairs(self.entries)do if not seen[view]then + pending=true;seen[view]=true;local snapshot={} + for _,key in ipairs(d.kind=='state'and stateKeys or d.kind=='battler'and battlerKeys or d.kind=='mon'and monKeys or{'attack','defense','speed','spAtk','spDef','accuracy','evasion'})do + if key~='session'and key~='rng'then snapshot[key]=read(d,key)end + end + d.snapshot=snapshot + end end + if not pending then break end + until false + for _,d in pairs(self.entries)do d.raw=nil end + self.context,self.moveArrays,self.wildParty,self.valid=nil,nil,nil,function()return false end + self.retired=true;ids=nil + end + return e +end +return M diff --git a/src/core/game3/battle.lua b/src/core/game3/battle.lua new file mode 100644 index 000000000..12f549c0b --- /dev/null +++ b/src/core/game3/battle.lua @@ -0,0 +1,132 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Source battle presentation and observations, driven by the native owner. +local Runtime=require('src.compat.gen1.BattleRuntime') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local Scope=require('src.render.GraphicsScope') +local M={} +local runtime=Runtime.new() +local function call(name,...) + local facade=require('src.core.Game3');local owner=facade._callbackOwner() + local seams=package.loaded['src.mods.ModSeams'] + for i=#(seams and seams._journal or{}),1,-1 do + local change=seams._journal[i] + if change.tbl==M and change.key==name and change.after==M[name]then owner=change.owner;break end + end + return facade._withCallbackOwner(owner,M[name],...) +end +function M.isActive()return runtime:active()~=nil end +function M.getState()local c=runtime:active();return c and c.views.view or runtime.last end +function M.getResult()local view=M.getState();return view and view.result end +function M.start(opts) + if Boundary.active('battle.start')then return Boundary.invoke('battle.start')end + return require('src.compat.gen1.BattleRequests').start(opts) +end +function M.songInfo(st) + st=st or M.getState();local kinds=st and st.kinds or{} + return{wild=st and st.wild or false,link=st and st.link or false, + trainerClass=st and st.trainerClass,trainerName=st and st.trainerName,frontier=kinds.frontier, + kind=kinds.kyogreGroudon and'kyogreGroudon'or kinds.regi and'regi'or nil} +end +function M.statWindowPhase() + local c=runtime:active();if not c then return false end + for _,state in ipairs(c.stack.states)do + if getmetatable(state)==c.screen.StatBox then return true end + end + return false +end +function M.update()return Boundary.invoke('battle.update')end +local function draw(value,w,h) + local c=runtime:active(value);if not c then return false,'no admitted native battle'end + local bw,bh=c.screen:uiSize();w,h=w or bw,h or bh + if w~=bw or h~=bh then return false,'battle capture must use the native surface size'end + if Boundary.active('battle.draw')then return Boundary.invoke('battle.draw')end + -- A capture can draw the native screen but cannot re-enter source wrappers. + local ok,result=Scope.call(c.screen._drawNative,c.screen) + if not ok then error(result,0)end + return true +end +M.draw=draw +function M.abort(result) + result=result or'run' + local c=runtime:active();if not c then return false,'no admitted native battle'end + if c.stack:top()~=c.screen then return false,'native battle is covered by another state'end + if c.aborting then return false,'native battle completion already requested'end + if result~='run'and result~='lose'and result~='win'and result~='caught'then + return false,'a native battle outcome is required' + end + -- Aborting cannot award a victory or catch that the native rules never made. + if result~='run'and c.screen.result~=result then return false,'outcome has not been decided by the native battle'end + if c.screen.kind=='link'then return false,'link termination belongs to the native peer protocol'end + c.views:flush();c.aborting=true;c.screen.result=result;c.screen:finish();return true +end +function M._nativeEnter(screen) + local c=runtime:bind(screen);if not c then return end + if c.notified then return end;c.notified=true + local st=c.views.view + return Boundary.dispatch('battle.start',function()return true end,function() + return call('start',{wild=st.wild,playerParty=st.playerParty,foe=st.enemy and st.enemy.mon, + trainerId=screen.trainer and screen.trainer.id}) + end) +end +function M.reset() + if runtime:active()then return false,'cannot reset an admitted native battle'end + runtime:release();return true +end +function M._nativeUpdate(screen,dt,body) + local c=runtime:bind(screen);if not c then return body(screen,dt)end + if c.stack:top()~=screen then return false,'native battle is covered by another state'end + if Boundary.active('battle.update')then return Boundary.invoke('battle.update')end + return Boundary.dispatch('battle.update',function() + c.views:flush() + local ui=package.loaded['src.core.game3.battle.ui'] + if ui then return ui._nativeUpdate(screen,dt,body)end + return body(screen,dt) + end,function() + local result=call('update',dt,require('src.core.Game3').forNative(c.game)) + if Runtime.member(c)then c.views:flush()end + return result + end) +end +function M._nativeDraw(screen,body) + local c=runtime:bind(screen);if not c then return body(screen)end + if Boundary.active('battle.draw')then return Boundary.invoke('battle.draw')end + local ui=package.loaded['src.core.game3.battle.ui'] + local function native() + if ui then return ui._nativeDraw(screen,body)end + return body(screen) + end + if M.draw==draw then return native()end + local w,h=screen:uiSize() + local ok,result=Scope.call(function() + return Boundary.dispatch('battle.draw',native,function() + return call('draw',require('src.core.Game3').forNative(c.game),w,h) + end) + end) + if not ok then error(result,0)end + return result +end +function M._nativeField(screen,body) + local bg=package.loaded['src.core.game3.battle.bg'] + if not bg then return body(screen)end + return bg._nativeDraw(screen,body) +end +function M._nativeExit(screen)runtime:exit(screen)end +-- Private typed consumers can resolve a source observation to the same native +-- admission; callers cannot submit another state or a stale completed view. +function M._screen(view) + local c=runtime:active() + if c and(view==nil or view==c.views.view)then return c.screen end +end +local service={poll=function()runtime:poll()end,release=function()runtime:release()end, + releaseOwner=function(_,who)runtime:releaseOwner(who)end} +M._service=service +require('src.core.game3.asset_stream').register(service) +setmetatable(M,{__index=function(_,key) + local c=runtime:active() + if key=='_active'then return c~=nil end + if key=='_st'then return c and c.views.view or runtime.last end + if not c then return nil end + if key=='_pendingEnd'then return c.screen.result end + if key=='_phase'then return c.screen.result and'ending'or c.screen.phase end +end}) +return M diff --git a/src/core/game3/battle/bg.lua b/src/core/game3/battle/bg.lua new file mode 100644 index 000000000..9cd7a851c --- /dev/null +++ b/src/core/game3/battle/bg.lua @@ -0,0 +1,25 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- The background hook consumes only the native field pass, never its HUD. +local Boundary=require('src.compat.gen1.ScriptBoundary') +local Scope=require('src.render.GraphicsScope') +local B={} +local function draw()return Boundary.invoke('battle.background')end +B.draw=draw +function B._nativeDraw(screen,body) + if B.draw==draw then return body(screen)end + if Boundary.active('battle.background')then return Boundary.invoke('battle.background')end + local ok,result=Scope.call(function() + return Boundary.dispatch('battle.background',function()return body(screen)end,function() + local facade=require('src.core.Game3');local who=facade._callbackOwner() + local seams=package.loaded['src.mods.ModSeams'] + for i=#(seams and seams._journal or{}),1,-1 do + local change=seams._journal[i] + if change.tbl==B and change.key=='draw'and change.after==B.draw then who=change.owner;break end + end + return facade._withCallbackOwner(who,B.draw,screen.gen3TerrainId or screen.terrainId,0,0,0) + end) + end) + if not ok then error(result,0)end + return result +end +return B diff --git a/src/core/game3/battle/ui.lua b/src/core/game3/battle/ui.lua new file mode 100644 index 000000000..23532c682 --- /dev/null +++ b/src/core/game3/battle/ui.lua @@ -0,0 +1,69 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native battle surface and input-phase seams. Sprite/particle presentation +-- capabilities are separate; this module does not advertise that protocol. +local Battle=require('src.core.game3.battle') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local Scope=require('src.render.GraphicsScope') +local Ui={} +local function call(name,...) + local facade=require('src.core.Game3');local who=facade._callbackOwner() + local seams=package.loaded['src.mods.ModSeams'] + for i=#(seams and seams._journal or{}),1,-1 do + local change=seams._journal[i] + if change.tbl==Ui and change.key==name and change.after==Ui[name]then who=change.owner;break end + end + return facade._withCallbackOwner(who,Ui[name],...) +end +function Ui.statusHUDVisible() + local screen=Battle._screen();return screen and screen:statusHUDVisible()or false +end +function Ui.bottomUIVisible(state) + local screen=Battle._screen(state);return screen and screen:bottomUIVisible()or false +end +function Ui.moveGridNavigation() + local screen=Battle._screen();return screen and screen:moveGridNavigation()or false +end +function Ui.bindState(state) + if state==nil then + if Battle.isActive()then return false,'cannot unbind an admitted native battle UI'end + return true + end + if not Battle._screen(state)then return false,'UI state is not the admitted native battle'end + return true +end +local function pump()return Boundary.invoke('battle.ui.update')end +Ui.pump=pump +local function draw(w,h) + local screen=Battle._screen();if not screen then return false,'no admitted native battle'end + local bw,bh=screen:uiSize() + if w~=nil and w~=bw or h~=nil and h~=bh then return false,'UI capture must use the native battle surface'end + if Boundary.active('battle.ui.draw')then return Boundary.invoke('battle.ui.draw')end + local ok,result=Scope.call(screen._drawNative,screen) + if not ok then error(result,0)end + return true +end +Ui.draw=draw +function Ui._nativeDraw(screen,body) + if Ui.draw==draw then return body(screen)end + if Boundary.active('battle.ui.draw')then return Boundary.invoke('battle.ui.draw')end + local w,h=screen:uiSize() + local ok,result=Scope.call(function() + return Boundary.dispatch('battle.ui.draw',function()return body(screen)end,function()return call('draw',w,h)end) + end) + if not ok then error(result,0)end + return result +end +function Ui._nativeUpdate(screen,dt,body) + if Ui.pump==pump then return body(screen,dt)end + if Boundary.active('battle.ui.update')then return Boundary.invoke('battle.ui.update')end + return Boundary.dispatch('battle.ui.update',function()return body(screen,dt)end,function()return call('pump',dt)end) +end +setmetatable(Ui,{__index=function(_,key) + local screen=Battle._screen() + if key=='_st'then return Battle.getState()end + if not screen then return nil end + if key=='_menuIndex'then return screen.menuIndex end + if key=='_moveIndex'then return screen.moveIndex end + if key=='_mode'then return({menu='menu',moveSelect='moves',mimicSelect='moves',targetSelect='target'})[screen.phase]or'none'end +end}) +return Ui diff --git a/src/core/game3/mon_anim.lua b/src/core/game3/mon_anim.lua new file mode 100644 index 000000000..61efedc4a --- /dev/null +++ b/src/core/game3/mon_anim.lua @@ -0,0 +1,85 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Mod-owned presentation players reuse the native procedural motions and +-- imported frame strips. They never tick a battler or consume a command. +local Session=require('src.compat.gen1.NativeSession') +local Ids=require('src.compat.gen1.BattleIds') +local Motion=require('src.pokemon.MonAnim') +local Pictures=require('src.pokemon.PicAnim') +local Version=require('src.core.GameVersion') +local M={nativeMotion=true} +local records=setmetatable({},{__mode='k'}) +local function owner() + local host=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + return host and host.currentMod or facade and facade._callbackOwner() +end +local function retire(c) + c.game,c.data,c.loader,c.motion,c.pictures=nil,nil,nil,nil,nil;c.stopped=true +end +local function valid(c) + if not c or c.stopped then return false end + if Session.game()~=c.game or c.game.data~=c.data or c.game.mods~=c.loader or Version.get()~=c.edition then retire(c);return false end + return true +end +function M.enabled() + local game=Session.game();local record=game and game.data and game.data.constants and game.data.constants.gen3MonAnim + return type(record)=='table'and type(record.anims)=='table'and next(record.anims)~=nil or false +end +function M.newSprite(species) + local game=Session.game();if not(game and game.data)then return nil,'no native Pokemon data'end + local key=Ids.new(game.data):native('pokemon',species) + if not key then return nil,'unknown cartridge species'end + local sprite={frames=0} + records[sprite]={game=game,data=game.data,loader=game.mods,edition=Version.get(),key=key,owner=owner()} + return sprite +end +function M.summary(sprite,species,oneFrame) + local c=records[sprite];if not valid(c)then return nil,'native presentation player retired'end + if species~=nil and Ids.new(c.data):native('pokemon',species)~=c.key then return nil,'presentation species changed'end + c.motion=Motion.new(c.data,c.key) + if c.motion then c.motion:restart()end + c.pictures=not oneFrame and Pictures.new(c.data,c.key)or nil + if c.pictures then c.pictures:start()end + sprite.frames=0;return sprite +end +function M.step(sprite) + local c=records[sprite];if not valid(c)then return false,'native presentation player retired'end + if c.motion then c.motion:update(1/60)end + if c.pictures then c.pictures:update(1/60)end + sprite.frames=sprite.frames+1;return true +end +function M.done(sprite) + local c=records[sprite] + return not valid(c)or(not c.motion or not c.motion:active()) + and(not c.pictures or c.pictures.paused or c.pictures.steps==nil) +end +function M.busy(sprite)return not M.done(sprite)end +function M.stop(sprite)local c=records[sprite];if c then retire(c)end end +function M.transform(sprite) + local c=records[sprite];local t=valid(c)and c.motion and c.motion:transform()or Motion.IDENTITY + local tint=t.tint;local color,coeff=0,0 + if tint then + color=math.floor(tint[1]*31+.5)+math.floor(tint[2]*31+.5)*32+math.floor(tint[3]*31+.5)*1024 + coeff=(tint[4]or 0)*16 + end + return{x2=t.dx,y2=t.dy,sx=t.sx,sy=t.sy,rotation=t.rot,alpha=t.alpha, + invisible=t.alpha==0,frame=valid(c)and c.pictures and c.pictures:frame()or 0, + blendColor=color,blendCoeff=coeff} +end +function M.framePic(species,frame,shiny) + if type(frame)~='number'or frame%1~=0 or frame<=0 then return nil end + local game=Session.game();if not(game and game.data)then return nil end + local key=Ids.new(game.data):native('pokemon',species);if not key then return nil end + local mon={species=key,isShiny=shiny==true} + local record=Pictures.record(game.data,key,mon) + if shiny and record and not record.shinySheet then return nil,'native shiny frame strip unavailable'end + -- Borrow the native frame cache. Only its native owner may retire it. + local image=Pictures.frameImage(record,frame,mon) + if not image then return nil end + local w,h=image:getDimensions();return{image=image,w=w,h=h} +end +local service={release=function()for _,c in pairs(records)do retire(c)end end, + poll=function()for _,c in pairs(records)do valid(c)end end, + releaseOwner=function(_,who)for _,c in pairs(records)do if c.owner==who then retire(c)end end end} +M._service=service +require('src.core.game3.asset_stream').register(service) +return M diff --git a/src/core/game3/pokemon.lua b/src/core/game3/pokemon.lua new file mode 100644 index 000000000..95089e051 --- /dev/null +++ b/src/core/game3/pokemon.lua @@ -0,0 +1,175 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Cartridge-numbered observations over native imported Pokemon data and art. +-- This module does not create another party, save, extractor or battle engine. +local Native=require('src.compat.gen1.NativeSession') +local Ids=require('src.compat.gen1.BattleIds') +local Pokemon=require('src.pokemon.Pokemon') +local Sprites=require('src.pokemon.Sprites') +local Assets=require('src.render.Assets') +local Version=require('src.core.GameVersion') +local P={} +local current,cache,users=nil,{},{} +local function release() + for _,entry in pairs(cache)do if entry.image and entry.image.release then entry.image:release()end end + current,cache,users=nil,{},{} +end +local function context() + local game=Native.game() + if not(game and game.data and type(game.data.pokemon)=='table')then release();return end + if not current or current.game~=game or current.data~=game.data or current.loader~=game.mods or current.edition~=Version.get()then + release();current={game=game,data=game.data,loader=game.mods,edition=Version.get(),ids=Ids.new(game.data)} + end + local host=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + local who=host and host.currentMod or facade and facade._callbackOwner() + if who then users[who]=true end + return current +end +local function copy(value,seen) + if type(value)~='table'then return type(value)~='function'and value or nil end + seen=seen or{};if seen[value]then return seen[value]end + local out={};seen[value]=out;for k,v in pairs(value)do out[k]=copy(v,seen)end;return out +end +local function species(value) + local c=context();if not c then return end + local key=c.ids:native('pokemon',value) + return key and c.data.pokemon[key],key,c +end +function P.ready()return context()~=nil end +function P.speciesOf(mon) + if type(mon)~='table'then return nil end + local _,key,c=species(mon.species) + return key and c.ids:read('pokemon',key) +end +function P.name(value)local row=species(value);return row and row.name end +function P.keyName(value)local row,key=species(value);return row and(row.key or row.id or key)end +function P.speciesFromName(value) + local c=context();if not c or type(value)~='string'then return nil end + local found + for key,row in pairs(c.data.pokemon)do + if value==key or value==row.id or value==row.name or value==row.key then + local id=c.ids:read('pokemon',key) + if found and found~=id then return nil,'ambiguous imported species name'end + found=id + end + end + return found +end +function P.national(value)local row=species(value);return row and row.dex end +function P.speciesFromNational(number) + local c=context();if not c then return nil end + local found + for key,row in pairs(c.data.pokemon)do if row.dex==number and not row.unused then + local id=c.ids:read('pokemon',key) + if found and found~=id then return nil,'ambiguous imported National Dex entry'end + found=id + end end + return found +end +function P.types(value) + local row,_,c=species(value);if not row then return nil end + local types=row.types or{row.type1,row.type2} + return{c.ids:read('types',types[1]),c.ids:read('types',types[2]or types[1])} +end +function P.stats(value) + local row=species(value);local s=row and row.baseStats;if not s then return nil end + return{hp=s.hp,atk=s.attack,def=s.defense,spe=s.speed,spa=s.spatk or s.special,spd=s.spdef or s.special} +end +function P.speciesMeta(value) + local row=species(value);if not row then return nil end + return{genderRatio=row.genderRatio,eggGroups=copy(row.eggGroups),eggCycles=row.eggCycles, + catchRate=row.catchRate,baseFriendship=row.friendship,growthRate=row.growthRate, + bodyColor=row.bodyColor,safariFleeRate=row.safariFleeRate} +end +function P.dexEntry(value) + if type(value)=='table'then value=P.speciesOf(value)end + local row=species(value);if not row then return nil end + -- Native imports store metres/kilograms; this source contract uses the + -- cartridge's decimetres/hectograms. Never pass metres as sprite height. + return{height=type(row.height)=='number'and math.floor(row.height*10+.5)or nil, + weight=type(row.weight)=='number'and math.floor(row.weight*10+.5)or nil, + category=row.category,description=type(row.dexEntry)=='string'and row.dexEntry or nil} +end +function P.abilities(value) + local row,_,c=species(value);if not row then return nil end + local a=row.abilities or{};return{c.ids:read('abilities',a[1]),c.ids:read('abilities',a[2])} +end +function P.abilityId(value,personality) + local a=P.abilities(value);if not a then return nil end + return a[(tonumber(personality)or 0)%2+1]or a[1] +end +function P.gender(value,personality) + local row,key,c=species(value);if not row then return nil end + local gender=Pokemon.genderOf(c.data,{species=key,personality=personality}) + return({male='M',female='F',none='U',unknown='U'})[gender] +end +function P.isShiny(mon) + if type(mon)~='table'then return false end + local c=context();local key=c and c.ids:native('pokemon',mon.species) + local native={};for k,v in pairs(mon)do native[k]=v end + -- Typed live views intentionally store their fields behind __index. + for _,k in ipairs{'species','personality','otId','otSecretId','trainerId','isShiny','shiny','dvs','ivs','level'}do + native[k]=mon[k] + end + if key then native.species=key end + return Pokemon.isShiny(native) +end +function P.isEgg(mon)return type(mon)=='table'and(mon.isEgg==true or mon.egg==true)or false end +function P.displayName(mon) + if type(mon)~='table'then return nil end + if mon.nickname and mon.nickname~=''then return mon.nickname end + return P.name(P.speciesOf(mon)) +end +function P.moveName(value) + local c=context();local key=c and c.ids:native('moves',value) + return key and c.data.moves[key].name +end +function P.movePp(value) + local c=context();local key=c and c.ids:native('moves',value) + return key and c.data.moves[key].pp +end +function P.movesAtLevel(value,level) + local row,_,c=species(value);if not row then return nil end + local moves,pp,maximum={},{},{} + for i,key in ipairs(Pokemon.movesAtLevel(row,level))do + moves[i]=c.ids:read('moves',key);pp[i]=c.data.moves[key]and c.data.moves[key].pp;maximum[i]=pp[i] + end + return moves,pp,maximum +end +function P.movesLearnedAt(value,level) + local row,_,c=species(value);if not row then return nil end + local out={};for _,entry in ipairs(row.learnset or{})do + if entry.level==level then out[#out+1]=c.ids:read('moves',entry.move)end + end;return out +end +function P.moveIdAt(mon,slot) + local value=mon and mon.moves and mon.moves[slot] + if type(value)=='number'then return value end + local c=context();return c and type(value)=='table'and c.ids:read('moves',value.id)or nil +end +function P.moveSlotCount(mon)return mon and mon.moves and#mon.moves or 0 end +function P.knowsMove(mon,number) + for slot=1,P.moveSlotCount(mon)do if P.moveIdAt(mon,slot)==number then return true end end;return false +end +local function picture(side,value,form,shiny,personality) + local row,key,c=species(value);if not row then return nil,'imported native species unavailable'end + if form~=nil and form~=0 then return nil,'native alternate-form picture binding unavailable'end + local mon={species=key,personality=personality,isShiny=shiny==true} + local path=Sprites.path(c.data,key,side,{mon=mon,kind='battle'}) + if not path then return nil,'native species picture unavailable'end + local old=cache[path];if old then return old end + local ok,image=pcall(love.graphics.newImage,Assets.resolve(path)) + if not ok then return nil,tostring(image)end + local w,h=image:getDimensions() + local entry={image=image,w=w,h=h};cache[path]=entry;return entry +end +function P.frontPic(value,form,shiny,personality)return picture('front',value,form,shiny,personality)end +function P.backPic(value,form,shiny)return picture('back',value,form,shiny)end +P.frontSprite=P.frontPic +P.flushPicCaches=release +P.invalidate=release +local service={release=release,poll=function()if current then context()end end, + releaseOwner=function(_,who)if users[who]then users[who]=nil;if not next(users)then release()end end end} +P._service=service +require('src.core.game3.asset_stream').register(service) +Assets.register(release) +return P diff --git a/tools/battle-source-runtime/fixture.lua b/tools/battle-source-runtime/fixture.lua new file mode 100644 index 000000000..c3f08ff7a --- /dev/null +++ b/tools/battle-source-runtime/fixture.lua @@ -0,0 +1,47 @@ +-- Synthetic imported GBA records over the real native constructors. No +-- external pack, portable encounter API, player profile or ROM is required. +local F={} +function F.new(edition) + local T=require('tests.modkit') + require('src.core.GameVersion').set(edition) + local data=T.fixtures.fresh() + local row=require('src.mods.Merge').deepCopy(data.pokemon.FIXMON_A) + row.id,row.name,row.dex,row.index='CHARMANDER','CHARMANDER',4,4 + row.types,row.level1Moves,row.learnset,row.evolutions={'NORMAL'},{'MOVE_033'},{},{} + row.baseStats.spatk,row.baseStats.spdef=row.baseStats.special,row.baseStats.special + row.trueColor=true + data.pokemon.CHARMANDER=row + data.moves.MOVE_033={id='MOVE_033',index=33,name='TACKLE',power=35,type='NORMAL',accuracy=100, + accuracyRaw=255,pp=10,effect='NO_ADDITIONAL_EFFECT',effectChance=0,effectChanceRaw=0} + data.constants=data.constants or{} + data.constants.gen=3 + data.constants.speciesOrder={[4]='CHARMANDER'} + data.constants.moveOrder={[33]='MOVE_033'} + local game={data=data,mods={exports={}},save={party={},options={},player={name='PROBE',id=1000}, + flags={},inventory={},pokedex={seen={},caught={}},defeatedTrainers={},money=0}, + stack=setmetatable({},{__index=require('src.core.StateStack')}), + input={wasPressed=function()return false end,isDown=function()return false end}} + game.stack:init() + require('src.render.Font').load(data);require('src.battle.TypeChart').load(data) + local original=package.loaded['src.core.Game'] + local f={game=game,data=data,ids={TACKLE='MOVE_033'}} + function f.fresh(name) + assert(name=='TACKLE') + local player=require('src.pokemon.Pokemon').new(data,'CHARMANDER',30,function(lo)return lo or 0 end) + player.hp,player.stats.hp,player.moves=400,400,{{id='MOVE_033',pp=10}} + game.save.party={player} + local battle=require('src.battle.BattleState').newWild(game,'CHARMANDER',30) + battle.enemy.mon.hp,battle.enemy.mon.stats.hp=400,400 + battle.rng=function(lo)return lo or 0 end + return{battle=battle} + end + function f.close() + for _,name in ipairs{'src.core.game3.battle','src.core.game3.pokemon','src.core.game3.mon_anim'}do + local module=package.loaded[name] + if module and module._service and module._service.release then module._service.release()end + end + if package.loaded['src.core.Game']==game then package.loaded['src.core.Game']=original end + end + return f +end +return F diff --git a/tools/battle-source-runtime/host/conf.lua b/tools/battle-source-runtime/host/conf.lua new file mode 100644 index 000000000..7aea1d29a --- /dev/null +++ b/tools/battle-source-runtime/host/conf.lua @@ -0,0 +1,6 @@ +function love.conf(t) + t.identity=assert(os.getenv('POKEPORT_IDENTITY')) + t.window.width,t.window.height=480,320;t.window.vsync=0;t.window.highdpi=false + t.modules.audio,t.modules.joystick,t.modules.physics=false,false,false + t.graphics=t.graphics or{};t.graphics.renderers={assert(os.getenv('POKEPORT_GRAPHICS_BACKEND'))} +end diff --git a/tools/battle-source-runtime/host/main.lua b/tools/battle-source-runtime/host/main.lua new file mode 100644 index 000000000..f403db56c --- /dev/null +++ b/tools/battle-source-runtime/host/main.lua @@ -0,0 +1,67 @@ +io.stdout:setvbuf('no') +local root=assert(os.getenv('BATTLE_SOURCE_ENGINE')) +package.path=root..'/?.lua;'..root..'/?/init.lua;'..package.path +function love.errorhandler(err)io.stderr:write(debug.traceback(tostring(err),2)..'\n');return function()return 1 end end +function love.load() + local T=require('tests.harness').suite('native battle source GPU') + local g=love.graphics + local Battle=require('src.core.game3.battle') + local Ui=require('src.core.game3.battle.ui') + local Bg=require('src.core.game3.battle.bg') + local baseDraw,uiDraw,bgDraw=Battle.draw,Ui.draw,Bg.draw + local renderer,version,vendor,device=g.getRendererInfo() + local backend=assert(os.getenv('POKEPORT_GRAPHICS_BACKEND')) + T.check(renderer:lower():find(backend,1,true)~=nil,'requested actual GPU backend') + T.eq(love.filesystem.getIdentity(),os.getenv('POKEPORT_IDENTITY'),'owned QA profile') + local readbacks,comparisons=0,0 + local function read(canvas) + local data=g.readbackTexture and g.readbackTexture(canvas)or canvas:newImageData() + local bytes=data:getString();data:release();readbacks=readbacks+1;return bytes + end + local function frame(canvas,fn) + g.push('all');g.setCanvas(canvas);g.origin();g.setShader();g.setScissor();g.clear(0,0,0,0) + g.setColor(1,1,1,1);fn();g.pop();return read(canvas) + end + local function same(a,b,label)comparisons=comparisons+1;T.eq(a,b,label)end + 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 + game.data.constants=game.data.constants or{} + game.data.constants.speciesOrder={[4]='CHARMANDER'} + game.data.constants.moveOrder={[33]=f.ids.TACKLE} + local b=f.fresh('TACKLE').battle + game.stack:push(b);b.phase='menu';b.queue={};b.current=nil + b.showPlayerBack,b.showEnemyTrainer=false,false;b.frlgIntro=nil;b.introSlide=0 + local canvas=g.newCanvas(240,160,{format='rgba8',dpiscale=1,msaa=0}) + local baseline=frame(canvas,function()b:_drawNative()end) + local sourceCalls,uiCalls,backgroundCalls=0,0,0 + Battle.draw=function(view,w,h)sourceCalls=sourceCalls+1;T.eq(w,240);T.eq(h,160);return baseDraw(view,w,h)end + Ui.draw=function(w,h)uiCalls=uiCalls+1;return uiDraw(w,h)end + Bg.draw=function(...)backgroundCalls=backgroundCalls+1;return bgDraw(...)end + same(frame(canvas,function()b:draw()end),baseline,'wrapped native battle pixels match native rendering '..edition) + T.eq(sourceCalls,1);T.eq(uiCalls,1);T.eq(backgroundCalls,1) + Bg.draw=function() + g.setColor(.4,.1,.7,1);g.rectangle('fill',0,0,240,160);return true + end + local replaced=frame(canvas,function()b:draw()end) + T.check(replaced~=baseline,'background replacement reaches actual GPU pixels') + -- Native text/menu pass still owns the opaque bottom rows. + same(replaced:sub(240*128*4+1),baseline:sub(240*128*4+1),'native command UI survives background replacement '..edition) + local before=Battle.getState();T.eq(before,Battle.getState()) + T.eq(before.player.mon.hp,b.player.mon.hp) + local bound=g.getCanvas();local depth=g.getStackDepth and g.getStackDepth() + Battle.draw=function()g.push();g.setCanvas(canvas);error('owned GPU callback failure')end + local ok,why=pcall(b.draw,b);T.eq(ok,false);T.check(tostring(why):find('owned GPU callback failure',1,true)~=nil) + T.eq(g.getCanvas(),bound);if depth then T.eq(g.getStackDepth(),depth)end + Battle.draw,Ui.draw,Bg.draw=baseDraw,uiDraw,bgDraw + game.stack:pop();Battle.reset();canvas:release();f.close();require('src.core.Game3').release() + end + T.finish() + local report={passed=true,checks=T.checks,readbacks=readbacks,comparisons=comparisons, + backend=backend,renderer=renderer,version=version,vendor=vendor,device=device, + playerProfilesUsed=false,gameplayProven=false,originalModLoaded=false, + scope='Actual native BattleState and Gen3Battle draws over synthetic imported Pokemon; five GBA editions. Source/UI/background wrappers and GPU error restoration; no original-mod boot or gameplay claim.'} + local file=assert(io.open(assert(os.getenv('BATTLE_SOURCE_REPORT')),'w')) + file:write(require('src.link.Json').encode(report));file:close() + print('NATIVE_BATTLE_SOURCE_GPU_PASS',readbacks,comparisons);love.event.quit(0) +end From 5a36c15900f8d7cfea3a14c78a259c56143fb4f9 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 11:31:28 +0100 Subject: [PATCH 2/9] Include standalone native boundary checks and contribution notice --- LICENSE-ROXAS2712.md | 88 ++++++++++++ tests/engine/native_battle_session.lua | 138 +++++++++++++++++++ tests/engine/native_mon_animation_source.lua | 47 +++++++ tests/engine/native_pokemon_source.lua | 55 ++++++++ 4 files changed, 328 insertions(+) create mode 100644 LICENSE-ROXAS2712.md create mode 100644 tests/engine/native_battle_session.lua create mode 100644 tests/engine/native_mon_animation_source.lua create mode 100644 tests/engine/native_pokemon_source.lua diff --git a/LICENSE-ROXAS2712.md b/LICENSE-ROXAS2712.md new file mode 100644 index 000000000..8931eb41f --- /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, third-party engine 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/tests/engine/native_battle_session.lua b/tests/engine/native_battle_session.lua new file mode 100644 index 000000000..d3d87fa58 --- /dev/null +++ b/tests/engine/native_battle_session.lua @@ -0,0 +1,138 @@ +-- 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 Background=require('src.core.game3.battle.bg') +local Ui=require('src.core.game3.battle.ui') +local baseUpdate,baseDraw=Battle.update,Battle.draw +local originalGame=package.loaded['src.core.Game'] +local retained,weak +T.eq(Battle.isActive(),false) +T.eq(baseUpdate(),nil,'a source call cannot manufacture a native tick') +T.eq(baseDraw(),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 + game.data.constants=game.data.constants or{} + game.data.constants.speciesOrder={[4]='CHARMANDER'} + game.data.constants.moveOrder={[33]=f.ids.TACKLE} + game.data.constants.typeOrder={'NORMAL'} + game.data.constants.abilityOrder={'NONE','PROBE_ABILITY'} + game.data.constants.abilities={NONE={index=0},PROBE_ABILITY={index=1}} + game.data.constants.types={NORMAL={index=0}} + game.data.constants.itemOrder={[7]='PROBE_ITEM'} + game.data.items.PROBE_ITEM={index=7} + local b=f.fresh('TACKLE').battle + b.player.ability='PROBE_ABILITY';b.player.mon.item='PROBE_ITEM' + local starts=0;local start=Battle.start + Battle.start=function(opts)starts=starts+1;T.eq(opts.wild,true);return start(opts)end + game.stack:push(b);Battle.start=start;T.eq(starts,1,'real native entry notifies source start once') + local st=assert(Battle.getState()) + T.eq(Battle.isActive(),true) + T.eq(baseDraw({data=game.data,save=game.save,stack=game.stack}),false,'foreign capture cannot retire the active view') + T.eq(Ui.bindState(nil),false) + T.eq(st,Battle.getState(),'one identity for the native admission') + T.eq(st.player.id,0);T.eq(st.enemy.id,1) + T.eq(st.player.side,'player');T.eq(st.enemy.side,'enemy') + T.eq(st.player.mon.species,4);T.eq(st.player.mon.moves[1],33) + T.eq(st.player.mon.item,7);T.eq(st.player.ability,1);T.eq(st.player.type1,0) + T.eq(st.player.mon.maxHp,400);T.eq(st.party[1],st.player.mon) + T.neq(st.player.mon,b.player.mon);T.neq(st, b) + st.player.mon.hp=399;T.eq(b.player.mon.hp,399) + st.player.mon.status=128;T.eq(b.player.mon.status,'PSN');T.eq(b.player.mon.toxic,true) + st.player.mon.status=0;T.eq(b.player.mon.status,nil) + T.raises(function()st.player.mon.hp=-1 end,'invalid native HP') + T.raises(function()st.weather='SUN'end,'not directly writable') + T.raises(function()st.player={}end,'not directly writable') + st.paperPresentation={enabled=true};T.eq(st.paperPresentation.enabled,true) + local ticks=0;local tickFx=b.tickFx + b.tickFx=function(self,...)ticks=ticks+1;return tickFx(self,...)end + b.phase='menu';b.queue={};b.current=nil + local pp=st.player.mon.pp + pp[1]=9 + Battle.update=function(dt,view) + T.eq(require('src.core.Game3').native(view),game) + baseUpdate(dt,view);return baseUpdate(dt,view) + end + local pump=Ui.pump + Ui.pump=function()pump();return pump()end + b:update(1/60);Ui.pump=pump + T.eq(pump(),nil,'UI calls outside the native input phase cannot tick combat') + T.eq(Ui.bindState(st),true);T.eq(Ui.bindState({}),false) + T.eq(Ui.statusHUDVisible(),b:statusHUDVisible()) + T.eq(Ui.bottomUIVisible(st),b:bottomUIVisible()) + T.eq(Ui.moveGridNavigation(),b:moveGridNavigation()) + T.eq(ticks,1,'two wrapper base calls consume the native tick once') + T.eq(b.player.mon.moves[1].pp,9,'validated source PP edit reaches the actual native move') + T.eq(baseUpdate(1/60),nil);T.eq(ticks,1,'direct source update outside native scope has no clock') + local overlay={};game.stack:push(overlay) + T.eq(Battle.getState(),st,'covered combat remains observable') + T.eq(Battle.abort('run'),false,'abort cannot pop a foreign overlay') + T.eq(b:update(1/60),false);T.eq(ticks,1) + game.stack:pop() + Battle.update=baseUpdate + T.eq(Battle.statWindowPhase(),false) + game.stack:push(b.StatBox.new(game,b.player.mon)) + T.eq(Battle.statWindowPhase(),true,'the actual native level-up window is observed') + game.stack:pop() + local Host=require('src.mods.Runtime');Host.currentMod='battle_owner' + T.eq(Battle.getState(),st);Host.currentMod=nil + require('src.core.game3.asset_stream').cancelOwner('other_owner');T.eq(Battle.getState(),st) + require('src.core.game3.asset_stream').cancelOwner('battle_owner') + T.raises(function()st.player.mon.hp=1 end,'retired') + T.eq(Battle.isActive(),true,'mod teardown retires views while native combat continues') + local fresh=Battle.getState();T.neq(fresh,st);st=fresh + local draws,fields=0,0 + local NativeDraw=require('src.battle.Gen3Battle');local draw,field=NativeDraw.draw,NativeDraw.drawField + NativeDraw.draw=function(screen)draws=draws+1;screen:drawBattleField()end + NativeDraw.drawField=function()fields=fields+1 end + local uiDraw=Ui.draw;local uiDraws=0 + Ui.draw=function(w,h)uiDraws=uiDraws+1;uiDraw(w,h);return uiDraw(w,h)end + Battle.draw=function(view,w,h)T.eq(w,240);T.eq(h,160);baseDraw(view,w,h);return baseDraw(view,w,h)end + b:draw();T.eq(uiDraws,1);T.eq(draws,1);T.eq(fields,1,'base draw consumes the field and native UI once') + local bgDraw=Background.draw + Background.draw=function()return true end + b:draw();T.eq(draws,2);T.eq(fields,1,'background replacement leaves the native UI draw admitted') + Background.draw=bgDraw + Battle.draw=function()love.graphics.push();error('battle presentation error')end + local push,pop=love.graphics.push,love.graphics.pop + T.raises(function()b:draw()end,'battle presentation error') + T.eq(love.graphics.push,push);T.eq(love.graphics.pop,pop) + Battle.draw=baseDraw;Ui.draw=uiDraw;NativeDraw.draw,NativeDraw.drawField=draw,field + local bad=st.player.mon.moves;bad[1]=99 + T.raises(function()b:update(1/60)end,'source move replacement') + bad[1]=33;b:update(1/60) + local rng=st.rng + local nativeHp=b.player.mon.hp + local callbacks=0;b.onFinish=function(result)T.eq(result,'lose');callbacks=callbacks+1 end + b.result='lose';b:finish() + T.eq(Battle.isActive(),false);T.eq(Battle.getState(),st) + T.eq(st.result,'lose');T.eq(st.over,true);T.eq(callbacks,1) + T.eq(st.player.mon.hp,nativeHp,'retired state has a detached final observation') + T.raises(function()st.player.mon.hp=10 end,'retired') + T.raises(function()rng(1,2)end,'retired') + T.eq(Battle.abort('run'),false,'completion cannot pop a subsequent native screen') + T.eq(Battle.reset(),true);T.eq(Battle.getState(),nil) + retained={st,st.player,st.player.mon,rng} + weak=setmetatable({game,game.save,b},{__mode='v'}) + f.close();require('src.core.Game3').release() +end +package.loaded['src.core.Game']=originalGame +for _=1,4 do collectgarbage('collect')end +T.eq(weak[3],nil,'retained completed views do not keep the native controller alive') +T.eq(retained[1].result,'lose');T.raises(function()retained[4](1,2)end,'retired') +-- Isolate this view's ownership from the host's process-wide Game3 facade cache. +local function retiredViews() + local data={pokemon={},moves={},constants={}} + local game={data=data,save={party={}}} + local screen={kind='wild',result='run',rng=function(lo)return lo end} + local weak=setmetatable({game,game.save,data,screen},{__mode='v'}) + local e=require('src.compat.gen1.BattleView').new(game,screen,function()return true end) + local view,rng=e.view,e.rng;e:retire();return view,rng,weak +end +local view,rng,refs=retiredViews() +for _=1,4 do collectgarbage('collect')end +for i=1,4 do T.eq(refs[i],nil,'retired battle views release native owner '..i)end +T.eq(view.result,'run');T.raises(function()rng(1,2)end,'retired') +T.finish('native GBA battle admission, numeric views, once-only phases and teardown') diff --git a/tests/engine/native_mon_animation_source.lua b/tests/engine/native_mon_animation_source.lua new file mode 100644 index 000000000..787eca455 --- /dev/null +++ b/tests/engine/native_mon_animation_source.lua @@ -0,0 +1,47 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Source=require('src.core.game3.mon_anim') +local Native=require('src.pokemon.MonAnim') +local Pictures=require('src.pokemon.PicAnim') +local Host=require('src.mods.Runtime') +local original=package.loaded['src.core.Game'] +T.eq(Source.enabled(),false) +T.eq(Source.newSprite(4),nil) +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 + game.data.constants=game.data.constants or{} + game.data.constants.speciesOrder={[4]='CHARMANDER'} + game.data.constants.gen3MonAnim={anims={CHARMANDER={anim='H_SHAKE',delay=3}}} + local rec={sheet='unused-synthetic-strip.png',width=64,height=64,count=2, + play={{frame=1,dur=2},{frame=2,dur=3}},idle={{frame=0,dur=1}}} + game.data.pokemon.CHARMANDER.picAnim=rec + T.eq(Source.enabled(),true) + Host.currentMod='motion_owner';local sprite=assert(Source.newSprite(4));Host.currentMod=nil + T.eq(Source.summary(sprite,4,false),sprite) + local motion=assert(Native.new(game.data,'CHARMANDER'));motion:restart() + local frames=assert(Pictures.new(game.data,'CHARMANDER'));frames:start() + for tick=0,70 do + local native=motion:transform();local source=Source.transform(sprite) + T.eq(source.x2,native.dx);T.eq(source.y2,native.dy) + T.eq(source.sx,native.sx);T.eq(source.sy,native.sy);T.eq(source.rotation,native.rot) + T.eq(source.frame,frames:frame());T.eq(sprite.frames,tick) + Source.step(sprite);motion:update(1/60);frames:update(1/60) + end + T.eq(Source.done(sprite),true) + T.eq(Source.framePic(4,1,true),nil,'a missing shiny strip cannot flash the normal palette') + T.eq(Source.summary(sprite,4,true),sprite) + require('src.core.game3.asset_stream').cancelOwner('other_owner');T.eq(Source.step(sprite),true) + require('src.core.game3.asset_stream').cancelOwner('motion_owner');T.eq(Source.step(sprite),false) + T.eq(Source.done(sprite),true) + local swapped=assert(Source.newSprite(4));Source.summary(swapped,4) + require('src.core.GameVersion').set(edition=='firered'and'emerald'or'firered') + T.eq(Source.step(swapped),false,'edition replacement retires the presentation lease') + require('src.core.GameVersion').set(edition) + local retired=assert(Source.newSprite(4));Source.summary(retired,4) + game.data={pokemon={}};T.eq(Source.step(retired),false,'dataset replacement retires the presentation lease') + f.close() +end +Source._service.release();package.loaded['src.core.Game']=original +T.finish('native motion and frame-player presentation adapter; native timing only') diff --git a/tests/engine/native_pokemon_source.lua b/tests/engine/native_pokemon_source.lua new file mode 100644 index 000000000..66fc3b836 --- /dev/null +++ b/tests/engine/native_pokemon_source.lua @@ -0,0 +1,55 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local P=require('src.core.game3.pokemon') +local original=package.loaded['src.core.Game'] +local Host=require('src.mods.Runtime') +T.eq(P.ready(),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 data=game.data + data.constants=data.constants or{} + data.constants.speciesOrder={[4]='CHARMANDER',[277]='SPARSE_SPECIES'} + data.pokemon.SPARSE_SPECIES={index=277,dex=252,name='SPARSE',id='SPARSE_SPECIES'} + data.constants.typeOrder={'NORMAL'};data.constants.types={NORMAL={index=0}} + data.constants.abilityOrder={'NONE','PROBE'};data.constants.abilities={NONE={index=0},PROBE={index=1}} + data.constants.moveOrder={[33]=f.ids.TACKLE} + local row=data.pokemon.CHARMANDER + row.height,row.weight,row.genderRatio,row.abilities=.7,6.9,127,{'PROBE','PROBE'} + row.level1Moves={f.ids.TACKLE};row.learnset={{level=7,move=f.ids.TACKLE}} + T.eq(P.ready(),true);T.eq(P.speciesOf{species='CHARMANDER'},4) + T.eq(P.speciesOf{species=277},277);T.eq(P.national(277),252) + T.eq(P.speciesFromNational(252),277,'cartridge slots and National Dex are distinct') + T.eq(P.speciesFromName('CHARMANDER'),4);T.eq(P.keyName(4),'CHARMANDER') + T.eq(P.name(4),'CHARMANDER');T.eq(P.name(8888),nil) + T.eq(P.types(4)[1],0);T.eq(P.types(4)[2],0) + T.eq(P.stats(4).atk,row.baseStats.attack) + local dex=P.dexEntry(4);T.eq(dex.height,7,'metres become source decimetres');T.eq(dex.weight,69) + local meta=P.speciesMeta(4);meta.genderRatio=0;T.eq(row.genderRatio,127,'observations do not lend native records') + T.eq(P.gender(4,0),'F');T.eq(P.gender(4,255),'M') + T.eq(P.abilities(4)[1],1);T.eq(P.abilityId(4,2),1) + T.eq(P.isShiny{species=4,personality=0,otId=0,otSecretId=0},true) + T.eq(P.isShiny(setmetatable({},{__index={species=4,personality=0,otId=0,otSecretId=0}})),true,'typed mon views use their declared identity fields') + T.eq(P.isShiny{species=4,personality=12,otId=0,otSecretId=0},false) + T.eq(P.isEgg{isEgg=true},true);T.eq(P.isEgg{species=4},false) + T.eq(P.displayName{species=4,nickname='NAMED'},'NAMED') + T.eq(P.displayName{species=4},'CHARMANDER') + local moves,pp,max=P.movesAtLevel(4,10) + T.eq(moves[1],33);T.eq(pp[1],10);T.eq(max[1],10) + T.eq(P.moveName(33),'TACKLE');T.eq(P.movePp(33),10) + T.eq(P.movesLearnedAt(4,7)[1],33) + T.eq(P.moveIdAt({moves={33}},1),33);T.eq(P.moveIdAt({moves={{id=f.ids.TACKLE}}},1),33) + T.eq(P.knowsMove({moves={33}},33),true);T.eq(P.knowsMove({moves={33}},34),false) + Host.currentMod='sprite_owner' + local image=assert(P.frontPic(4)) + T.eq(P.frontPic(4),image,'native source picture is cached within its dataset') + T.check(image.image~=nil and image.w>0 and image.h>0) + T.eq(P.frontPic(4,2),nil,'unsupported forms cannot silently show the base sprite') + require('src.core.game3.asset_stream').cancelOwner('other_owner') + T.eq(P.frontPic(4),image) + P.invalidate();T.neq(P.frontPic(4),image,'owned picture invalidation never reuses its released image') + Host.currentMod=nil;P.invalidate();f.close() +end +package.loaded['src.core.Game']=original +T.finish('native source Pokemon identity, metadata units and picture ownership') From 679abfdab2e4766be3a918b816654d4a7c506966 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 11:36:37 +0100 Subject: [PATCH 3/9] Report unsupported older-host UI queries explicitly --- .../native-foundation/gba-battle-source-boundary.md | 4 ++++ src/core/game3/battle/ui.lua | 11 +++++++---- tests/engine/native_battle_session.lua | 7 ++++--- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/qa/native-foundation/gba-battle-source-boundary.md b/docs/qa/native-foundation/gba-battle-source-boundary.md index 5575fba5e..b5bd39302 100644 --- a/docs/qa/native-foundation/gba-battle-source-boundary.md +++ b/docs/qa/native-foundation/gba-battle-source-boundary.md @@ -27,6 +27,10 @@ This is an incremental presentation and observation bridge over the existing timing is the existing native implementation, not a proof of exact source sprite callbacks. They cannot consume combat commands. +Older native controllers without HUD visibility or move-grid query methods +return an explicit unavailable reason for those observations. This does not +advertise the complete presentation protocol on that host. + ## Qualification The focused engine suites cover all five registered GBA editions using diff --git a/src/core/game3/battle/ui.lua b/src/core/game3/battle/ui.lua index 23532c682..ecef91926 100644 --- a/src/core/game3/battle/ui.lua +++ b/src/core/game3/battle/ui.lua @@ -14,14 +14,17 @@ local function call(name,...) end return facade._withCallbackOwner(who,Ui[name],...) end -function Ui.statusHUDVisible() - local screen=Battle._screen();return screen and screen:statusHUDVisible()or false +local function query(method,state) + local screen=Battle._screen(state);if not screen then return false end + if type(screen[method])~='function'then return nil,'native UI query unavailable: '..method end + return screen[method](screen) end +function Ui.statusHUDVisible()return query('statusHUDVisible')end function Ui.bottomUIVisible(state) - local screen=Battle._screen(state);return screen and screen:bottomUIVisible()or false + return query('bottomUIVisible',state) end function Ui.moveGridNavigation() - local screen=Battle._screen();return screen and screen:moveGridNavigation()or false + return query('moveGridNavigation') end function Ui.bindState(state) if state==nil then diff --git a/tests/engine/native_battle_session.lua b/tests/engine/native_battle_session.lua index d3d87fa58..6b5ccf7c8 100644 --- a/tests/engine/native_battle_session.lua +++ b/tests/engine/native_battle_session.lua @@ -60,9 +60,10 @@ for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do b:update(1/60);Ui.pump=pump T.eq(pump(),nil,'UI calls outside the native input phase cannot tick combat') T.eq(Ui.bindState(st),true);T.eq(Ui.bindState({}),false) - T.eq(Ui.statusHUDVisible(),b:statusHUDVisible()) - T.eq(Ui.bottomUIVisible(st),b:bottomUIVisible()) - T.eq(Ui.moveGridNavigation(),b:moveGridNavigation()) + for _,method in ipairs{'statusHUDVisible','bottomUIVisible','moveGridNavigation'}do + if type(b[method])=='function'then T.eq(Ui[method](),b[method](b)) + else local value,why=Ui[method]();T.eq(value,nil);T.check(why:find('native UI query unavailable',1,true)~=nil)end + end T.eq(ticks,1,'two wrapper base calls consume the native tick once') T.eq(b.player.mon.moves[1].pp,9,'validated source PP edit reaches the actual native move') T.eq(baseUpdate(1/60),nil);T.eq(ticks,1,'direct source update outside native scope has no clock') From 3de60c590fec58a318eaf0f1a63c4edb1d233b22 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 11:52:57 +0100 Subject: [PATCH 4/9] Guard native battle admissions and share frame capture ownership --- .../gba-battle-source-boundary.md | 10 ++ src/compat/gen1/BattleIds.lua | 16 +++ src/compat/gen1/BattleRuntime.lua | 8 +- src/compat/gen1/BattleView.lua | 20 +++- src/core/game3/battle.lua | 56 ++++++++--- src/core/game3/battle/ui.lua | 4 +- src/core/game3/mon_anim.lua | 8 +- src/core/game3/pokemon.lua | 2 +- src/mods/ModSeams.lua | 10 ++ tests/engine/native_battle_ownership.lua | 1 + .../engine/native_battle_source_lifecycle.lua | 1 + tools/battle-source-runtime/host/main.lua | 28 ++++++ .../battle-source-runtime/lifecycle-check.lua | 56 +++++++++++ .../battle-source-runtime/ownership-check.lua | 99 +++++++++++++++++++ 14 files changed, 295 insertions(+), 24 deletions(-) create mode 100644 tests/engine/native_battle_ownership.lua create mode 100644 tests/engine/native_battle_source_lifecycle.lua create mode 100644 tools/battle-source-runtime/lifecycle-check.lua create mode 100644 tools/battle-source-runtime/ownership-check.lua diff --git a/docs/qa/native-foundation/gba-battle-source-boundary.md b/docs/qa/native-foundation/gba-battle-source-boundary.md index b5bd39302..24df2ccfc 100644 --- a/docs/qa/native-foundation/gba-battle-source-boundary.md +++ b/docs/qa/native-foundation/gba-battle-source-boundary.md @@ -13,6 +13,16 @@ This is an incremental presentation and observation bridge over the existing - Native start notifications, update, UI pump, whole-screen drawing and the background pass have independent scopes. Repeated base calls consume each native body once. Calls outside an update scope cannot tick combat. +- Every queued native update/draw rechecks the original admission after source + hooks, including the inner UI/background hooks. Save, dataset, loader, stack, + edition and imported catalogue replacement retire the old binding; restoring + an earlier pointer cannot revive that binding. Native method bodies execute + outside a source callback owner. +- Explicit battle/UI captures suppress the repeated native screen draw within + the same renderer frame. Capturing never advances combat. +- LuaJIT `pairs`/`next` expose typed battle fields, preserving nested typed + identity. Iterator registry values are weak, so an unused observation cannot + retain its own weak key through a closure. - Covered battles remain observable, but cannot update or pop their cover. Mod retirement invalidates its source views without cancelling native combat. Completed views keep detached observations and reject further native writes. diff --git a/src/compat/gen1/BattleIds.lua b/src/compat/gen1/BattleIds.lua index f2cb29d1b..cbc8b08c7 100644 --- a/src/compat/gen1/BattleIds.lua +++ b/src/compat/gen1/BattleIds.lua @@ -6,6 +6,19 @@ local schema={pokemon={'speciesOrder','pokemon'},moves={'moveOrder','moves'},ite abilities={'abilityOrder','abilities',1},types={'typeOrder','types',1},trainers={'trainerOrder','trainers'}} function M.new(data) local indexes={} + local constants=data.constants + local catalogues={} + for kind,spec in pairs(schema)do + catalogues[kind]={order=constants and constants[spec[1]],rows=data[spec[2]]or constants and constants[spec[2]]} + end + local function valid() + if data.constants~=constants then return false end + for kind,c in pairs(catalogues)do + local spec=schema[kind] + if (constants and constants[spec[1]])~=c.order or(data[spec[2]]or constants and constants[spec[2]])~=c.rows then return false end + end + return true + end local function index(kind) if indexes[kind]then return indexes[kind]end local spec=assert(schema[kind],'unknown battle identifier kind') @@ -23,13 +36,16 @@ function M.new(data) local result={forward=forward,reverse=reverse,rows=rows};indexes[kind]=result;return result end return { + valid=valid, read=function(_,kind,key) + if not valid()then return nil end if key==nil or key==false then return(kind=='items'or kind=='abilities')and 0 or nil end local i=index(kind);local n=i.reverse[key] if n~=nil then return n or nil end if type(key)=='number'and i.rows[key]then return key end end, native=function(_,kind,value) + if not valid()then return nil end if value==nil or value==0 and(kind=='items'or kind=='abilities')then return nil end local i=index(kind) if type(value)=='number'then return i.forward[value]or nil end diff --git a/src/compat/gen1/BattleRuntime.lua b/src/compat/gen1/BattleRuntime.lua index 94701a9c7..4ef2fc296 100644 --- a/src/compat/gen1/BattleRuntime.lua +++ b/src/compat/gen1/BattleRuntime.lua @@ -4,6 +4,7 @@ local Native=require('src.compat.gen1.NativeSession') local Version=require('src.core.GameVersion') local Views=require('src.compat.gen1.BattleView') +local Ids=require('src.compat.gen1.BattleIds') local R={};R.__index=R local function owner() local host=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] @@ -11,7 +12,7 @@ local function owner() end local function cohort(c) return c.game.data==c.data and c.game.save==c.save and c.game.mods==c.loader - and c.game.stack==c.stack and Version.get()==c.edition + and c.game.stack==c.stack and Version.get()==c.edition and c.ids:valid() end local function member(c) if c.retired or not cohort(c)then return false end @@ -29,6 +30,7 @@ function R:retire(c,completed) self.lastSession=setmetatable({c.game,c.data,c.save,c.stack,c.loader},{__mode='v'}) self.lastEdition=c.edition end + c.game,c.screen,c.data,c.save,c.stack,c.loader,c.ids=nil,nil,nil,nil,nil,nil,nil end function R:bind(screen) local game=screen and Native.game(screen.game) @@ -36,10 +38,10 @@ function R:bind(screen) local c=self.records[screen] if c and member(c)then local who=owner();if who then c.users[who]=true end;return c end if c then self:retire(c,false)end - c={screen=screen,game=game,data=game.data,save=game.save,stack=game.stack,loader=game.mods,edition=Version.get(),users={}} + c={screen=screen,game=game,data=game.data,save=game.save,stack=game.stack,loader=game.mods,edition=Version.get(),users={},ids=Ids.new(game.data)} if not member(c)then return end if self.current and self.current~=c then self:retire(self.current,false)end - c.views=Views.new(game,screen,function()return member(c)end) + c.views=Views.new(game,screen,function()return member(c)end,c.ids) local who=owner();if who then c.users[who]=true end self.records[screen]=c;self.current=c;self.last=nil return c diff --git a/src/compat/gen1/BattleView.lua b/src/compat/gen1/BattleView.lua index 526bfe393..949c55d68 100644 --- a/src/compat/gen1/BattleView.lua +++ b/src/compat/gen1/BattleView.lua @@ -2,6 +2,7 @@ -- Live, typed source observations over one admitted native battle. Views own -- their arrays; they never lend the native controller/save under source names. local Ids=require('src.compat.gen1.BattleIds') +local Iteration=require('src.compat.gen1.TableIteration') local M={} local statNames={attack='attack',defense='defense',speed='speed',spAtk='spatk',spDef='spdef',hp='hp'} local statusBits={PSN=8,BRN=16,FRZ=32,PAR=64,TOX=128} @@ -19,10 +20,10 @@ local stateKeys={'player','enemy','battlers','party','playerParty','enemyParty', 'link','safari','tutorial','oldManTutorial','pokedude','firstBattle','ghostBattle','kinds','rng'} local reserved={} for _,key in ipairs(stateKeys)do reserved[key]=true end -function M.new(game,screen,valid) +function M.new(game,screen,valid,identifiers) local e={context={game=game,screen=screen,data=game.data},entries=setmetatable({},{__mode='k'}), caches={},arrays={},valid=valid,retired=false} - local ids=Ids.new(game.data) + local ids=identifiers or Ids.new(game.data) local function at(pos) local s=e.context and e.context.screen;if not s then return end if pos==0 then return s.player elseif pos==1 then return s.enemy end @@ -67,6 +68,20 @@ function M.new(game,screen,valid) rawset(t,key,value) else error('native battle '..kind..' '..tostring(key)..' is not directly writable',2)end end}) + -- LuaJIT does not consult __pairs. Keep registry values weak so its + -- weak key cannot retain itself through this environment's public view. + local observation=setmetatable({environment=e,entry=d},{__mode='v'}) + Iteration.register(view,function(spec,source) + local environment,entry=spec.environment,spec.entry + assert(environment and entry,'native battle view retired') + local out={};for key,value in Iteration.rawPairs(source)do out[key]=value end + if entry.snapshot then for key,value in pairs(entry.snapshot)do out[key]=value end;return out end + assert(environment.context and environment.valid(),'native battle view retired') + local keys=entry.kind=='state'and stateKeys or entry.kind=='battler'and battlerKeys or entry.kind=='mon'and monKeys + or{'attack','defense','speed','spAtk','spDef','accuracy','evasion'} + for _,key in ipairs(keys)do out[key]=environment.read(entry,key)end + return out + end,observation) return view end local function partyIndex(party,mon)for i,value in ipairs(party or{})do if value==mon then return i end end end @@ -159,6 +174,7 @@ function M.new(game,screen,valid) if key=='rng'then return e.rng end end end + e.read=read e.view=proxy('state',screen) e.rng=function(...)assert(e.context and e.valid(),'native battle RNG retired');return e.context.screen.rng(...)end function e:flush() diff --git a/src/core/game3/battle.lua b/src/core/game3/battle.lua index 12f549c0b..f1e4b2752 100644 --- a/src/core/game3/battle.lua +++ b/src/core/game3/battle.lua @@ -5,6 +5,12 @@ local Boundary=require('src.compat.gen1.ScriptBoundary') local Scope=require('src.render.GraphicsScope') local M={} local runtime=Runtime.new() +local function admitted(c) + if Runtime.member(c)then return true end + runtime:retire(c,false);return false +end +local captures=setmetatable({},{__mode='k'}) +local function serial()local renderer=package.loaded['src.render.Renderer'];return renderer and renderer.frameSerial end local function call(name,...) local facade=require('src.core.Game3');local owner=facade._callbackOwner() local seams=package.loaded['src.mods.ModSeams'] @@ -41,11 +47,18 @@ local function draw(value,w,h) if w~=bw or h~=bh then return false,'battle capture must use the native surface size'end if Boundary.active('battle.draw')then return Boundary.invoke('battle.draw')end -- A capture can draw the native screen but cannot re-enter source wrappers. - local ok,result=Scope.call(c.screen._drawNative,c.screen) + return M._captureNative(c.screen) +end +M.draw=draw +function M._captureNative(screen) + local c=runtime:active();if not c or c.screen~=screen then return false,'native battle admission retired'end + local ok,result=Scope.call(function() + return require('src.core.Game3')._withCallbackOwner(nil,screen._drawNative,screen) + end) if not ok then error(result,0)end + if admitted(c)then captures[screen]={serial=serial()}end return true end -M.draw=draw function M.abort(result) result=result or'run' local c=runtime:active();if not c then return false,'no admitted native battle'end @@ -73,27 +86,44 @@ function M.reset() runtime:release();return true end function M._nativeUpdate(screen,dt,body) + captures[screen]=nil local c=runtime:bind(screen);if not c then return body(screen,dt)end if c.stack:top()~=screen then return false,'native battle is covered by another state'end if Boundary.active('battle.update')then return Boundary.invoke('battle.update')end return Boundary.dispatch('battle.update',function() - c.views:flush() + if not admitted(c)then return false,'native battle admission retired'end + if c.stack:top()~=screen then return false,'native battle is covered by another state'end + local function native(owner,step) + if not admitted(c)then return false,'native battle admission retired'end + if c.stack:top()~=screen then return false,'native battle is covered by another state'end + c.views:flush() + return require('src.core.Game3')._withCallbackOwner(nil,body,owner,step) + end local ui=package.loaded['src.core.game3.battle.ui'] - if ui then return ui._nativeUpdate(screen,dt,body)end - return body(screen,dt) + if ui then return ui._nativeUpdate(screen,dt,native)end + return native(screen,dt) end,function() local result=call('update',dt,require('src.core.Game3').forNative(c.game)) - if Runtime.member(c)then c.views:flush()end + if admitted(c)and c.stack:top()==screen then c.views:flush()end return result end) end function M._nativeDraw(screen,body) local c=runtime:bind(screen);if not c then return body(screen)end if Boundary.active('battle.draw')then return Boundary.invoke('battle.draw')end + local capture=captures[screen];local frame=serial() + if capture and(capture.serial~=nil and capture.serial==frame or capture.serial==nil)then + if capture.serial==nil then captures[screen]=nil end;return true + end local ui=package.loaded['src.core.game3.battle.ui'] + local function guarded(owner) + if not admitted(c)then return false,'native battle admission retired'end + return require('src.core.Game3')._withCallbackOwner(nil,body,owner) + end local function native() - if ui then return ui._nativeDraw(screen,body)end - return body(screen) + if not admitted(c)then return false,'native battle admission retired'end + if ui then return ui._nativeDraw(screen,guarded)end + return guarded(screen) end if M.draw==draw then return native()end local w,h=screen:uiSize() @@ -108,16 +138,20 @@ end function M._nativeField(screen,body) local bg=package.loaded['src.core.game3.battle.bg'] if not bg then return body(screen)end - return bg._nativeDraw(screen,body) + local c=runtime:bind(screen);if not c then return body(screen)end + return bg._nativeDraw(screen,function(owner) + if not admitted(c)then return false,'native battle admission retired'end + return require('src.core.Game3')._withCallbackOwner(nil,body,owner) + end) end -function M._nativeExit(screen)runtime:exit(screen)end +function M._nativeExit(screen)captures[screen]=nil;runtime:exit(screen)end -- Private typed consumers can resolve a source observation to the same native -- admission; callers cannot submit another state or a stale completed view. function M._screen(view) local c=runtime:active() if c and(view==nil or view==c.views.view)then return c.screen end end -local service={poll=function()runtime:poll()end,release=function()runtime:release()end, +local service={poll=function()runtime:poll()end,release=function()captures=setmetatable({},{__mode='k'});runtime:release()end, releaseOwner=function(_,who)runtime:releaseOwner(who)end} M._service=service require('src.core.game3.asset_stream').register(service) diff --git a/src/core/game3/battle/ui.lua b/src/core/game3/battle/ui.lua index ecef91926..85e74700d 100644 --- a/src/core/game3/battle/ui.lua +++ b/src/core/game3/battle/ui.lua @@ -41,9 +41,7 @@ local function draw(w,h) local bw,bh=screen:uiSize() if w~=nil and w~=bw or h~=nil and h~=bh then return false,'UI capture must use the native battle surface'end if Boundary.active('battle.ui.draw')then return Boundary.invoke('battle.ui.draw')end - local ok,result=Scope.call(screen._drawNative,screen) - if not ok then error(result,0)end - return true + return Battle._captureNative(screen) end Ui.draw=draw function Ui._nativeDraw(screen,body) diff --git a/src/core/game3/mon_anim.lua b/src/core/game3/mon_anim.lua index 61efedc4a..cf18d74d6 100644 --- a/src/core/game3/mon_anim.lua +++ b/src/core/game3/mon_anim.lua @@ -13,11 +13,11 @@ local function owner() return host and host.currentMod or facade and facade._callbackOwner() end local function retire(c) - c.game,c.data,c.loader,c.motion,c.pictures=nil,nil,nil,nil,nil;c.stopped=true + c.game,c.data,c.loader,c.motion,c.pictures,c.ids=nil,nil,nil,nil,nil,nil;c.stopped=true end local function valid(c) if not c or c.stopped then return false end - if Session.game()~=c.game or c.game.data~=c.data or c.game.mods~=c.loader or Version.get()~=c.edition then retire(c);return false end + if Session.game()~=c.game or c.game.data~=c.data or c.game.mods~=c.loader or Version.get()~=c.edition or not c.ids:valid()then retire(c);return false end return true end function M.enabled() @@ -26,10 +26,10 @@ function M.enabled() end function M.newSprite(species) local game=Session.game();if not(game and game.data)then return nil,'no native Pokemon data'end - local key=Ids.new(game.data):native('pokemon',species) + local ids=Ids.new(game.data);local key=ids:native('pokemon',species) if not key then return nil,'unknown cartridge species'end local sprite={frames=0} - records[sprite]={game=game,data=game.data,loader=game.mods,edition=Version.get(),key=key,owner=owner()} + records[sprite]={game=game,data=game.data,loader=game.mods,edition=Version.get(),key=key,owner=owner(),ids=ids} return sprite end function M.summary(sprite,species,oneFrame) diff --git a/src/core/game3/pokemon.lua b/src/core/game3/pokemon.lua index 95089e051..b9e6e9c02 100644 --- a/src/core/game3/pokemon.lua +++ b/src/core/game3/pokemon.lua @@ -16,7 +16,7 @@ end local function context() local game=Native.game() if not(game and game.data and type(game.data.pokemon)=='table')then release();return end - if not current or current.game~=game or current.data~=game.data or current.loader~=game.mods or current.edition~=Version.get()then + if not current or current.game~=game or current.data~=game.data or current.loader~=game.mods or current.edition~=Version.get()or not current.ids:valid()then release();current={game=game,data=game.data,loader=game.mods,edition=Version.get(),ids=Ids.new(game.data)} end local host=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] diff --git a/src/mods/ModSeams.lua b/src/mods/ModSeams.lua index f4d06a01c..564eb179c 100644 --- a/src/mods/ModSeams.lua +++ b/src/mods/ModSeams.lua @@ -116,6 +116,16 @@ function ModSeams.open(owner) local function require2(name, ...) local had = package.loaded[name] local result = real(name, ...) + if name=='src.core.game3.battle' then + local facade=package.loaded['src.core.Game3'] or real('src.core.Game3') + if facade and facade._observeMod then facade._observeMod(owner)end + local watched=false + for _,entry in ipairs(snap)do if entry.t==facade then watched=true;break end end + if type(facade)=='table'and not watched and not fresh[facade]then + fresh[facade]=true + snap[#snap+1]={t=facade,label='src.core.Game3',before=copyOf(facade)} + end + end if had == nil and type(name) == "string" and name:match("^src%.") and type(result) == "table" and not fresh[result] then fresh[result] = true diff --git a/tests/engine/native_battle_ownership.lua b/tests/engine/native_battle_ownership.lua new file mode 100644 index 000000000..b52cc9d8f --- /dev/null +++ b/tests/engine/native_battle_ownership.lua @@ -0,0 +1 @@ +dofile('tools/battle-source-runtime/ownership-check.lua') diff --git a/tests/engine/native_battle_source_lifecycle.lua b/tests/engine/native_battle_source_lifecycle.lua new file mode 100644 index 000000000..230905d08 --- /dev/null +++ b/tests/engine/native_battle_source_lifecycle.lua @@ -0,0 +1 @@ +dofile('tools/battle-source-runtime/lifecycle-check.lua') diff --git a/tools/battle-source-runtime/host/main.lua b/tools/battle-source-runtime/host/main.lua index f403db56c..f8a16f984 100644 --- a/tools/battle-source-runtime/host/main.lua +++ b/tools/battle-source-runtime/host/main.lua @@ -13,6 +13,19 @@ function love.load() local backend=assert(os.getenv('POKEPORT_GRAPHICS_BACKEND')) T.check(renderer:lower():find(backend,1,true)~=nil,'requested actual GPU backend') T.eq(love.filesystem.getIdentity(),os.getenv('POKEPORT_IDENTITY'),'owned QA profile') + -- Owned asymmetric art makes battler draws visible; no missing-image + -- fallback may stand in for either Pokemon picture in this test. + love.filesystem.createDirectory('tests/fixture_data/assets') + for side,name in ipairs{'back','front'}do + local pixels=love.image.newImageData(64,64) + pixels:mapPixel(function(x,y) + if x<7 or y<5 or x>56 or y>57 then return 0,0,0,0 end + if y<20 and x<37 then return 1,0,91/255,1 end + if x>40 and y>35 then return 0,1,125/255,1 end + return side==1 and .2 or .7,.3,side==1 and .9 or .1,1 + end) + pixels:encode('png','tests/fixture_data/assets/fixmon_a_'..name..'.png');pixels:release() + end local readbacks,comparisons=0,0 local function read(canvas) local data=g.readbackTexture and g.readbackTexture(canvas)or canvas:newImageData() @@ -34,6 +47,7 @@ function love.load() b.showPlayerBack,b.showEnemyTrainer=false,false;b.frlgIntro=nil;b.introSlide=0 local canvas=g.newCanvas(240,160,{format='rgba8',dpiscale=1,msaa=0}) local baseline=frame(canvas,function()b:_drawNative()end) + T.check(baseline:find(string.char(255,0,91,255),1,true)~=nil,'asymmetric fixture pixels reach the native GPU frame') local sourceCalls,uiCalls,backgroundCalls=0,0,0 Battle.draw=function(view,w,h)sourceCalls=sourceCalls+1;T.eq(w,240);T.eq(h,160);return baseDraw(view,w,h)end Ui.draw=function(w,h)uiCalls=uiCalls+1;return uiDraw(w,h)end @@ -54,12 +68,26 @@ function love.load() local ok,why=pcall(b.draw,b);T.eq(ok,false);T.check(tostring(why):find('owned GPU callback failure',1,true)~=nil) T.eq(g.getCanvas(),bound);if depth then T.eq(g.getStackDepth(),depth)end Battle.draw,Ui.draw,Bg.draw=baseDraw,uiDraw,bgDraw + local rendererState=require('src.render.Renderer') + local captures=0 + Bg.draw=function(...)captures=captures+1;return bgDraw(...)end + rendererState.frameSerial=(rendererState.frameSerial or 0)+1 + same(frame(canvas,function()baseDraw(game);b:draw()end),baseline,'captured battle keeps native pixels '..edition) + T.eq(captures,1,'capture and screen share one native field draw') + rendererState.frameSerial=rendererState.frameSerial+1 + same(frame(canvas,function()uiDraw();b:draw()end),baseline,'UI capture keeps native pixels '..edition) + T.eq(captures,2,'UI capture and screen share one native field draw') + rendererState.frameSerial=rendererState.frameSerial+1 + same(frame(canvas,function()b:draw()end),baseline,'next frame draws the native battle again '..edition) + T.eq(captures,3,'capture suppression ends at the next renderer frame') + Bg.draw=bgDraw game.stack:pop();Battle.reset();canvas:release();f.close();require('src.core.Game3').release() end T.finish() local report={passed=true,checks=T.checks,readbacks=readbacks,comparisons=comparisons, backend=backend,renderer=renderer,version=version,vendor=vendor,device=device, playerProfilesUsed=false,gameplayProven=false,originalModLoaded=false, + syntheticBattlerArt=true, scope='Actual native BattleState and Gen3Battle draws over synthetic imported Pokemon; five GBA editions. Source/UI/background wrappers and GPU error restoration; no original-mod boot or gameplay claim.'} local file=assert(io.open(assert(os.getenv('BATTLE_SOURCE_REPORT')),'w')) file:write(require('src.link.Json').encode(report));file:close() diff --git a/tools/battle-source-runtime/lifecycle-check.lua b/tools/battle-source-runtime/lifecycle-check.lua new file mode 100644 index 000000000..729bcf4c7 --- /dev/null +++ b/tools/battle-source-runtime/lifecycle-check.lua @@ -0,0 +1,56 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local fixture=require('tools.battle-source-runtime.fixture').new('emerald') +local native=fixture.game;package.loaded['src.core.Game']=native +local state=fixture.fresh('TACKLE').battle;state.enter=false;native.stack:push(state) +local Json=require('src.link.Json') +local api=tonumber(os.getenv('SESSION_RUNTIME_API'))or require('src.core.Version').modApi +local files={};local function mod(id,priority,body) + files['mods/'..id..'/manifest.json']=Json.encode({id=id,name=id,version='1.0.0',api=api, + entry='main.lua',games={'gen3'},priority=priority,permissions={'engine_internals'}}) + files['mods/'..id..'/main.lua']=body +end +mod('battle_owner',100,[[local mod=...;local S=require('src.core.game3.battle') + local update,draw=S.update,S.draw;mod.exports.baseUpdate=update;mod.exports.baseDraw=draw + mod.exports.ticks=0;mod.exports.draws=0 + S.update=function(dt,game) + mod.exports.game=game;mod.exports.ticks=mod.exports.ticks+1 + update(dt,game);return update(dt,game) + end + S.draw=function(game)mod.exports.draws=mod.exports.draws+1;draw(game);return draw(game)end +]]) +mod('battle_bad',200,[[local S=require('src.core.game3.battle') + S.update=function()error('failed battle update survived')end + S.draw=function()error('failed battle draw survived')end + error('intentional battle entry failure') +]]) +mod('battle_native_peer',0,[[local mod=...;mod.events:on('game.ready',function(p)mod.exports.game=p.game end)]]) +local run=T.sdk.loadMods({'mods/battle_owner','mods/battle_bad','mods/battle_native_peer'}, + {fs=T.sdk.memfs(files),data=T.fixtures.fresh()}) +T.eq(#run.errors,1);local owner=assert(run.loader.exports.battle_owner) +native.mods=run.loader;run.loader.game=native +local S=require('src.core.game3.battle');local G=require('src.core.Game3') +T.eq(G._usesCallbacks('battle_owner'),true,'the battle namespace selects source game callback identities') +T.eq(G._usesCallbacks('battle_bad'),false,'failed entry retires callback identity selection') +T.eq(G._usesCallbacks('battle_native_peer'),false) +run.loader.events:emit('game.ready',{game=native}) +T.eq(run.loader.exports.battle_native_peer.game,native) +local ticks=0;state.phase='QA_IDLE';state.tickFx=function() + ticks=ticks+1;T.eq(G._callbackOwner(),nil,'native method bodies do not inherit source callback ownership') +end +state:update(1/60);T.eq(ticks,1);T.eq(owner.ticks,1);T.eq(owner.game,G.forNative(native)) +T.eq(G._callbackOwner(),nil,'phase completion restores the caller owner') +local old=S.getState();local rawParty=native.save.party +local Gen3=require('src.battle.Gen3Battle');local originalDraw=Gen3.draw;local draws=0 +state.wideLayout=function()return false end;state.gen3Layout=function()return true end +Gen3.draw=function()draws=draws+1;T.eq(G._callbackOwner(),nil)end +require('src.render.Renderer').frameSerial=51 +state:draw();T.eq(draws,1);T.eq(owner.draws,1) +require('src.mods.Loader').teardown() +T.eq(S.update,owner.baseUpdate);T.eq(S.draw,owner.baseDraw) +T.eq(G._usesCallbacks('battle_owner'),false);T.raises(function()old.player.mon.hp=1 end,'retired') +T.eq(native.stack:top(),state,'observer teardown cannot abort a native admission') +T.eq(native.save.party,rawParty,'observer teardown leaves native party ownership intact') +state:update(1/60);T.eq(ticks,2);T.eq(owner.ticks,1,'unwound wrapper is no longer driven') +Gen3.draw=originalDraw;run.release() +T.finish('native battle phase SDK retirement and native peer API '..api) diff --git a/tools/battle-source-runtime/ownership-check.lua b/tools/battle-source-runtime/ownership-check.lua new file mode 100644 index 000000000..f90fa4e3b --- /dev/null +++ b/tools/battle-source-runtime/ownership-check.lua @@ -0,0 +1,99 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local S=require('src.core.game3.battle');local Ui=require('src.core.game3.battle.ui') +local Background=require('src.core.game3.battle.bg') +T.eq(package.loaded['src.core.Game'],nil);T.eq(S.isActive(),false) +local base={update=S.update,draw=S.draw,pump=Ui.pump,uiDraw=Ui.draw,bg=Background.draw} +local Gen3=require('src.battle.Gen3Battle');local originalDraw,originalField=Gen3.draw,Gen3.drawField +local Renderer=require('src.render.Renderer') +local G=require('src.core.Game3') +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + S._service.release();local f=require('tools.battle-source-runtime.fixture').new(edition) + local game=f.game;package.loaded['src.core.Game']=game + local state=f.fresh('TACKLE').battle;state.enter=false;game.stack:push(state) + state.phase='QA_IDLE';local ticks,draws,fields,sourceTicks=0,0,0,0 + state.tickFx=function()ticks=ticks+1;T.eq(G._callbackOwner(),nil)end + Gen3.draw=function(screen)draws=draws+1;T.eq(G._callbackOwner(),nil);screen:drawBattleField()end + Gen3.drawField=function()fields=fields+1;T.eq(G._callbackOwner(),nil)end + local model=S.getState();T.eq(S.getState(),model) + S.update=function(dt,view) + sourceTicks=sourceTicks+1;T.eq(G.native(view),game) + state:update(dt);base.update(dt,view);return base.update(dt,view) + end + state:update(1/60);T.eq(ticks,1);T.eq(sourceTicks,1) + S.update=base.update + local tick=state.tickFx;state.tickFx=function()error('native phase body failure')end + S.update=function()pcall(base.update);return base.update()end + T.raises(function()state:update(1/60)end,'native phase body failure') + state.tickFx=tick;S.update=base.update;state:update(1/60);T.eq(ticks,2) + local save=game.save + S.update=function()game.save={};return base.update()end + state:update(1/60);T.eq(ticks,2,'a changed save cancels the already queued native step') + game.save=save;T.eq(S.isActive(),true) + T.raises(function()model.player.mon.hp=1 end,'retired','restoring a prior save cannot revive a retired view') + local overlay={} + S.update=function()game.stack:push(overlay);return base.update()end + state:update(1/60);T.eq(ticks,2);game.stack:pop();S.update=base.update + Ui.pump=function()game.stack:push(overlay);return base.pump()end + state:update(1/60);T.eq(ticks,2,'UI hooks must recheck the admission at the inner native body') + game.stack:pop();Ui.pump=base.pump + Ui.pump=function()game.save={};return base.pump()end + state:update(1/60);T.eq(ticks,2,'UI hooks cannot retain a native step through save replacement') + game.save=save;Ui.pump=base.pump + local current=S.getState();local snap=require('src.compat.gen1.TableIteration').snapshot(current) + T.eq(snap.player,current.player);T.eq(snap.player.mon,current.player.mon) + local monSnap=require('src.compat.gen1.TableIteration').snapshot(current.player.mon) + T.eq(monSnap.species,4);T.eq(monSnap.hp,state.player.mon.hp) + monSnap.hp=1;T.neq(state.player.mon.hp,1) + local P=require('src.core.game3.pokemon');local Motion=require('src.core.game3.mon_anim') + T.eq(P.speciesFromName('CHARMANDER'),4) + local moving=assert(Motion.newSprite(4)) + local order=game.data.constants.speciesOrder + S.update=function()game.data.constants.speciesOrder={};return base.update()end + state:update(1/60);T.eq(ticks,2,'replaced cartridge identity tables retire a pending native command') + T.eq(Motion.step(moving),false,'a presentation player cannot retain a retired cartridge identity') + game.data.pokemon.QA_SPARSE={index=277,name='QA_SPARSE'} + game.data.constants.speciesOrder={[4]='CHARMANDER',[277]='QA_SPARSE'} + T.eq(P.speciesFromName('QA_SPARSE'),277,'new Pokemon queries rebuild from the current cartridge order') + game.data.constants.speciesOrder=order;game.data.pokemon.QA_SPARSE=nil;S.update=base.update + T.eq(P.speciesFromName('CHARMANDER'),4) + T.raises(function()current.player.mon.hp=1 end,'retired') + Renderer.frameSerial=(Renderer.frameSerial or 0)+1 + T.eq(base.draw(game),true);T.eq(draws,1);T.eq(fields,1) + state:draw();state:draw();T.eq(draws,1,'capturing a native frame suppresses its repeated screen pass') + Renderer.frameSerial=Renderer.frameSerial+1;state:draw();T.eq(draws,2) + Renderer.frameSerial=Renderer.frameSerial+1;T.eq(Ui.draw(),true);state:draw();T.eq(draws,3,'UI capture shares native frame ownership') + Renderer.frameSerial=Renderer.frameSerial+1 + S.draw=function()game.save={};return base.draw()end + state:draw();T.eq(draws,3,'a stale outer draw cannot reach the native UI') + game.save=save;S.draw=base.draw + Renderer.frameSerial=Renderer.frameSerial+1 + Ui.draw=function()game.save={};return base.uiDraw()end + state:draw();T.eq(draws,3,'a stale inner UI draw cannot reach the native renderer') + game.save=save;Ui.draw=base.uiDraw + Renderer.frameSerial=Renderer.frameSerial+1 + Background.draw=function()game.save={};return base.bg()end + state:draw();T.eq(fields,3,'a stale background hook cannot consume the native field pass') + game.save=save;Background.draw=base.bg + local live=S.getState();S._service.release() + T.eq(game.stack:top(),state);T.raises(function()live.player.mon.hp=1 end,'retired') + local final=S.getState();state.result='lose';game.stack:pop() + T.eq(S.getResult(),'lose');T.eq(S.getState(),final) + local finalSnapshot=require('src.compat.gen1.TableIteration').snapshot(final) + T.eq(finalSnapshot.result,'lose');T.eq(finalSnapshot.player,final.player) + f.close();G.release() +end +Gen3.draw,Gen3.drawField=originalDraw,originalField +-- A typed iterator registration cannot retain its own weak key through a +-- closure that reaches the environment's public view. +local weak=setmetatable({},{__mode='v'}) +do + local data={constants={},pokemon={},moves={}} + local game={data=data,save={party={}}};local screen={kind='wild'} + local e=require('src.compat.gen1.BattleView').new(game,screen,function()return true end) + weak[1],weak[2],weak[3],weak[4]=e.view,game,data,screen +end +for _=1,4 do collectgarbage('collect')end +for i=1,4 do T.eq(weak[i],nil,'unused typed observations remain collectible: '..i)end +T.finish('native battle admission guards, capture ownership and typed iteration') From 6913ad466180c7308cf38817cdc23dc5973db700 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 12:00:58 +0100 Subject: [PATCH 5/9] Preserve the native screen after captures into separate render targets --- .../gba-battle-source-boundary.md | 3 ++- src/core/game3/battle.lua | 19 +++++++++++++++++-- tools/battle-source-runtime/host/main.lua | 8 +++++++- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/docs/qa/native-foundation/gba-battle-source-boundary.md b/docs/qa/native-foundation/gba-battle-source-boundary.md index 24df2ccfc..0450f6f74 100644 --- a/docs/qa/native-foundation/gba-battle-source-boundary.md +++ b/docs/qa/native-foundation/gba-battle-source-boundary.md @@ -19,7 +19,8 @@ This is an incremental presentation and observation bridge over the existing an earlier pointer cannot revive that binding. Native method bodies execute outside a source callback owner. - Explicit battle/UI captures suppress the repeated native screen draw within - the same renderer frame. Capturing never advances combat. + the same renderer frame and render target. A separate preview target cannot + suppress the main screen. Capturing never advances combat. - LuaJIT `pairs`/`next` expose typed battle fields, preserving nested typed identity. Iterator registry values are weak, so an unused observation cannot retain its own weak key through a closure. diff --git a/src/core/game3/battle.lua b/src/core/game3/battle.lua index f1e4b2752..7eaf7be14 100644 --- a/src/core/game3/battle.lua +++ b/src/core/game3/battle.lua @@ -11,6 +11,21 @@ local function admitted(c) end local captures=setmetatable({},{__mode='k'}) local function serial()local renderer=package.loaded['src.render.Renderer'];return renderer and renderer.frameSerial end +local function sameTarget(a,b,depth) + if a==b then return true end + if type(a)~='table'or type(b)~='table'or(depth or 0)>=4 then return false end + -- LOVE canvases are userdata; preserve identity for table-based test or + -- external wrappers too. Only the MRT binding description is structural. + if a.typeOf or b.typeOf then return false end + for k,v in pairs(a)do if not sameTarget(v,b[k],(depth or 0)+1)then return false end end + for k in pairs(b)do if a[k]==nil then return false end end + return true +end +local function target(value,depth) + if depth==nil then value=love.graphics.getCanvas();depth=0 end + if type(value)~='table'or value.typeOf or depth>=4 then return value end + local out={};for k,v in pairs(value)do out[k]=target(v,depth+1)end;return out +end local function call(name,...) local facade=require('src.core.Game3');local owner=facade._callbackOwner() local seams=package.loaded['src.mods.ModSeams'] @@ -56,7 +71,7 @@ function M._captureNative(screen) return require('src.core.Game3')._withCallbackOwner(nil,screen._drawNative,screen) end) if not ok then error(result,0)end - if admitted(c)then captures[screen]={serial=serial()}end + if admitted(c)then captures[screen]={serial=serial(),target=target()}end return true end function M.abort(result) @@ -112,7 +127,7 @@ function M._nativeDraw(screen,body) local c=runtime:bind(screen);if not c then return body(screen)end if Boundary.active('battle.draw')then return Boundary.invoke('battle.draw')end local capture=captures[screen];local frame=serial() - if capture and(capture.serial~=nil and capture.serial==frame or capture.serial==nil)then + if capture and sameTarget(capture.target,target())and(capture.serial~=nil and capture.serial==frame or capture.serial==nil)then if capture.serial==nil then captures[screen]=nil end;return true end local ui=package.loaded['src.core.game3.battle.ui'] diff --git a/tools/battle-source-runtime/host/main.lua b/tools/battle-source-runtime/host/main.lua index f8a16f984..74190b1f1 100644 --- a/tools/battle-source-runtime/host/main.lua +++ b/tools/battle-source-runtime/host/main.lua @@ -80,7 +80,13 @@ function love.load() rendererState.frameSerial=rendererState.frameSerial+1 same(frame(canvas,function()b:draw()end),baseline,'next frame draws the native battle again '..edition) T.eq(captures,3,'capture suppression ends at the next renderer frame') - Bg.draw=bgDraw + rendererState.frameSerial=rendererState.frameSerial+1 + local preview=g.newCanvas(240,160,{format='rgba8',dpiscale=1,msaa=0}) + same(frame(canvas,function() + g.setCanvas(preview);baseDraw(game);g.setCanvas(canvas);b:draw() + end),baseline,'offscreen capture cannot suppress the main battle surface '..edition) + T.eq(captures,5,'different render targets each receive their own native field pass') + preview:release();Bg.draw=bgDraw game.stack:pop();Battle.reset();canvas:release();f.close();require('src.core.Game3').release() end T.finish() From 807f3ca59eee315e480bb773ce295933b4f7130d Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 12:06:42 +0100 Subject: [PATCH 6/9] Retire loaded battle presentation services on older native hosts --- .../gba-battle-source-boundary.md | 5 +++++ src/mods/Loader.lua | 16 ++++++++++++++++ tools/battle-source-runtime/lifecycle-check.lua | 2 +- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/qa/native-foundation/gba-battle-source-boundary.md b/docs/qa/native-foundation/gba-battle-source-boundary.md index 0450f6f74..5f24b3cc6 100644 --- a/docs/qa/native-foundation/gba-battle-source-boundary.md +++ b/docs/qa/native-foundation/gba-battle-source-boundary.md @@ -68,3 +68,8 @@ source effect timing/art parity and physical target acceptance remain open. The last unchanged-package loader probe advances beyond battle, Pokemon, motion and UI imports to the missing `src.core.game3.battle.anim` interface. + +Older native hosts also retire already-loaded battle, Pokemon-picture and +motion services at loader unwind, even when their stream service is not yet +driven by loader retirement. This releases observations and owned pictures; +it does not pop or finish the actual native battle. diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..14c98eea7 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -32,6 +32,20 @@ require("src.core.ModCompat").install() local Loader = {} Loader.__index = Loader +-- Compatibility battle services also retire on older native hosts whose +-- stream service is available but not yet driven by loader retirement. +local function retireBattlePresentation() + local first + for _,name in ipairs({'src.core.game3.battle','src.core.game3.pokemon','src.core.game3.mon_anim'})do + local module=package.loaded[name];local service=type(module)=='table'and module._service + if type(service)=='table'and type(service.release)=='function'then + local ok,why=pcall(service.release,service) + if not ok and not first then first=why end + end + end + if first then error(first,0)end +end + local MOD_STATE_FILE = "mod_state.lua" -- legacy migration only -- walk a dotted target path without creating anything; the base view a @@ -1315,6 +1329,7 @@ function Loader:load(data) -- again (and the launcher backdrop boots the plain game in between); the -- wraps and flags the previous load put on engine tables come off first. ModSeams.unwindAll() + retireBattlePresentation() self.baseData = data -- every registry folds against the pristine view of its Data target; -- resolution is lazy so optional namespaces may appear later @@ -1654,6 +1669,7 @@ end -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() local undone = select(1, ModSeams.unwindAll()) + retireBattlePresentation() pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) Runtime.currentMod = nil diff --git a/tools/battle-source-runtime/lifecycle-check.lua b/tools/battle-source-runtime/lifecycle-check.lua index 729bcf4c7..cdeed1911 100644 --- a/tools/battle-source-runtime/lifecycle-check.lua +++ b/tools/battle-source-runtime/lifecycle-check.lua @@ -48,7 +48,7 @@ require('src.render.Renderer').frameSerial=51 state:draw();T.eq(draws,1);T.eq(owner.draws,1) require('src.mods.Loader').teardown() T.eq(S.update,owner.baseUpdate);T.eq(S.draw,owner.baseDraw) -T.eq(G._usesCallbacks('battle_owner'),false);T.raises(function()old.player.mon.hp=1 end,'retired') +T.eq(G._usesCallbacks('battle_owner'),false);T.raises(function()old.player.mon.hp=1 end,'retired','loader teardown retires held battle observations') T.eq(native.stack:top(),state,'observer teardown cannot abort a native admission') T.eq(native.save.party,rawParty,'observer teardown leaves native party ownership intact') state:update(1/60);T.eq(ticks,2);T.eq(owner.ticks,1,'unwound wrapper is no longer driven') From 8e3669e73f782b22376204c44d1e672264f2fe61 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 12:17:12 +0100 Subject: [PATCH 7/9] Assert the native GPU controller comes from its frozen source tree --- tools/battle-source-runtime/host/main.lua | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/battle-source-runtime/host/main.lua b/tools/battle-source-runtime/host/main.lua index 74190b1f1..1aba9934d 100644 --- a/tools/battle-source-runtime/host/main.lua +++ b/tools/battle-source-runtime/host/main.lua @@ -1,6 +1,8 @@ io.stdout:setvbuf('no') local root=assert(os.getenv('BATTLE_SOURCE_ENGINE')) -package.path=root..'/?.lua;'..root..'/?/init.lua;'..package.path +-- Keep the absolute frozen engine first when the SDK checks for its Lua +-- package-path sentinel. Relative fixture IO runs from that same engine. +package.path=root..'/?.lua;'..root..'/?/init.lua;./?/init.lua;'..package.path function love.errorhandler(err)io.stderr:write(debug.traceback(tostring(err),2)..'\n');return function()return 1 end end function love.load() local T=require('tests.harness').suite('native battle source GPU') @@ -9,6 +11,8 @@ function love.load() local Ui=require('src.core.game3.battle.ui') local Bg=require('src.core.game3.battle.bg') local baseDraw,uiDraw,bgDraw=Battle.draw,Ui.draw,Bg.draw + local nativeBattleSource=debug.getinfo(require('src.battle.BattleState').update,'S').source + T.eq(nativeBattleSource,'@'..root..'/src/battle/BattleState.lua','native battle code comes from the frozen engine') local renderer,version,vendor,device=g.getRendererInfo() local backend=assert(os.getenv('POKEPORT_GRAPHICS_BACKEND')) T.check(renderer:lower():find(backend,1,true)~=nil,'requested actual GPU backend') @@ -94,6 +98,7 @@ function love.load() backend=backend,renderer=renderer,version=version,vendor=vendor,device=device, playerProfilesUsed=false,gameplayProven=false,originalModLoaded=false, syntheticBattlerArt=true, + engineRoot=root,nativeBattleSource=nativeBattleSource, scope='Actual native BattleState and Gen3Battle draws over synthetic imported Pokemon; five GBA editions. Source/UI/background wrappers and GPU error restoration; no original-mod boot or gameplay claim.'} local file=assert(io.open(assert(os.getenv('BATTLE_SOURCE_REPORT')),'w')) file:write(require('src.link.Json').encode(report));file:close() From 316ad42348ecdfcfa2b67f6945aa4a89ef4f115c Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 13:31:27 +0100 Subject: [PATCH 8/9] Share native GBA animation frames, screen effects and layout queries --- docs/native-battle-animation-runtime.md | 72 +++++ src/battle/BattleState.lua | 77 ++++- src/battle/Gen3MoveAnim.lua | 1 + src/battle/PresentationClock.lua | 34 +++ src/core/GameVersion.lua | 8 + src/core/game3/battle.lua | 10 +- src/core/game3/battle/anim.lua | 267 ++++++++++++++++++ src/core/game3/battle/anim_coords.lua | 131 +++++++++ src/mods/Loader.lua | 2 +- src/mods/ModSeams.lua | 2 +- src/render/BattleScreenEffect.lua | 62 ++++ src/render/GraphicsScope.lua | 56 ++++ tests/engine/native_battle_animation.lua | 137 +++++++++ tests/engine/native_version_layout.lua | 19 ++ .../battle-animation-runtime/coords-check.lua | 42 +++ tools/battle-animation-runtime/host/conf.lua | 6 + tools/battle-animation-runtime/host/main.lua | 159 +++++++++++ .../lifecycle-check.lua | 45 +++ 18 files changed, 1114 insertions(+), 16 deletions(-) create mode 100644 docs/native-battle-animation-runtime.md create mode 100644 src/battle/PresentationClock.lua create mode 100644 src/core/game3/battle/anim.lua create mode 100644 src/core/game3/battle/anim_coords.lua create mode 100644 src/render/BattleScreenEffect.lua create mode 100644 src/render/GraphicsScope.lua create mode 100644 tests/engine/native_battle_animation.lua create mode 100644 tests/engine/native_version_layout.lua create mode 100644 tools/battle-animation-runtime/coords-check.lua create mode 100644 tools/battle-animation-runtime/host/conf.lua create mode 100644 tools/battle-animation-runtime/host/main.lua create mode 100644 tools/battle-animation-runtime/lifecycle-check.lua diff --git a/docs/native-battle-animation-runtime.md b/docs/native-battle-animation-runtime.md new file mode 100644 index 000000000..5baca515a --- /dev/null +++ b/docs/native-battle-animation-runtime.md @@ -0,0 +1,72 @@ +# Native battle animation bridge + +`src.core.game3.battle.anim` presents the admitted GBA battle through its native +`BattleState`, `Gen3MoveAnim` and `Gen3BallAnim` owners. It does not load another +battle engine or rewrite a mod package. FireRed, LeafGreen, Emerald, Ruby and +Sapphire use the same boundary and their own imported identifiers and records. + +## Shared clock and ownership + +The native update opens one presentation phase. A source `Anim.update()` can +advance that phase while a custom ending or pose holds the combat update. +Repeated source calls and a later native base call consume the same display +steps. The native dt drives sprite animations. Outside that phase, source +updates cannot manufacture frames. + +Move/ball effects, visual waits, HP drain and EXP fill remain native operations. +Only the native combat update consumes command rows, input and outcomes. +Source-launched clips occupy the native animation player and hold the command +queue until that clip finishes. A presentation serial distinguishes a finished +source clip from a newer native animation on the same player. Reset/unload +cancels only a clip still owned by that source admission. + +`present(id)` has stable identity for an admitted battler. It observes native +display HP/maximum/level/status/EXP, visibility, offsets and affine effects. +Source visual overrides compose around the existing native sprite draw; +reading a native effect never applies it a second time. `shownBattler` carries +an owned presentation override without replacing the native combatant. +Replacing the session, save, data or catalogue retires old poses and callbacks. + +`GameVersion.layout(id)` also reports the registered GBA family (`frlg` or +`rse`) so an unchanged field companion probe can recognize the native host. +It supports current-version and colon queries without changing cache/save +selection; an explicit registered layout takes precedence. + +## Geometry and screen effects + +`anim_coords` exposes authored anchors and ordering for singles and doubles. +Current imported cartridge coordinates take precedence over the standard +240x160 anchors. Temporary side bindings restore on success and failure and +preserve trailing nil arguments/results. + +Supported screen effects are inversion, fade to white/black, darkening, +grayscale and blending to a custom colour. Repeated begin calls reapply within +one guarded render seam; nested seams restore their caller's shader. The native +graphics guard restores render targets, transforms, shaders and leaked pushes +after an error. Synthetic asymmetric sprites test orientation as well as colour. + +## Current boundary + +This bridge implements native move launch/observation, source pose tweens, +display ratios, sequence observations, screen effects and coordinate bindings. +It deliberately does not advertise a complete presentation API version. +Authored animation commands, mutable VM/sprite/task/palette callbacks, +particle bands, trainer/intro/switch/catch stage control, source HP/EXP tween +commands and complete custom UI captures still require integration. A module +being importable is not evidence of a complete mod boot or playable campaign. + +## Validation + +- `tests/engine/native_battle_animation.lua`: all five editions; shared clocks, + owned clip completion, held command queues, stale poses/callbacks and 180-frame + native trajectory comparisons per edition with and without the bridge. +- `tools/battle-animation-runtime/lifecycle-check.lua`: SDK entry failure, + teardown and native peer callback identities. +- `tools/battle-animation-runtime/coords-check.lua`: optional local contract + oracle supplied through `BATTLE_COORDS_ORACLE`; no external engine is bundled. +- `tools/battle-animation-runtime/host`: actual Metal/OpenGL sprite and screen + effect rendering, pixel comparisons and graphics error restoration. + +Local qualification runs use the repository's QA storage manager, frozen source +copies and dedicated LOVE identities. They use synthetic imported data and art; +they do not open player profiles, save files or ROM originals. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index c90feb417..5299618d9 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -2595,11 +2595,9 @@ 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 +-- Only display controllers live here. Command holds, HP awards, turn rows +-- and completion remain in updateQueue, even while a mod holds presentation. +function BattleState:updateMovePresentation() -- 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. -- @@ -2650,11 +2648,16 @@ function BattleState:updateQueue() if not anim.caught then self.gen3Ball = nil end end end +end + +-- Display-only waits and bar completion. Returning idle grants no command; +-- only updateQueue may consume a battle row after this gate. +function BattleState:updateQueuePresentation() + self:updateMovePresentation() + local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle.anim'] + if source and source._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 - return true - end + if self:stepPresentationWait() then return true end -- WaitForSoundToFinish (home/delay.asm:15-20) blocks until the sfx has -- actually stopped sounding, which is how the original gives a sound its -- own clear window instead of letting the next beat play over it @@ -2684,6 +2687,15 @@ function BattleState:updateQueue() if self:stepExpBar() then return true end self.expFilling = nil end + return false +end + +function BattleState:updateQueue() + if self.waitingUI then + if self.game.stack:top() ~= self then return true end + self.waitingUI = nil + end + if self:updateQueuePresentation() then return true end if self.animPlaying then self.animPlayer:update() if self.animPlayer.pollEffects and self.applyAnimEffect then @@ -4272,6 +4284,28 @@ function BattleState:touchpressed(_, px, py) end return true end +function BattleState:updateBattlerPresentation(dt) + for _, b in activeBattlers(self) do + if b and b.picAnim then b.picAnim:update(dt) end + if b and b.monAnim then b.monAnim:update(dt) end + end +end + +function BattleState:stepPresentationWait() + if self.waitFrames and self.waitFrames > 0 then + self.waitFrames = self.waitFrames - 1 + return true + end + return false +end + +-- Advance an ending/pose hold without executing battle commands or input. +function BattleState:updatePresentation(dt) + self:tickFx() + self:updateBattlerPresentation(dt) + self:updateQueuePresentation() +end + function BattleState:update(dt) self:tickFx() -- one sample a second into the fight, while it is still on screen @@ -4279,10 +4313,7 @@ function BattleState:update(dt) if self._stadiumReportTicks == 60 then self:reportStadiumBattle("mid") end -- Crystal pic animations run off the wall clock like every other battle -- pic effect; a Gold/Silver battler has no picAnim and this is a no-op. - for _, b in activeBattlers(self) do - if b and b.picAnim then b.picAnim:update(dt) end - if b and b.monAnim then b.monAnim:update(dt) end - end + self:updateBattlerPresentation(dt) local input = self.game.input -- L PUTS THE BOTTOM SCREEN AWAY, AND BRINGS IT BACK. @@ -12862,7 +12893,21 @@ end do local update, draw, field, exit, enter = BattleState.update, BattleState.draw, BattleState.drawBattleField, BattleState.exit, BattleState.enter + local Clock = require('src.battle.PresentationClock') + for _, name in ipairs{'tickFx','updateBattlerPresentation','updateMovePresentation', + 'stepHPDrain','stepExpBar','stepPresentationWait','updateQueuePresentation'} do + local body = BattleState[name] + BattleState[name] = function(self, ...) + return Clock.once(self, name, body, ...) + end + end BattleState._drawNative = draw + local battlerDraw = BattleState.drawBattlerPic + function BattleState:drawBattlerPic(battler, ...) + local anim = GameVersion.isGen3() and package.loaded['src.core.game3.battle.anim'] + if anim then return anim._drawBattler(self, battler, battlerDraw, ...) end + return battlerDraw(self, battler, ...) + end function BattleState:enter() enter(self) local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle'] @@ -12870,6 +12915,12 @@ do end function BattleState:update(dt) local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle'] + local anim = source and package.loaded['src.core.game3.battle.anim'] + if anim then + return Clock.run(self, dt, function() + return source._nativeUpdate(self, dt, update) + end) + end if source then return source._nativeUpdate(self, dt, update) end return update(self, dt) end diff --git a/src/battle/Gen3MoveAnim.lua b/src/battle/Gen3MoveAnim.lua index f6a3a09ea..d2f590fb4 100644 --- a/src/battle/Gen3MoveAnim.lua +++ b/src/battle/Gen3MoveAnim.lua @@ -754,6 +754,7 @@ function Gen3MoveAnim:start(moveId, attackerIsPlayer, attackerPosition, targetPo branchCtx, battle) local anim = self:recordFor(moveId, branchCtx) if not hasVisual(anim) then return false end + self.presentationSerial = (self.presentationSerial or 0) + 1 self.events = anim.events or {} self.shakes = anim.shakes self.blends = anim.blends diff --git a/src/battle/PresentationClock.lua b/src/battle/PresentationClock.lua new file mode 100644 index 000000000..00d8d13a1 --- /dev/null +++ b/src/battle/PresentationClock.lua @@ -0,0 +1,34 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Presentation holds borrow a native update; they never drain its command +-- queue. Results (including failures and nils) are shared within that update. +local M={} +local phases=setmetatable({},{__mode='k'}) +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +function M.active(screen)return phases[screen]~=nil end +function M.delta(screen)return phases[screen]and phases[screen].delta end +function M.once(screen,key,body,...) + local phase=phases[screen] + if not phase then return body(screen,...)end + local entry=phase.steps[key] + if not entry then + entry={};phase.steps[key]=entry + entry.result=pack(pcall(body,screen,...)) + end + local r=entry.result + if not r then return nil,'presentation step is already executing'end + if not r[1]then error(r[2],0)end + return unpack(r,2,r.n) +end +function M.run(screen,dt,body,...) + if phases[screen]then return body(...)end + local phase={steps={},delta=dt};phases[screen]=phase + local result=pack(pcall(body,...));phases[screen]=nil + -- A wrapper cannot swallow a failed display step and retry that step. + for _,entry in pairs(phase.steps)do + if entry.result and not entry.result[1]then error(entry.result[2],0)end + end + if not result[1]then error(result[2],0)end + return unpack(result,2,result.n) +end +return M diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index 330f4f3e9..8d0464c60 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -1117,6 +1117,14 @@ end -- ways no amount of widening an `isGen2()` reaches -- a filesystem instead of -- a flat ROM, two screens instead of one, and a 3D field. This exists so the -- Gen 4 work has a gate to hang itself on without touching the branches the +-- Registered GBA family for source mod probes; cache/save ownership stays native. +local gbaLayouts={firered='frlg',leafgreen='frlg',ruby='rse',sapphire='rse',emerald='rse'} +function GameVersion.layout(id,versionId) + if id==GameVersion then id=versionId end + local info=GameVersion.info(id) + return info and(info.layout or gbaLayouts[info.id])or nil +end + -- other three generations take. function GameVersion.isGen4(id) return GameVersion.generation(id) == 4 diff --git a/src/core/game3/battle.lua b/src/core/game3/battle.lua index 7eaf7be14..dd02e0fa5 100644 --- a/src/core/game3/battle.lua +++ b/src/core/game3/battle.lua @@ -118,6 +118,9 @@ function M._nativeUpdate(screen,dt,body) if ui then return ui._nativeUpdate(screen,dt,native)end return native(screen,dt) end,function() + local anim=package.loaded['src.core.game3.battle.anim'] + if anim then anim._nativeStep(screen,dt)end + if not admitted(c)or c.stack:top()~=screen then return false,'native battle admission retired'end local result=call('update',dt,require('src.core.Game3').forNative(c.game)) if admitted(c)and c.stack:top()==screen then c.views:flush()end return result @@ -159,7 +162,12 @@ function M._nativeField(screen,body) return require('src.core.Game3')._withCallbackOwner(nil,body,owner) end) end -function M._nativeExit(screen)captures[screen]=nil;runtime:exit(screen)end +function M._nativeExit(screen) + captures[screen]=nil + local anim=package.loaded['src.core.game3.battle.anim'] + if anim then anim._nativeExit(screen)end + runtime:exit(screen) +end -- Private typed consumers can resolve a source observation to the same native -- admission; callers cannot submit another state or a stale completed view. function M._screen(view) diff --git a/src/core/game3/battle/anim.lua b/src/core/game3/battle/anim.lua new file mode 100644 index 000000000..5c3bb9c60 --- /dev/null +++ b/src/core/game3/battle/anim.lua @@ -0,0 +1,267 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- One admitted native presentation owner. This is not a second battle VM; +-- authored command/particle APIs are advertised only as they are integrated. +local Battle=require('src.core.game3.battle') +local Coords=require('src.core.game3.battle.anim_coords') +local Clock=require('src.battle.PresentationClock') +local Scope=require('src.render.GraphicsScope') +local Ids=require('src.compat.gen1.BattleIds') +local A={Coords=Coords,ENEMY_MON={x=176,y=40},PLAYER_MON={x=72,y=80}} +local current +local numeric={ox=true,oy=true,alpha=true,scale=true,sx=true,sy=true,rotation=true, + darken=true,flash=true,displayHp=true,displayMaxHp=true,displayLevel=true,displayExp=true} +local function retire() + if current then + local screen=current.screen[1] + if screen and current.clip and screen.gen3Anim==current.clip.player + and current.clip.serial==screen.gen3Anim.presentationSerial then + screen.gen3Anim:release();screen.gen3AnimPlaying=false + end + current.clip=nil + if current.effect then current.effect:release();current.effect=nil end + current.tasks={};current.onEnd=nil;current.users={};current.overrides={} + current.view=nil;current.screen[1]=nil + end + current=nil +end +local function context() + local screen=Battle._screen();local view=Battle.getState() + if not screen then retire();return end + if not current or current.screen[1]~=screen or current.view~=view then + retire() + current={screen=setmetatable({screen},{__mode='v'}),view=view,present={},overrides={}, + tasks={},nextTask=0,users={},ids=Ids.new(screen.data)} + end + local facade=require('src.core.Game3');local owner=facade._callbackOwner() + local host=package.loaded['src.mods.Runtime'];owner=host and host.currentMod or owner + if owner then current.users[owner]=true end + return current,screen +end +local function battler(screen,id) + if id==0 then return screen.player elseif id==1 then return screen.enemy end + return screen:battlerAt(id) +end +local function number(v)return type(v)=='number'and v==v and v~=math.huge and v~=-math.huge end +local function callback(fn,...) + if fn then return require('src.core.Game3')._withCallbackOwner(fn.owner,fn.call,...)end +end +local function owned(fn) + if fn==nil then return end + assert(type(fn)=='function','presentation callback must be a function') + local host=package.loaded['src.mods.Runtime'] + return{call=fn,owner=host and host.currentMod or require('src.core.Game3')._callbackOwner()} +end +local function read(d,id,key) + local active,screen=context();if active~=d then return nil end + local b=battler(screen,id);if not b then return nil end + local p=d.overrides[id] + if p[key]~=nil then return p[key]end + if key=='id'then return id end + if key=='side'then return Coords.sideOf(id)end + if key=='visible'then return not b.fainted and not screen:fxHidden(b)and not screen:gen3AnimMonBgHidden(b)end + if key=='invisible'or key=='battlerInvisible'then return screen:gen3AnimMonBgHidden(b)end + if key=='ox'or key=='oy'then + local x,y=screen:gen3AnimShake(b);local fx=screen.picFx and screen.picFx[b] + return(key=='ox'and x+(fx and fx.ox or 0)or y+(fx and fx.oy or 0)) + end + if key=='scale'then return screen:gen3AnimScale(b)or 1 end + if key=='sx'or key=='sy'then + local x,y=screen:gen3AnimSquash(b);return(key=='sx'and x or y)or 1 + end + if key=='rotation'then return screen:gen3AnimRotate(b)or 0 end + if key=='alpha'then local fx=screen:gen3AnimSpecialMonState(b);return fx and fx.alpha or 1 end + if key=='hFlip'then return false end + if key=='darken'or key=='flash'then return 0 end + if key=='displayHp'then return b.shownHP or b.mon.hp end + if key=='displayMaxHp'then return b.mon.stats.hp end + if key=='displayLevel'then return b.mon.level end + if key=='displayStatus'then local observed=Coords.battler(d.view,id);return observed and observed.status end + if key=='displayExp'then return screen:expFraction(b.mon)end +end +function A.idOf(key) + if key=='attacker_side'then + local _,screen=context();local anim=screen and screen.gen3Anim + return anim and anim.attackerPosition or 0 + end + return Coords.idOf(key) +end +A.sideOf,A.isDouble,A.setDouble=Coords.sideOf,Coords.isDouble,Coords.setDouble +A.coords,A.subpriority,A.bgPriorityRank=Coords.coords,Coords.subpriority,Coords.bgPriorityRank +A.monDrawOrder,A.particleBand,A.battlerIds=Coords.monDrawOrder,Coords.particleBand,Coords.ids +function A.x(value) + local _,screen=context();local anim=screen and screen.gen3Anim + value=tonumber(value)or 0 + return anim and anim.attackerIsPlayer==false and -value or value +end +function A.present(key) + local d,screen=context();local id=A.idOf(key) + if not d or id==nil or id>=2 and not Coords.isDouble(d.view)or not battler(screen,id)then return nil end + if not d.present[id]then + d.overrides[id]={} + d.present[id]=setmetatable({},{__index=function(_,k)return read(d,id,k)end, + __newindex=function(_,k,v) + assert(context()==d,'native battle presentation retired') + assert(k~='id'and k~='side','native battler identity is read-only') + if v~=nil and(numeric[k]or type(v)=='number')then assert(number(v),'invalid presentation number')end + d.overrides[id][k]=v + end}) + end + return d.present[id] +end +function A.battlerCenter(key) + local p=A.present(key);local base=Coords.coords(nil,A.idOf(key)) + if not p or not base then return nil end + return base.x+(p.ox or 0),base.y+(p.oy or 0) +end +function A.shownBattler(key,fallback)local p=A.present(key);return p and p.shown or fallback end +function A.setShown(key,value)local p=A.present(key);if p then p.shown=value;return true end;return false end +function A.ballIdOf(key) + local d,screen=context();local id=A.idOf(key);local b=d and id and battler(screen,id) + if not b then return nil end + return require('src.battle.Gen3BallAnim').ballIndex(screen.data,b.mon.ball or'POKE_BALL',screen.data.items) +end +function A.displayHpRatio(key,fallback) + local p=A.present(key);local mon=fallback and fallback.mon + local hp=p and p.displayHp or mon and mon.hp or 0 + local maximum=math.max(1,p and p.displayMaxHp or mon and mon.maxHp or 1) + return math.max(0,math.min(1,hp/maximum)),hp,maximum +end +function A.displayExpRatio(key,fallback) + local p=A.present(key or'player') + return p and p.displayExp or 0,p and p.displayLevel or fallback and fallback.mon and fallback.mon.level or 1 +end +function A.hpTweening() + local _,screen=context();if not screen then return false end + for _,id in ipairs(Coords.ids())do + local b=battler(screen,id) + if b and b.shownHP and(b.shownHP~=b.mon.hp or b.draining or(b.drainHold or 0)>0)then return true end + end + return false +end +local function effect() + local d=context();if not d then return end + if not d.effect then d.effect=require('src.render.BattleScreenEffect').new()end + return d.effect +end +function A.setScreenEffect(opts)local e=effect();return e and e:set(opts)or false end +function A.screenEffect()local e=effect();return e and e.value end +function A.beginScreenEffect()local e=effect();return e and e:begin()or false end +function A.endScreenEffect()local e=effect();return e and e:finish()or false end +function A.busy() + local d,screen=context() + return d~=nil and(screen.gen3AnimPlaying==true or screen.gen3BallPlaying==true + or A.hpTweening()or screen.expFilling==true or next(d.tasks)~=nil)or false +end +function A.setSeqBusy(value)local d=context();if d then d.seqBusy=value==true end end +function A.seqBusy()local d=context();return d and d.seqBusy==true or false end +function A.tweenStage(frames,onStep,onComplete) + local d=context();if not d then return nil,'no admitted native battle'end + assert(number(frames)and frames>=0,'invalid presentation duration') + d.nextTask=d.nextTask+1 + local task={id=d.nextTask,frames=0,total=math.max(1,math.ceil(frames)),step=owned(onStep),done=owned(onComplete)} + d.tasks[task.id]=task + return task +end +function A._nativeStep(screen,dt) + if not Clock.active(screen)then return false,'animation update belongs to its native battle phase'end + local d,actual=context();if actual~=screen or screen.game.stack:top()~=screen then return false end + return Clock.once(screen,'source.presentation',function() + local clip=d.clip + if clip then + if clip.player~=screen.gen3Anim or clip.serial~=screen.gen3Anim.presentationSerial then + d.clip,d.onEnd=nil,nil + elseif screen.gen3AnimPlaying then + require('src.core.Game3')._withCallbackOwner(nil,screen.updateMovePresentation,screen) + end + end + -- Snapshot the IDs: a callback may schedule another tween, which starts + -- next frame, or retire the admission, which cancels the remaining work. + local ids={};for id in pairs(d.tasks)do ids[#ids+1]=id end;table.sort(ids) + for _,id in ipairs(ids)do + if context()~=d or screen.game.stack:top()~=screen then return false end + local task=d.tasks[id] + if task then + task.frames=task.frames+1 + callback(task.step,math.min(1,task.frames/task.total),task) + if context()~=d then return false end + if task.frames>=task.total then d.tasks[id]=nil;callback(task.done)end + end + end + if context()==d and screen.game.stack:top()==screen and d.onEnd and not screen.gen3AnimPlaying then + local done=d.onEnd;d.onEnd,d.clip=nil,nil;callback(done) + elseif context()==d and not screen.gen3AnimPlaying then d.clip=nil + end + return true + end) +end +function A._holdsNativeQueue(screen) + local d,actual=context();local clip=d and d.clip + return actual==screen and clip~=nil and clip.player==screen.gen3Anim + and clip.serial==screen.gen3Anim.presentationSerial and screen.gen3AnimPlaying==true +end +function A.update(dt) + local _,screen=context() + if not screen or not Clock.active(screen)then return false,'animation update belongs to its native battle phase'end + if screen.game.stack:top()~=screen then return false,'native battle is covered by another state'end + A._nativeStep(screen,dt) + if Battle._screen()~=screen or screen.game.stack:top()~=screen then return false,'native battle admission retired'end + -- Use the owner's dt, not a second caller-provided clock. The original + -- native update and this hold consume the same once-only display steps. + require('src.core.Game3')._withCallbackOwner(nil,screen.updatePresentation,screen,Clock.delta(screen)) + return true +end +function A.launchMove(moveId,opts) + opts=opts or{};local d,screen=context() + if not d then return false,'no admitted native battle'end + if screen.game.stack:top()~=screen then return false,'native battle is covered by another state'end + if screen.gen3AnimPlaying then return false,'native animation is already active'end + local native=d.ids:native('moves',moveId);local anim=screen.gen3Anim + if not native or not anim or not anim:has(native,opts)then return false,'no native animation record'end + if opts.miss then return false,'native miss presentation requires its authored command'end + local attacker=A.idOf(opts.attackerId or opts.attackerSide or'player') + local target=A.idOf(opts.targetId or opts.targetSide or'enemy') + if attacker==nil or target==nil or not battler(screen,attacker)or not battler(screen,target)then + return false,'animation requires admitted native battlers' + end + local done=owned(opts.onEnd) + if not require('src.core.Game3')._withCallbackOwner(nil,anim.start,anim,native,attacker%2==0,attacker,target,opts,screen)then return false end + screen.gen3AnimPlaying=true;d.onEnd=done;d.clip={player=anim,serial=anim.presentationSerial} + return true +end +function A.reset() + retire();Coords._service.release();return true +end +function A._nativeExit(screen) + if current and current.screen[1]==screen then A.reset()end +end +-- Applied around the existing native sprite draw, preserving its scanlines, +-- tints, substitutes and affine effects. Only source overrides compose here; +-- querying present() never feeds native effects back into themselves. +function A._drawBattler(screen,b,body,...) + local d,actual=context();local id=b.position or(b.isPlayer and 0 or 1) + local p=d and actual==screen and d.overrides[id] + if not p or next(p)==nil then return body(screen,b,...)end + if p.visible==false or p.invisible or p.battlerInvisible or p.blinkHidden then return end + if not(p.hFlip or(p.ox or 0)~=0 or(p.oy or 0)~=0 or(p.rotation or 0)~=0 + or(p.scale or 1)~=1 or(p.sx or 1)~=1 or(p.sy or 1)~=1 or(p.alpha or 1)~=1)then + return body(screen,b,...) + end + local args={...};local x,y,scale=args[1],args[2],args[3]or 1 + local img=screen:battlerPic(b);local w,h=img:getDimensions() + local cx,cy=x+w*scale/2,y+h*scale/2 + local ok,result=Scope.call(function() + local g=love.graphics + g.translate(cx+(p.ox or 0),cy+(p.oy or 0));g.rotate(p.rotation or 0) + g.scale((p.scale or 1)*(p.sx or 1)*(p.hFlip and -1 or 1),(p.scale or 1)*(p.sy or 1)) + g.translate(-cx,-cy) + local r,green,blue,a=g.getColor();g.setColor(r,green,blue,a*(p.alpha or 1)) + return body(screen,b,x,y,scale) + end) + if not ok then error(result,0)end + return result +end +A._service={release=A.reset,poll=function()context()end,releaseOwner=function(_,owner) + if current and current.users[owner]then A.reset()end +end} +require('src.core.game3.asset_stream').register(A._service) +return A diff --git a/src/core/game3/battle/anim_coords.lua b/src/core/game3/battle/anim_coords.lua new file mode 100644 index 000000000..6f8611cf7 --- /dev/null +++ b/src/core/game3/battle/anim_coords.lua @@ -0,0 +1,131 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Authored 240x160 sprite anchors, separate from opaque image bounds. Imported +-- cartridge coordinates override the standard anchors on the native owner. +local Battle=require('src.core.game3.battle') +local C={SINGLES={},DOUBLES={}} +C.SUBPRIORITY={[0]=30,40,20,50} +C.BG_PRIORITY_RANK={[0]=2,1,1,2} +C.DRAW_ORDER_SINGLES={1,0} +C.DRAW_ORDER_DOUBLES={3,1,0,2} +C.battleState=Battle.getState +local function point(x,y)return{x=x,y=y,[1]=x,[2]=y}end +for id,p in pairs{[0]={72,80},{176,40},{48,40},{112,80}}do C.SINGLES[id]=point(p[1],p[2])end +for id,p in pairs{[0]={32,80},{200,40},{90,88},{152,32}}do C.DOUBLES[id]=point(p[1],p[2])end +local double,overrides,binding +local users={} +local function recordOwner() + local host=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + local owner=host and host.currentMod or facade and facade._callbackOwner() + if owner then users[owner]=true end +end +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +function C.sideOf(id)return type(id)=='number'and(id%2==0 and'player'or'enemy')or nil end +function C.partner(id)return type(id)=='number'and(id+2)%4 or nil end +function C.fixedId(key) + if type(key)=='number'and key==key and key>=0 and key<=3 then return math.floor(key)end + return({player=0,enemy=1})[key] +end +function C.idOf(key) + if type(key)=='table'then key=key.id or key.side end + if binding and binding[key]~=nil then return binding[key]end + return C.fixedId(key) +end +function C.bind(attacker,target) + recordOwner() + if attacker==nil then binding=nil;return end + attacker=C.fixedId(attacker);target=C.fixedId(target) + binding={} + if target then binding[C.sideOf(target)]=target end + if attacker then binding[C.sideOf(attacker)]=attacker end +end +function C.withBattler(key,fn,...) + local previous=binding;local id=C.idOf(key) + binding={player=previous and previous.player or 0,enemy=previous and previous.enemy or 1} + if id then binding[C.sideOf(id)]=id end + local result=pack(pcall(fn,C.sideOf(id),...));binding=previous + if not result[1]then error(result[2],0)end + return unpack(result,2,result.n) +end +function C.sideArg(fn,index) + index=index or 1 + return function(...) + local args=pack(...);local id=C.idOf(args[index]) + if type(args[index])~='number'or not id then return fn(...)end + return C.withBattler(id,function(side) + args[index]=side;return fn(unpack(args,1,args.n)) + end) + end +end +function C.setDouble(value)recordOwner();if value==nil then double=nil else double=not not value end end +function C.isDouble(st) + if double~=nil then return double end + st=st or Battle.getState();return st and st.double==true or false +end +function C.setCoordinateOverrides(value)recordOwner();local previous=overrides;overrides=value;return previous end +function C.coords(st,key) + local id=C.idOf(key);if id==nil then return nil end + local p=overrides and overrides[id] + if p then return p end + local screen=Battle._screen(st) + local imported=screen and screen.data.constants.gen3BattlerCoords + local row=imported and(C.isDouble(st)and imported.doubles or imported.singles) + p=row and row[id+1]or(C.isDouble(st)and C.DOUBLES or C.SINGLES)[id] + return point(p.x or p[1],p.y or p[2]) +end +function C.ids(st)return C.isDouble(st)and{0,1,2,3}or{0,1}end +function C.battler(st,key) + st=st or Battle.getState();local id=C.idOf(key) + if not st or id==nil then return nil end + return st.battlers and st.battlers[id]or(id==0 and st.player or id==1 and st.enemy or nil) +end +function C.spritePresent(st,key) + local id=C.idOf(key);if id==nil or id>=2 and not C.isDouble(st)then return false end + local b=C.battler(st,id) + if not st then st=Battle.getState()end + if st and st.absent and st.absent[id]then return false end + return b==nil and id<2 or b~=nil and not b.absent and not b.fainted and(b.mon==nil or(b.mon.hp or 0)>0) +end +function C.subpriority(key)return C.SUBPRIORITY[C.idOf(key)]or 40 end +function C.bgPriorityRank(key)return C.BG_PRIORITY_RANK[C.idOf(key)]or 1 end +function C.monDrawOrder(st)return C.isDouble(st)and{3,1,0,2}or{1,0}end +function C.monBehindZ(key,st) + local id=C.idOf(key) + for i,slot in ipairs(C.monDrawOrder(st))do if slot==id then return(i-1)*100+95 end end + return 95 +end +function C.particleBand(k,st) + k=tonumber(k)or 0 + if k<=0 then return 0,99 end + return k*100+1,k>=#C.monDrawOrder(st)and 999 or k*100+99 +end +function C.layerZ(sub,monbg,bgPriority,st) + sub=tonumber(sub)or 0 + local order=C.monDrawOrder(st);local band=0 + for i=#order,1,-1 do + local id=order[i] + local front + if monbg and rawget(monbg,id)then front=((bgPriority and bgPriority[C.bgPriorityRank(id)])or 2)>=2 + else front=sub=3 then return math.max(1,math.min(98,98-sub))end + return C.layerZ(sub,vm and vm._monbg,vm and vm._bgPrio,st) +end +function C.idTable(values) + local result=setmetatable({},{__index=function(t,k)local id=C.idOf(k);if id~=nil then return rawget(t,id)end end, + __newindex=function(t,k,v)local id=C.idOf(k);rawset(t,id~=nil and id or k,v)end}) + for k,v in pairs(values or{})do result[k]=v end + return result +end +C._service={release=function()double,overrides,binding=nil,nil,nil;users={}end} +C._service.poll=function()if not Battle.isActive()then C._service.release()end end +C._service.releaseOwner=function(_,owner)if users[owner]then C._service.release()end end +require('src.core.game3.asset_stream').register(C._service) +return C diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 14c98eea7..0809268ee 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -36,7 +36,7 @@ Loader.__index = Loader -- stream service is available but not yet driven by loader retirement. local function retireBattlePresentation() local first - for _,name in ipairs({'src.core.game3.battle','src.core.game3.pokemon','src.core.game3.mon_anim'})do + for _,name in ipairs({'src.core.game3.battle','src.core.game3.battle.anim','src.core.game3.battle.anim_coords','src.core.game3.pokemon','src.core.game3.mon_anim'})do local module=package.loaded[name];local service=type(module)=='table'and module._service if type(service)=='table'and type(service.release)=='function'then local ok,why=pcall(service.release,service) diff --git a/src/mods/ModSeams.lua b/src/mods/ModSeams.lua index 564eb179c..a6b0c086b 100644 --- a/src/mods/ModSeams.lua +++ b/src/mods/ModSeams.lua @@ -116,7 +116,7 @@ function ModSeams.open(owner) local function require2(name, ...) local had = package.loaded[name] local result = real(name, ...) - if name=='src.core.game3.battle' then + if name=='src.core.game3.battle' or type(name)=='string' and name:match('^src%.core%.game3%.battle%.') then local facade=package.loaded['src.core.Game3'] or real('src.core.Game3') if facade and facade._observeMod then facade._observeMod(owner)end local watched=false diff --git a/src/render/BattleScreenEffect.lua b/src/render/BattleScreenEffect.lua new file mode 100644 index 000000000..ff45819cf --- /dev/null +++ b/src/render/BattleScreenEffect.lua @@ -0,0 +1,62 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Repeated begin calls reapply an effect within one render seam. Nested seams +-- each restore their caller's shader, including error paths guarded by Scope. +local Scope=require('src.render.GraphicsScope') +local M={};M.__index=M +local codes={invert=1,fade_white=2,fade_black=3,darken=3,grayscale=4,custom_blend=5} +local source=[[ +extern number mode; +extern number amount; +extern vec3 blendColour; +vec4 effect(vec4 colour, Image image, vec2 uv, vec2 screen) { + vec4 pixel=Texel(image,uv)*colour; + vec3 target=pixel.rgb; + if(mode<1.5) target=vec3(1.0)-pixel.rgb; + else if(mode<2.5) target=vec3(1.0); + else if(mode<3.5) target=vec3(0.0); + else if(mode<4.5) target=vec3(dot(pixel.rgb,vec3(0.299,0.587,0.114))); + else target=blendColour; + pixel.rgb=mode<1.5 ? target : mix(pixel.rgb,target,amount); + return pixel; +} +]] +function M.new() + return setmetatable({value={type='none',coeff=0,targetColor={1,1,1}}, + bindings=setmetatable({},{__mode='k'}),outside={}},M) +end +function M:set(opts) + if not opts or opts.type==false or opts.type=='none'then + self.value={type='none',coeff=0,targetColor={1,1,1}};return true + end + local kind=opts.type or'invert' + assert(codes[kind],'unsupported battle screen effect') + local amount=opts.coeff==nil and 1 or opts.coeff + assert(type(amount)=='number'and amount==amount and amount>=0 and amount<=1,'invalid battle effect strength') + local colour=opts.targetColor or{1,1,1};local copy={} + for i=1,3 do local v=colour[i];assert(type(v)=='number'and v==v and v>=0 and v<=1,'invalid battle effect colour');copy[i]=v end + self.value={type=kind,coeff=amount,targetColor=copy};return true +end +function M:begin() + local value=self.value;local code=codes[value.type] + if not code then return false end + local g=love.graphics + if not self.shader then self.shader=g.newShader(source)end + local token=Scope.token()or self.outside + local previous=self.bindings[token] + if not previous then previous={shader=g.getShader()};self.bindings[token]=previous end + self.shader:send('mode',code);self.shader:send('amount',value.coeff) + self.shader:send('blendColour',value.targetColor) + g.setShader(self.shader);return true +end +function M:finish() + local token=Scope.token()or self.outside;local previous=self.bindings[token] + if not previous then return false end + self.bindings[token]=nil;love.graphics.setShader(previous.shader);return true +end +function M:release() + -- A surrounding render guard restores a shader still in use on an error + -- path. Do not destroy a borrowed binding during that unwind. + if self.shader and love.graphics.getShader()~=self.shader then self.shader:release();self.shader=nil end + self.bindings=setmetatable({},{__mode='k'}) +end +return M diff --git a/src/render/GraphicsScope.lua b/src/render/GraphicsScope.lua new file mode 100644 index 000000000..396978870 --- /dev/null +++ b/src/render/GraphicsScope.lua @@ -0,0 +1,56 @@ +-- Fence a native render seam, including leaked pushes and canvas changes. +local S = {} +local current +-- Opaque lifetime for paired helpers inside a guarded render callback. +function S.token() return current end +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +function S.call(fn, ...) + local G = love.graphics + local canvas = pack(G.getCanvas()) + local realPush, realPop = G.push, G.pop + local before = G.getStackDepth and G.getStackDepth() + local pushed, why = pcall(realPush, "all") + if not pushed then + return false, why + end + local depth = 0 + G.push = function(...) + local result = pack(realPush(...)) + depth = depth + 1 + return unpack(result, 1, result.n) + end + G.pop = function(...) + if depth <= 0 then + error("render callback cannot pop caller graphics state", 0) + end + local result = pack(realPop(...)) + depth = depth - 1 + return unpack(result, 1, result.n) + end + local parent = current + current = {} + local out = pack(pcall(fn, ...)) + current = parent + G.push, G.pop = realPush, realPop + if before and G.getStackDepth then + local current = G.getStackDepth() + if current < before + 1 then + G.setCanvas(unpack(canvas, 1, canvas.n)) + return false, "render callback removed its graphics guard" + end + depth = current - before - 1 + end + for _ = 1, depth do + pcall(realPop) + end + local restored, err = pcall(realPop) + G.setCanvas(unpack(canvas, 1, canvas.n)) + if not restored then + return false, err + end + return unpack(out, 1, out.n) +end +return S diff --git a/tests/engine/native_battle_animation.lua b/tests/engine/native_battle_animation.lua new file mode 100644 index 000000000..f7d3c2172 --- /dev/null +++ b/tests/engine/native_battle_animation.lua @@ -0,0 +1,137 @@ +-- 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 A=require('src.core.game3.battle.anim') +local C=A.Coords +local Clock=require('src.battle.PresentationClock') +local base=Battle.update +T.eq(A.update(1/60),false,'standalone callers cannot start an animation clock') +T.eq(A.launchMove(33),false);T.eq(A.present(0),nil) +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + A.reset();Battle._service.release() + local f=require('tools.battle-source-runtime.fixture').new(edition) + local game=f.game;package.loaded['src.core.Game']=game + game.data.constants.gen3BattleAnimGfx={} + game.data.moves.MOVE_033.anim={duration=4,shakes={{at=0,duration=4,x=2,y=1}}} + local b=f.fresh('TACKLE').battle;b.enter=false;game.stack:push(b);b.phase='QA_IDLE' + local st=Battle.getState();local p=A.present(0) + T.eq(A.present('player'),p);T.eq(p.displayHp,400);T.eq(p.displayMaxHp,400) + T.eq(p.id,0);T.eq(p.side,'player');T.eq(A.present(2),nil) + T.raises(function()p.id=1 end,'read-only') + T.raises(function()p.alpha=0/0 end,'invalid presentation') + C.setDouble(true);T.eq(C.isDouble(),true);C.setDouble(nil);T.eq(C.isDouble(),false) + C.bind(2,3);T.eq(C.idOf('player'),2);T.eq(C.idOf('enemy'),3) + local got=pack(C.withBattler(0,function(side,a,z)T.eq(side,'player');T.eq(C.idOf(side),0);return a,z,nil end,7,8)) + T.eq(got.n,3);T.eq(got[1],7);T.eq(got[2],8) + T.eq(C.idOf('player'),2,'nested binding is restored including trailing nils') + T.raises(function()C.withBattler(0,function()error('binding failure')end)end,'binding failure') + T.eq(C.idOf('player'),2) + local arg=C.sideArg(function(a,side,z)T.eq(a,4);T.eq(side,'enemy');T.eq(C.idOf(side),3);T.eq(z,nil);return true,nil end,2) + T.eq(pack(arg(4,3,nil)).n,2);C.bind(nil) + T.same(C.monDrawOrder(st),{1,0});T.eq(C.subpriority(0),30) + T.eq(C.bgPriorityRank(3),2);T.eq(C.monBehindZ(0),195) + T.same(pack(C.particleBand(2)),{n=2,201,999}) + game.data.constants.gen3BattlerCoords={singles={{x=73,y=81},{x=177,y=41}}} + local x,y=A.battlerCenter(0);T.eq(x,73);T.eq(y,81,'authored anchors use the admitted cartridge table') + local override={[0]={x=25,y=27}} + T.eq(C.setCoordinateOverrides(override),nil);T.eq(C.coords(nil,0),override[0]);C.setCoordinateOverrides(nil) + local poseFrames,poseDt=0 + b.player.picAnim={update=function(_,dt)poseFrames=poseFrames+1;poseDt=dt end} + local done=0 + T.eq(A.launchMove(33,{attackerId=0,targetId=1,onEnd=function()done=done+1 end}),true) + T.eq(A.launchMove(33),false,'an active native clip cannot be overwritten') + T.eq(b.gen3Anim.attackerPosition,0);T.eq(b.gen3Anim.targetPosition,1) + local ball=require('src.battle.Gen3BallAnim').new(game.data,{sendOut='opponent',battler=b.enemy}) + b.gen3Ball,b.gen3BallPlaying=ball,true + b.waitFrames=3;b.queue={{fn=function()error('held battle command executed')end}} + local frames=b.frame + local stepped,completed=0,0 + A.tweenStage(2,function(u,t)stepped=stepped+1;p.ox=12*u;T.eq(t.frames,stepped)end,function()completed=completed+1 end) + Battle.update=function(dt) + T.eq(A.update(999),true);T.eq(A.update(-999),true) + base(dt);return base(dt) + end + b:update(1/60) + T.eq(b.frame,frames+1);T.eq(b.gen3Anim.frame,1);T.eq(ball.frame,1) + T.eq(poseFrames,1);T.eq(poseDt,1/60,'a source argument cannot replace the native dt') + T.eq(b.waitFrames,3,'a source clip owns its own hold');T.eq(#b.queue,1);T.eq(stepped,1);T.eq(completed,0) + T.eq(p.ox,6);T.eq(A.battlerCenter(0),79) + Battle.update=function()return A.update(0)end + b:update(1/60) + T.eq(b.frame,frames+2);T.eq(b.gen3Anim.frame,2);T.eq(b.waitFrames,3) + T.eq(stepped,2);T.eq(completed,1);T.eq(#b.queue,1,'a source ending hold advances display without combat') + for i=1,300 do if not b.gen3AnimPlaying then break end;b:update(1/60)end + T.eq(b.gen3AnimPlaying,false);T.eq(done,1,'source completion follows the real native clip') + for i=1,3 do b:update(1/60)end;T.eq(b.waitFrames,0) + b.player.mon.hp=380;b.player.shownHP=400;b.draining=true + b:update(1/60) + local shown=b.player.shownHP;T.check(shown<400);T.eq(b.player.mon.hp,380,'display never applies damage') + T.eq(A.displayHpRatio(0),shown/400);T.eq(A.hpTweening(),true) + local heldFrame=b.frame;local overlay={};game.stack:push(overlay) + T.eq(A.update(1/60),false);T.eq(b:update(1/60),false) + T.eq(b.frame,heldFrame);T.eq(b.player.shownHP,shown);game.stack:pop() + -- Retire through a real session replacement; neither old poses nor queued + -- callbacks may write to the new admission, even if a save is restored. + local stale=p;local cancelled=0 + A.tweenStage(1,function()cancelled=cancelled+1 end) + local save=game.save;game.save={};T.neq(A.present(0),stale,'a replacement save creates a new presentation identity');game.save=save + T.raises(function()stale.ox=10 end,'retired') + b:update(1/60);T.eq(cancelled,0);T.neq(A.present(0),stale) + b.player.picAnim=nil;Battle.update=base + game.stack.states={};f.close();A._service.poll() + local function nativeTrace(enabled) + package.loaded['src.core.game3.battle.anim']=enabled and A or nil + local fixture=require('tools.battle-source-runtime.fixture').new(edition) + local g=fixture.game;package.loaded['src.core.Game']=g + g.data.constants.gen3BattleAnimGfx={} + g.data.moves.MOVE_033.anim={duration=4,shakes={{at=0,duration=4,x=2,y=1}}} + local state=fixture.fresh('TACKLE').battle;state.enter=false;g.stack:push(state) + state.player.shownHP,state.enemy.shownHP=400,400 + state.phase='messages';state.current=nil + state.queue={{fn=function() + local s=state + s.gen3Anim:start('MOVE_033',true,0,1,nil,s);s.gen3AnimPlaying=true + s.waitFrames=s.gen3Anim.total + end},{fn=function() + local s=state + s.enemy.mon.hp=370;s.enemy.shownHP=400;s.draining=true + end},{fn=function() + local s=state + s.gen3Ball=require('src.battle.Gen3BallAnim').new(g.data,{sendOut='opponent',battler=s.enemy}) + s.gen3BallPlaying=true;s.waitFrames=80 + end}} + local trace={} + for frame=1,180 do + state:update(1/60) + trace[frame]={frame=state.frame,phase=state.phase,queue=#state.queue,wait=state.waitFrames, + hp=state.enemy.mon.hp,shown=state.enemy.shownHP,draining=state.draining, + anim=state.gen3Anim.frame,playing=state.gen3AnimPlaying, + ball=state.gen3Ball and state.gen3Ball.frame,ballPlaying=state.gen3BallPlaying} + end + g.stack.states={};fixture.close();Battle._service.release();A.reset() + return trace + end + local baseline=nativeTrace(false);local adapted=nativeTrace(true) + for frame=1,#baseline do + local different=false + for key,value in pairs(baseline[frame])do if adapted[frame][key]~=value then different=true end end + for key,value in pairs(adapted[frame])do if baseline[frame][key]~=value then different=true end end + if different then print('FIRST_NATIVE_TRACE_DIFFERENCE',edition,frame, + require('src.link.Json').encode(baseline[frame]),require('src.link.Json').encode(adapted[frame]));break end + end + T.same(adapted,baseline,'an imported animation namespace preserves native queue timing '..edition) +end +Battle.update=base;A.reset();Battle._service.release() +-- Failed native work remains failed throughout its phase; a source wrapper +-- cannot swallow it and retry. The next real frame can recover normally. +local screen={};local attempts=0 +T.raises(function()Clock.run(screen,1/60,function() + local function fail()attempts=attempts+1;error('display failure')end + pcall(Clock.once,screen,'fx',fail);pcall(Clock.once,screen,'fx',fail) +end)end,'display failure') +T.eq(attempts,1);T.eq(Clock.active(screen),false) +T.eq(Clock.run(screen,1/60,function()return Clock.once(screen,'fx',function()return true,nil,4 end)end),true) +T.finish('native battle animation ownership') diff --git a/tests/engine/native_version_layout.lua b/tests/engine/native_version_layout.lua new file mode 100644 index 000000000..c2a5fcee9 --- /dev/null +++ b/tests/engine/native_version_layout.lua @@ -0,0 +1,19 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.harness') +local Version=require('src.core.GameVersion') +local layouts={firered='frlg',leafgreen='frlg',emerald='rse',ruby='rse',sapphire='rse'} +local original=Version.get() +for id,info in pairs(Version.VERSIONS)do + local prefix,suffix,manifest=info.cachePrefix,info.saveSuffix,info.manifest + T.eq(Version.layout(id),info.layout or layouts[id],id..' metadata layout') + T.eq(Version:layout(id),info.layout or layouts[id],id..' colon query') + Version.set(id);T.eq(Version.layout(),info.layout or layouts[id],id..' current query') + T.eq(Version.get(),id);T.eq(info.cachePrefix,prefix);T.eq(info.saveSuffix,suffix);T.eq(info.manifest,manifest) +end +Version.VERSIONS.QA_LAYOUT={id='QA_LAYOUT',generation=3,layout='custom'} +T.eq(Version.layout('QA_LAYOUT'),'custom','an explicit registered layout takes precedence') +Version.VERSIONS.QA_LAYOUT=nil +T.eq(Version.layout('UNKNOWN'),nil,'unknown IDs cannot inherit the current cartridge layout') +Version.set(original) +T.finish('native registered version layout queries') diff --git a/tools/battle-animation-runtime/coords-check.lua b/tools/battle-animation-runtime/coords-check.lua new file mode 100644 index 000000000..9401a88d9 --- /dev/null +++ b/tools/battle-animation-runtime/coords-check.lua @@ -0,0 +1,42 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Optional local contract comparison. The oracle is supplied by the caller; +-- no external engine, private package or cartridge is distributed here. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local C=require('src.core.game3.battle.anim_coords') +local reference=assert(loadfile(assert(os.getenv('BATTLE_COORDS_ORACLE'))))() +for _,double in ipairs{false,true}do + local st={double=double,battlers={}} + C.setDouble(double);reference.setDouble(double) + T.same(C.monDrawOrder(st),reference.monDrawOrder(st)) + T.same(C.ids(st),reference.ids(st)) + for id=0,3 do + T.same(C.coords(st,id),reference.coords(st,id)) + T.eq(C.subpriority(id),reference.subpriority(id)) + T.eq(C.bgPriorityRank(id),reference.bgPriorityRank(id)) + T.eq(C.monBehindZ(id,st),reference.monBehindZ(id,st)) + T.eq(C.partner(id),reference.partner(id)) + for _,hp in ipairs{0,1}do + st.battlers[id]={mon={hp=hp}} + T.eq(C.spritePresent(st,id),reference.spritePresent(st,id)) + end + end + for k=0,5 do + local a,b=C.particleBand(k,st);local x,y=reference.particleBand(k,st) + T.eq(a,x);T.eq(b,y) + end + for sub=0,255 do + for pri=0,3 do + for _,vm in ipairs{{},{_monbg={[0]=true,[3]=true},_bgPrio={[1]=1,[2]=3}}}do + T.eq(C.zFor(pri,sub,vm,st),reference.zFor(pri,sub,vm,st)) + end + end + end +end +C.setDouble(nil);reference.setDouble(nil) +for attacker=0,3 do for target=0,3 do + C.bind(attacker,target);reference.bind(attacker,target) + for _,side in ipairs{'player','enemy'}do T.eq(C.idOf(side),reference.idOf(side))end +end end +C.bind(nil);reference.bind(nil) +T.finish('authored battler coordinate contract') diff --git a/tools/battle-animation-runtime/host/conf.lua b/tools/battle-animation-runtime/host/conf.lua new file mode 100644 index 000000000..7aea1d29a --- /dev/null +++ b/tools/battle-animation-runtime/host/conf.lua @@ -0,0 +1,6 @@ +function love.conf(t) + t.identity=assert(os.getenv('POKEPORT_IDENTITY')) + t.window.width,t.window.height=480,320;t.window.vsync=0;t.window.highdpi=false + t.modules.audio,t.modules.joystick,t.modules.physics=false,false,false + t.graphics=t.graphics or{};t.graphics.renderers={assert(os.getenv('POKEPORT_GRAPHICS_BACKEND'))} +end diff --git a/tools/battle-animation-runtime/host/main.lua b/tools/battle-animation-runtime/host/main.lua new file mode 100644 index 000000000..a6381f06b --- /dev/null +++ b/tools/battle-animation-runtime/host/main.lua @@ -0,0 +1,159 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +io.stdout:setvbuf('no') +local root=assert(os.getenv('BATTLE_SOURCE_ENGINE')) +package.path=root..'/?.lua;'..root..'/?/init.lua;./?/init.lua;'..package.path +function love.errorhandler(err)io.stderr:write(debug.traceback(tostring(err),2)..'\n');return function()return 1 end end +function love.load() + local T=require('tests.harness').suite('native battle animation GPU') + local A=require('src.core.game3.battle.anim') + local Battle=require('src.core.game3.battle') + local Scope=require('src.render.GraphicsScope') + local g=love.graphics + local nativeSource=debug.getinfo(require('src.battle.BattleState').update,'S').source + T.eq(nativeSource,'@'..root..'/src/battle/BattleState.lua') + local renderer,version,vendor,device=g.getRendererInfo() + local backend=assert(os.getenv('POKEPORT_GRAPHICS_BACKEND')) + T.check(renderer:lower():find(backend,1,true)~=nil,'actual requested GPU backend') + T.eq(love.filesystem.getIdentity(),os.getenv('POKEPORT_IDENTITY')) + love.filesystem.createDirectory('tests/fixture_data/assets') + local pixels=love.image.newImageData(64,64) + pixels:mapPixel(function(x,y) + if x<7 or y<5 or x>56 or y>57 then return 0,0,0,0 end + if y<20 and x<37 then return 1,0,91/255,1 end + if x>40 and y>35 then return 0,1,125/255,1 end + return .2,.3,.9,1 + end) + for _,side in ipairs{'back','front'}do pixels:encode('png','tests/fixture_data/assets/fixmon_a_'..side..'.png')end + pixels:release() + local comparisons,readbacks=0,0 + local function read(canvas) + local data=g.readbackTexture and g.readbackTexture(canvas)or canvas:newImageData() + local bytes=data:getString();data:release();readbacks=readbacks+1;return bytes + end + local function frame(canvas,draw) + local ok,why=Scope.call(function() + g.setCanvas(canvas);g.origin();g.setShader();g.setScissor();g.clear(0,0,0,0);g.setColor(1,1,1,1);draw() + end) + assert(ok,why);return read(canvas) + end + local function same(a,b,label,tolerance) + comparisons=comparisons+1 + if not tolerance then return T.eq(a,b,label)end + local error=0 + for i=1,#a do error=math.max(error,math.abs(a:byte(i)-b:byte(i)))end + if error>tolerance then + local count=0 + for i=1,#a,4 do + local bad=false;for c=0,3 do if math.abs(a:byte(i+c)-b:byte(i+c))>tolerance then bad=true end end + if bad then + print('PIXEL_ORACLE_DIFFERENCE',label,((i-1)/4)%160,math.floor((i-1)/4/160), + string.format('%d,%d,%d,%d',a:byte(i,i+3)),string.format('%d,%d,%d,%d',b:byte(i,i+3)));count=count+1;if count==4 then break end + end + end + print('MAX_PIXEL_ERROR',error) + end + T.check(#a==#b and error<=tolerance,label) + end + -- Independent pixel oracle: inverse-map output pixel centres into the + -- already rendered native image, without using graphics transforms. + local function remap(bytes,width,height,map,alpha) + local rows={} + for y=0,height-1 do + local row={} + for x=0,width-1 do + local sx,sy=map(x+.5,y+.5);sx,sy=math.floor(sx),math.floor(sy) + local value='\0\0\0\0' + if sx>=0 and sx=0 and sy