From 84f4eb4b0e6886814c952cb489600522a194b82f Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 11:30:13 +0100 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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/7] 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/7] 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/7] 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()