From e568335920a041a78f8947103caa2e7c1481b9e2 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Tue, 27 Oct 2026 11:28:12 +0100 Subject: [PATCH 1/2] Expose native move animation launch and update phases --- LICENSE-ROXAS2712.md | 88 ++++++++++++++++++++ src/battle/Gen3MoveAnim.lua | 14 ++++ src/core/game3/battle/anim_vm.lua | 101 ++++++++++++++++++++++ tests/engine/native_animation_vm.lua | 120 +++++++++++++++++++++++++++ 4 files changed, 323 insertions(+) create mode 100644 LICENSE-ROXAS2712.md create mode 100644 src/core/game3/battle/anim_vm.lua create mode 100644 tests/engine/native_animation_vm.lua diff --git a/LICENSE-ROXAS2712.md b/LICENSE-ROXAS2712.md new file mode 100644 index 000000000..fbcffd47c --- /dev/null +++ b/LICENSE-ROXAS2712.md @@ -0,0 +1,88 @@ +# Roxas2712 — Fork Contributions + +Version 1.0 · Effective 25 October 2026 + +Copyright © 2026 Roxas2712, to the extent of rights held. +All rights reserved except as expressly granted below or already granted. + +## 1. Scope and ownership + +This notice applies only to original contributions made by Roxas2712 +(GitHub: Roxas2712) to `Roxas2712/Gen2Recomped`, including original additions +within modified files, insofar as he owns the relevant rights. Git history, +pull-request diffs and source provenance identify contributions; a commit +author or a whole-file change alone does not establish ownership of every +part of that file. + +It does not claim ownership of Cedric's work, Gen1Recomp / BOIS CLUB GAMES +work, other contributors' work, third-party dependencies, or ROM-derived +content. Their applicable licences and notices remain in force. It does not +grant permission to distribute the complete combined fork where another +rights holder's permission is required. + +## 2. General permission and reserved rights + +For contributions covered by this notice and not already licensed more +broadly, you may read and study the published source and make private, +non-commercial modifications and builds for your own use. This grants no +general permission to redistribute, sublicense, sell, or offer those +contributions as a hosted service. Other uses require Roxas2712's +written permission, except for the specific permission below and rights +available independently under applicable law. + +Keep the relevant copyright, licence and attribution notices. Do not +represent Roxas2712's contributions as exclusively authored by someone +else. Public source availability alone does not put them in the public +domain or grant an open-source licence. + +## 3. Specific permission for Cedric / UNDERdecoded + +Roxas2712 expressly grants Cedric (Ced; GitHub: UNDERdecoded / +UNDERdecodedHD) a non-exclusive, worldwide, perpetual, royalty-free +permission to use, reproduce, integrate, modify and distribute the covered +contributions that Roxas2712 submits or expressly offers through pull +requests to `UNDERdecoded/Gen2Recomped`, for Cedric's Gen2Recomped / +RetroRecompiled project. This includes its renamed continuation maintained +by Cedric, its official builds, and its commercial operation. + +Cedric may license the incorporated contributions to recipients as part of +that project, subject to preserving this permission's applicable credit and +notice requirements. This permission is not a copyright assignment, is not +exclusive, and does not cover unpublished work, separate repositories, +ModCompass service operation, credentials, or content not offered in those +pull requests. Roxas2712 retains his own rights to his contributions. + +For uses relying on this permission: + +- retain the relevant source copyright and licence notices; +- give visible credit in the project's README or equivalent public project + page, and in the application's existing About / Credits view, if present; +- use at least the following credit, or a substantially equivalent one: + + > Contributions by Roxas2712 — + > https://github.com/Roxas2712 + +A maintained consolidated contributor credit is sufficient; a separate +credit on every screen or for every change is not required. Adapted +contributions must not be attributed exclusively to another author. Other +contributors' credits must also remain intact. + +## 4. Earlier licences and permissions remain effective + +This notice does not revoke, narrow, or retrospectively add conditions to +any permission already validly granted, including MIT / GPL grants, +separate agreements, or permissions arising from previously submitted +pull requests. Where an earlier grant permits a use independently of this +notice, its existing terms govern that use. For such earlier uses, the +visible credit above is requested to the extent it is not already required. + +For subsequent contributions, this is the default statement of Roxas2712's own reserved rights and the specific PR permission, subject to any +applicable existing licence obligations or separately agreed terms. It +does not change another rights holder's licence or guarantee compatibility +of a particular combination of code. + +## 5. No warranty + +To the extent permitted by applicable law, these contributions are provided +as is, without warranty. No support obligation, endorsement, or rights to +third-party code, game assets, names, or trademarks are granted. diff --git a/src/battle/Gen3MoveAnim.lua b/src/battle/Gen3MoveAnim.lua index f6a3a09ea..727ebdc73 100644 --- a/src/battle/Gen3MoveAnim.lua +++ b/src/battle/Gen3MoveAnim.lua @@ -4488,4 +4488,18 @@ function Gen3MoveAnim:draw(battle, skipEvents) end end +-- Source callbacks borrow these exact native phases. Direct source calls +-- outside the controller cannot start or advance an independent script. +local startNative,updateNative=Gen3MoveAnim.start,Gen3MoveAnim.update +function Gen3MoveAnim:start(...) + local source=package.loaded['src.core.game3.battle.anim_vm'] + if source then return source._nativeLaunch(self,startNative,...)end + return startNative(self,...) +end +function Gen3MoveAnim:update(...) + local source=package.loaded['src.core.game3.battle.anim_vm'] + if source then return source._nativeUpdate(self,updateNative,...)end + return updateNative(self,...) +end + return Gen3MoveAnim diff --git a/src/core/game3/battle/anim_vm.lua b/src/core/game3/battle/anim_vm.lua new file mode 100644 index 000000000..d5d9dd4b4 --- /dev/null +++ b/src/core/game3/battle/anim_vm.lua @@ -0,0 +1,101 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Borrow the admitted native move player's launch/update, never another VM. +-- Retained handles contain weak native references and detached observations. +local Battle=require('src.core.game3.battle') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local Call=require('src.compat.gen1.SourceCall') +local M={} +local records=setmetatable({},{__mode='k'}) +local phases={} +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +local function retire(c) + c.retired=true;c.battle=nil;c.refs[1],c.refs[2]=nil,nil;c.users={} +end +local function valid(c) + local screen,player=c.refs[1],c.refs[2] + return not c.retired and screen and player and Battle._screen()==screen + and Battle.getState()==c.battle and screen.gen3Anim==player + and player.presentationSerial==c.serial +end +local function touch(c) + local host=package.loaded['src.mods.Runtime'] + local who=host and host.currentMod or require('src.core.Game3')._callbackOwner() + if who then c.users[who]=true end +end +local function context(screen,player,fresh) + local c=records[player] + if c and not fresh and valid(c)then touch(c);return c end + if c then retire(c)end + c={refs=setmetatable({screen,player},{__mode='v'}),battle=Battle.getState(), + serial=player.presentationSerial,users={}} + local function id(key) + return function() + if not valid(c)then return nil,'native animation handle retired'end + touch(c);return c.refs[2][key]or(key=='attackerPosition'and 0 or 1) + end + end + local attacker,target=id('attackerPosition'),id('targetPosition') + c.view=setmetatable({},{__index=function(_,key) + if not valid(c)then return nil end + touch(c);local native=c.refs[2] + if key=='attackerId'then return attacker end + if key=='targetId'then return target end + if key=='frame'then return native.frame end + if key=='_animBgId'or key=='_bgFade'then + local layer=native:backgroundLayer() + if not layer then return nil end + if key=='_animBgId'then return layer.id end + return{y=1-(layer.brightness or 1)} + end + end,__newindex=function()error('native animation observation is read-only',2)end}) + records[player]=c;touch(c);return c +end +local function invoke(name,view) + local phase=phases[name] + if not phase then return nil,'animation operation belongs to its native phase'end + if phase.context.view~=view or not valid(phase.context)then + return false,'native animation admission changed' + end + touch(phase.context) + return Boundary.invoke('battle.vm.'..name) +end +local function launch(view)return invoke('launch',view)end +local function update(view)return invoke('update',view)end +M.launch,M.update=launch,update +local function dispatch(name,player,body,...) + local method=M[name];local default=name=='launch'and launch or update + if method==default then + if name=='launch'and records[player]then + retire(records[player]);records[player]=nil + end + return body(player,...) + end + local screen=Battle._screen() + if not screen or screen.gen3Anim~=player then return body(player,...)end + local c=context(screen,player,name=='launch') + local stack=screen.game.stack + if stack:top()~=screen then return false,'native animation is covered'end + local args=pack(...) + local old=phases[name];phases[name]={context=c} + local result=pack(pcall(Boundary.dispatch,'battle.vm.'..name,function() + if not valid(c)or screen.game.stack~=stack or stack:top()~=screen then + return false,'native animation admission changed' + end + local values=pack(body(player,unpack(args,1,args.n)));c.serial=player.presentationSerial + return unpack(values,1,values.n) + end,function()return Call.call(M,name,c.view)end)) + phases[name]=old + if not result[1]then error(result[2],0)end + return unpack(result,2,result.n) +end +function M._nativeLaunch(player,body,...)return dispatch('launch',player,body,...)end +function M._nativeUpdate(player,body,...)return dispatch('update',player,body,...)end +local function release(who) + for player,c in pairs(records)do + if not who or c.users[who]then retire(c);records[player]=nil end + end +end +M._service={release=function()release()end,releaseOwner=function(_,who)release(who)end} +require('src.core.game3.asset_stream').register(M._service) +return M diff --git a/tests/engine/native_animation_vm.lua b/tests/engine/native_animation_vm.lua new file mode 100644 index 000000000..10e794ce7 --- /dev/null +++ b/tests/engine/native_animation_vm.lua @@ -0,0 +1,120 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local VM=require('src.core.game3.battle.anim_vm') +local Battle=require('src.core.game3.battle') +local G=require('src.core.Game3') +local launch,update=VM.launch,VM.update +local original=package.loaded['src.core.Game'] +local function pack(...)return{n=select('#',...),...}end +T.eq(launch({}),nil);T.eq(update({}),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.gen3BattleAnimGfx={} + game.data.moves.MOVE_033.anim={duration=4,shakes={{at=0,duration=4,x=2,y=1}}} + local b=f.fresh('TACKLE').battle;b.enter=false;game.stack:push(b);b.phase='QA_IDLE' + local native=b.gen3Anim;T.neq(native,nil) + local l,u=0,0;local handle + VM.launch=function(view) + l=l+1;handle=view;T.neq(view,native);T.eq(view.data,nil);T.eq(view.game,nil) + T.eq(launch(view),true);return launch(view) + end + T.eq(native:start('MOVE_033',true,0,1,nil,b),true);T.eq(l,1) + T.eq(handle:attackerId(),0);T.eq(handle:targetId(),1);T.eq(handle.frame,0) + T.raises(function()handle.frame=50 end,'read-only') + local id=handle.attackerId;local frame=b.frame;local hp=b.enemy.mon.hp + VM.update=function(view)u=u+1;T.eq(view,handle);update(view);return update(view)end + native:update();T.eq(u,1);T.eq(native.frame,1);T.eq(b.frame,frame);T.eq(b.enemy.mon.hp,hp) + T.eq(update(handle),nil);T.eq(native.frame,1,'source query between phases cannot tick') + VM.update=function()return false end;native:update();T.eq(native.frame,1,'held callback preserves native animation time') + VM.update=function()error('source vm update')end + T.raises(function()native:update()end,'source vm update');T.eq(native.frame,1) + VM.update=function(view)game.stack:push({});local a,why=update(view);game.stack:pop();return a,why end + local a,why=native:update();T.eq(a,false);T.eq(why,'native animation admission changed');T.eq(native.frame,1) + VM.update=function(view) + local save=game.save;game.save={};local a,why=update(view);game.save=save;return a,why + end + a,why=native:update();T.eq(a,false);T.eq(why,'native animation admission changed');T.eq(native.frame,1) + T.eq(handle.frame,nil,'old handle cannot observe a replacement session');T.eq(id(),nil) + -- Rebind through the real player after a new native admission. + VM.launch=function(view)handle=view;return launch(view)end + T.eq(native:start('MOVE_033',false,1,0,nil,b),true) + T.eq(handle:attackerId(),1);T.eq(handle:targetId(),0) + local old=handle;local oldId=old.attackerId + T.eq(native:start('MOVE_033',true,2,3,nil,b),true) + T.neq(handle,old,'each native launch gets a fresh presentation identity') + T.eq(old.frame,nil);T.eq(oldId(),nil);T.eq(handle:attackerId(),2);T.eq(handle:targetId(),3) + VM.update=function()return update(old)end + a,why=native:update();T.eq(a,false);T.eq(why,'native animation admission changed');T.eq(native.frame,0) + -- Dispatch preserves return arity and makes native failures sticky even + -- if a wrapper catches a failed base call and tries it again. + VM.update=function(view)local r=pack(update(view));T.eq(r.n,3);return update(view)end + local calls=0 + local r=pack(VM._nativeUpdate(native,function()calls=calls+1;return 7,nil,nil end)) + T.eq(r.n,3);T.eq(r[1],7);T.eq(calls,1) + VM.update=function(view)pcall(update,view);pcall(update,view);return true end + T.raises(function()VM._nativeUpdate(native,function()calls=calls+1;error('native vm failed')end)end,'native vm failed') + T.eq(calls,2) + VM.update=function(view)return update(view)end + game.stack:push({});a,why=native:update();game.stack:pop() + T.eq(a,false);T.eq(why,'native animation is covered') + local seams=require('src.mods.ModSeams');local owner=G._callbackOwner() + VM.update=function(view)T.eq(G._callbackOwner(),'vm_owner');return update(view)end + seams._journal[#seams._journal+1]={tbl=VM,key='update',after=VM.update,owner='vm_owner'} + native:update();table.remove(seams._journal);T.eq(G._callbackOwner(),owner) + require('src.core.game3.asset_stream').cancelOwner('unrelated_vm_owner');T.neq(handle.frame,nil) + require('src.core.game3.asset_stream').cancelOwner('vm_owner');T.eq(handle.frame,nil) + VM.launch,VM.update=launch,update + game.stack:pop();VM._service.release();Battle._service.release();f.close();G.release() + local function trace(mode) + if mode=='absent'then package.loaded['src.core.game3.battle.anim_vm']=nil + else package.loaded['src.core.game3.battle.anim_vm']=VM end + T.eq(package.loaded['src.core.game3.battle.anim_vm']==nil,mode=='absent','control actually excludes the source module') + local fixture=require('tools.battle-source-runtime.fixture').new(edition) + local game=fixture.game;package.loaded['src.core.Game']=game + game.data.constants.gen3BattleAnimGfx={} + game.data.moves.MOVE_033.anim={duration=4,shakes={{at=0,duration=4,x=2,y=1}}} + local state=fixture.fresh('TACKLE').battle;state.enter=false;game.stack:push(state);state.phase='QA_IDLE' + if mode=='wrapped'then + VM.launch=function(v)launch(v);return launch(v)end + VM.update=function(v)update(v);return update(v)end + end + local player=state.gen3Anim;local rows={} + T.eq(player:start('MOVE_033',true,0,1,nil,state),true) + for i=1,16 do + player:update();rows[i]={frame=player.frame,done=player:isDone(),clock=state.frame, + hp=state.player.mon.hp,pp=state.player.mon.moves[1].pp} + end + VM.launch,VM.update=launch,update;game.stack:pop();VM._service.release() + Battle._service.release();fixture.close();G.release() + return rows + end + local baseline=trace('absent');T.same(trace('default'),baseline,'default module preserves every native script frame') + T.same(trace('wrapped'),baseline,'repeated base calls preserve every native script frame') + package.loaded['src.core.game3.battle.anim_vm']=VM +end +VM.launch,VM.update=launch,update;package.loaded['src.core.Game']=original +local function retiredHandle() + local f=require('tools.battle-source-runtime.fixture').new('firered');local game=f.game + package.loaded['src.core.Game']=game;game.data.constants.gen3BattleAnimGfx={} + game.data.moves.MOVE_033.anim={duration=4,shakes={{at=0,duration=4,x=2,y=1}}} + local b=f.fresh('TACKLE').battle;b.enter=false;game.stack:push(b) + local player=b.gen3Anim;local handle + VM.launch=function(v)handle=v;return launch(v)end + player:start('MOVE_033',true,0,1,nil,b) + local id=handle.attackerId + local weak=setmetatable({game,game.save,game.data,b,player},{__mode='v'}) + VM.launch=launch;game.stack:pop();VM._service.release();Battle._service.release();f.close();G.release() + package.loaded['src.core.Game']=original + return handle,id,weak +end +local retained,id,weak=retiredHandle() +-- Font.load intentionally retains its current dataset process-wide. Move +-- that unrelated fixture cache to a fresh dataset before testing ownership +-- through the retained VM handle; all five weak-owner checks still apply. +require('src.render.Font').load(T.fixtures.fresh()) +for _=1,4 do collectgarbage('collect')end +for i=1,5 do T.eq(weak[i],nil,'retained animation handle releases native owner '..i)end +T.eq(retained.frame,nil);T.eq(id(),nil) +T.finish('native animation launch/update borrowing, handle retirement and single script clock') From b156b435bd0832df3660a1fac7402581dd46a956 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Tue, 27 Oct 2026 13:08:10 +0100 Subject: [PATCH 2/2] Retain native visual task facts and expose scoped Dig and Surf tasks --- src/battle/BattleState.lua | 2 + src/battle/Gen3MoveAnim.lua | 40 +++-- src/core/game3/battle/anim_tasks.lua | 204 ++++++++++++++++++++++ src/core/game3/battle/anim_vm.lua | 23 ++- src/import/Gen3VisualTaskFacts.lua | 34 ++++ src/import/RomExtractorGen3.lua | 16 +- src/import/RomImporter.lua | 3 + tests/engine/native_animation_tasks.lua | 139 +++++++++++++++ tests/engine/native_visual_task_facts.lua | 62 +++++++ tools/native-animation-tasks-gpu/conf.lua | 7 + tools/native-animation-tasks-gpu/main.lua | 96 ++++++++++ tools/native-visual-tasks/conf.lua | 6 + tools/native-visual-tasks/main.lua | 60 +++++++ 13 files changed, 674 insertions(+), 18 deletions(-) create mode 100644 src/core/game3/battle/anim_tasks.lua create mode 100644 src/import/Gen3VisualTaskFacts.lua create mode 100644 tests/engine/native_animation_tasks.lua create mode 100644 tests/engine/native_visual_task_facts.lua create mode 100644 tools/native-animation-tasks-gpu/conf.lua create mode 100644 tools/native-animation-tasks-gpu/main.lua create mode 100644 tools/native-visual-tasks/conf.lua create mode 100644 tools/native-visual-tasks/main.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 8f8e1409c..30aa3df10 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -3895,6 +3895,8 @@ end -- any pop (finish, script teardown) must silence the alarm loop -- (end_of_battle.asm clears wLowHealthAlarm when a battle ends) function BattleState:exit() + local tasks=package.loaded['src.core.game3.battle.anim_tasks'] + if tasks then tasks._nativeExit(self)end -- Teachy TV temporarily replaces the player's party exactly like -- SavePlayerParty/LoadPlayerParty in teachy_tv.c. finish() normally -- restores it before the pop, but exit is the safety net for a forced state diff --git a/src/battle/Gen3MoveAnim.lua b/src/battle/Gen3MoveAnim.lua index 727ebdc73..6ebc9269f 100644 --- a/src/battle/Gen3MoveAnim.lua +++ b/src/battle/Gen3MoveAnim.lua @@ -918,7 +918,7 @@ function Gen3MoveAnim:start(moveId, attackerIsPlayer, attackerPosition, targetPo -- script's waitforvisualfinish ends at waitLife rather than paying the -- generic six-frame presentation tail used for particles. if self.surf and self.surf.waitLife then - self.total = math.max(duration, self.surf.waitLife) + self.total = math.max(duration, (self.surf.at or 0)+self.surf.waitLife) end -- ACID ARMOR's task lifetime and the one script-frame delay after clear are -- fully known, so do not append the generic particle presentation tail. @@ -951,6 +951,8 @@ function Gen3MoveAnim:start(moveId, attackerIsPlayer, attackerPosition, targetPo self.total = math.max(anim.duration or 0, self.camouflage.waitLife or 0) end + local tasks=package.loaded['src.core.game3.battle.anim_tasks'] + if tasks then tasks._nativeStart(self,battle)end return true end @@ -989,6 +991,8 @@ function Gen3MoveAnim:update() self._transformApplied = true end end + local tasks=package.loaded['src.core.game3.battle.anim_tasks'] + if tasks then tasks._nativeTick(self)end end function Gen3MoveAnim:isDone() @@ -1000,6 +1004,8 @@ function Gen3MoveAnim:isDone() end function Gen3MoveAnim:release() + local tasks=package.loaded['src.core.game3.battle.anim_tasks'] + if tasks then tasks._nativeRelease(self)end self.events, self.total, self.frame, self.fan, self.oamRanks = nil, nil, 0, nil, nil self.oamGlobalTieResidual = nil @@ -2394,7 +2400,7 @@ end function Gen3MoveAnim:surfLayer() local shape = self.surf if not shape then return nil end - local frame = self.frame or 0 + local frame = (self.frame or 0)-(shape.at or 0) if frame < 0 or frame >= (shape.life or 134) then return nil end -- the task's two arms: which one is chosen is GetBattlerSide(attacker) local side = self.attackerIsPlayer and shape.player or shape.opponent @@ -4414,17 +4420,8 @@ function Gen3MoveAnim:drawEvent(event, battle, Gen3Battle, Assets, pose, return true end -function Gen3MoveAnim:draw(battle, skipEvents) - if not self.events or not battle then return end - local okG3, Gen3Battle = pcall(require, "src.battle.Gen3Battle") - local okA, Assets = pcall(require, "src.render.Assets") - if not (okG3 and okA) then return end - local g = love.graphics - local now = self.frame or 0 - - -- the wave first, and over everything: the cartridge gives its layer - -- priority 1 and the battlers' sprites priority 2, so it passes in FRONT of - -- the Pokemon it is breaking over +function Gen3MoveAnim:drawSurfNative(battle,Gen3Battle,Assets) + local g=love.graphics local layer = self:surfLayer() if layer then local okImg, img = pcall(Assets.image, layer.image) @@ -4451,6 +4448,23 @@ function Gen3MoveAnim:draw(battle, skipEvents) end end +end + +function Gen3MoveAnim:draw(battle, skipEvents) + if not self.events or not battle then return end + local okG3, Gen3Battle = pcall(require, "src.battle.Gen3Battle") + local okA, Assets = pcall(require, "src.render.Assets") + if not (okG3 and okA) then return end + local g = love.graphics + local now = self.frame or 0 + + -- the wave first, and over everything: the cartridge gives its layer + -- priority 1 and the battlers' sprites priority 2, so it passes in FRONT of + -- the Pokemon it is breaking over + local tasks=package.loaded['src.core.game3.battle.anim_tasks'] + if tasks then tasks._nativeDraw(self,battle,Gen3MoveAnim.drawSurfNative,Gen3Battle,Assets) + else self:drawSurfNative(battle,Gen3Battle,Assets)end + -- ...and the stat-up sparkle, which belongs to ONE Pokemon rather than to -- the screen: the cartridge centres its layer on the attacker's own sprite. do diff --git a/src/core/game3/battle/anim_tasks.lua b/src/core/game3/battle/anim_tasks.lua new file mode 100644 index 000000000..6b6e6ad8a --- /dev/null +++ b/src/core/game3/battle/anim_tasks.lua @@ -0,0 +1,204 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Observers/draw callbacks for decoded native Dig and Surf tasks. Time, +-- lifetimes, battler movement and pixels remain owned by Gen3MoveAnim. +local Battle=require('src.core.game3.battle') +local VM=require('src.core.game3.battle.anim_vm') +local Facts=require('src.import.Gen3VisualTaskFacts') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local Call=require('src.compat.gen1.SourceCall') +local Scope=require('src.render.GraphicsScope') +local M={} +local records=setmetatable({},{__mode='k'}) +local spawnPhase,drawPhase,taskPhase +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +local function valid(c) + local screen,player=c.refs[1],c.refs[2] + return not c.retired and screen and player and Battle._screen()==screen + and Battle.getState()==c.view and screen.gen3Anim==player + and player.presentationSerial==c.serial +end +local function touch(c,name) + local who=Call.owner(M,name or'draw');if who then c.users[who]=true end +end +local function retire(c) + c.retired=true;c.view,c.vm=nil,nil;c.refs[1],c.refs[2]=nil,nil;c.items={};c.users={} + for _,h in ipairs(c.pool)do h.paint,h.paintOwner=nil,nil end +end +function M._nativeRelease(player) + local c=records[player];if c then retire(c);records[player]=nil end +end +function M._nativeExit(screen) + for player,c in pairs(records)do if c.refs[1]==screen then M._nativeRelease(player)end end +end +local function current() + local screen=Battle._screen();local player=screen and screen.gen3Anim + local c=player and records[player] + if c and not valid(c)then M._nativeRelease(player);return nil end + if c then touch(c)end + return c +end +local function age(h) + if not valid(h.context)then return nil end + return(h.context.refs[2].frame or 0)-h.meta.at +end +local function active(h)local t=age(h);return t~=nil and t>=0 and t=0 and key%1==0 and meta.args[key+1]or nil end) + local scan=readonly(function(key) + if key~=1 or age(h)==nil then return nil end + local layer=c.refs[2]:surfLayer();local coefficient=layer and layer.alpha*(h.shape.blendOf or 16)or 0 + return coefficient+(16-coefficient)*256 + end) + h.public=setmetatable({},{__index=function(_,key) + if key=='name'then return meta.name end + if key=='priority'then return meta.priority end + if key=='active'then return active(h)end + if key=='draw'then return h.paint or(h.kind=='surf'and nativeDraw or nil)end + if key=='data'then return data end + if key=='args'then return args end + if key=='ga'then return ga end + if key=='_scan'then return h.kind=='surf'and scan or nil end + end,__newindex=function(_,key,value) + assert(key=='draw','native task observation is read-only: '..tostring(key)) + assert(value==nil or type(value)=='function','task draw callback must be a function') + assert(valid(c)or value==nil or value==nativeDraw,'native task handle retired') + if value==nativeDraw or value==nil then h.paint,h.paintOwner=nil,nil + else + h.paint=value + local host=package.loaded['src.mods.Runtime'] + h.paintOwner=host and host.currentMod or require('src.core.Game3')._callbackOwner() + if h.paintOwner then c.users[h.paintOwner]=true end + end + end}) + return h +end +local function spawn() + if not spawnPhase or not valid(spawnPhase.context)then return nil,'task creation belongs to its native phase'end + return Boundary.invoke('battle.task.spawn') +end +local function draw(vm) + if not drawPhase or drawPhase.context.vm~=vm then return false,'task drawing belongs to its native phase'end + return Boundary.invoke('battle.tasks.draw') +end +M.spawn,M.draw=spawn,draw +local function tick(c) + if not valid(c)then return end + local player=c.refs[2] + for _,item in ipairs(c.items)do + if not item.born and(player.frame or 0)>=item.meta.at then + item.born=true + local h=handle(c,item.meta,item);c.pool[#c.pool+1]=h;c.publicPool[#c.publicPool+1]=h.public + if item.kind=='surf'then c.surf=h end + local function native()return valid(c)and h.public or nil end + if M.spawn==spawn then native() + else + local previous=spawnPhase;spawnPhase={context=c} + local values=pack(pcall(Boundary.dispatch,'battle.task.spawn',native,function() + touch(c,'spawn');local args={};for i,value in ipairs(item.meta.args)do args[i]=value end + return Call.call(M,'spawn',item.meta.name,item.meta.priority,args,c.vm) + end));spawnPhase=previous + if not values[1]then error(values[2],0)end + end + end + end +end +function M._nativeStart(player,screen) + M._nativeRelease(player) + if not screen or Battle._screen()~=screen or screen.gen3Anim~=player then return end + local items={} + local function add(kind,shape,life) + local d=shape and shape.visualTask + if type(d)~='table'or d.schema~=Facts.SCHEMA or d.kind~=kind then return end + local meta=Facts.fromTask(kind,{fn=d.nativeFunction,priority=d.priority,at=d.at,args=d.args, + site=d.scriptOffset,order=d.order}) + if not meta or meta.name~=d.name or meta.nativeFunction~=d.nativeFunction + or type(life)~='number'or life<=0 then return end + items[#items+1]={kind=kind,shape=shape,meta=meta,life=life,order=#items+1} + end + for _,scan in ipairs(player.scanlines or{})do + if scan.kind=='dig_down'or scan.kind=='dig_up'then add(scan.kind,scan,player:scanlineTaskLife(scan,screen))end + end + if player.surf then add('surf',player.surf,player.surf.waitLife or player.surf.life)end + if #items==0 then return end + table.sort(items,function(a,b) + if a.meta.at~=b.meta.at then return a.meta.at=low and value<=high +end +function M.fromTask(kind,task) + local name=names[kind] + if not name then return nil,'native visual task family is not exposed'end + local at=type(task)=='table'and task.at + if at==nil then at=0 end + if type(task)~='table'or not integer(task.fn,1,0x1ffffff) + or not integer(task.priority,0,255)or not integer(at,0,0x7fffffff) + or task.site~=nil and not integer(task.site,0,0x1ffffff) + or task.order~=nil and not integer(task.order,1,0x7fffffff) + or type(task.args)~='table'or #task.args>8 then + return nil,'native visual task operands unavailable' + end + local args={} + for i,value in ipairs(task.args)do + if not integer(value,-32768,32767)then return nil,'invalid native visual task operand'end + args[i]=value + end + for key in pairs(task.args)do + if not integer(key,1,#args)then return nil,'invalid native visual task operand index'end + end + return{schema=M.SCHEMA,kind=kind,name=name,priority=task.priority,args=args, + at=at,nativeFunction=task.fn-task.fn%2,scriptOffset=task.site,order=task.order} +end +return M diff --git a/src/import/RomExtractorGen3.lua b/src/import/RomExtractorGen3.lua index c2ce0b55e..208f04ea8 100644 --- a/src/import/RomExtractorGen3.lua +++ b/src/import/RomExtractorGen3.lua @@ -36185,7 +36185,8 @@ function RomExtractorGen3:animScriptRead(entry, branchCtx, visualLife) if v >= 32768 then v = v - 65536 end args[i] = v end - local task = { fn = fn - 0x08000000, args = args, at = t, site = p } + local task = { fn = fn - 0x08000000, args = args, at = t, site = p, + priority = rom:u8(p + 5), order = #tasks + 1 } tasks[#tasks + 1] = task visualStarted("task", task, t) end @@ -39120,6 +39121,7 @@ function RomExtractorGen3:extractMoveAnimations() local one = { kind = kind, at = t.at or 0, selector = selector, task = taskFn(t), + visualTask = require('src.import.Gen3VisualTaskFacts').fromTask(kind,t), source = ("ROM:createvisualtask %07X (%s)") :format(taskFn(t) or 0, kind), } @@ -39404,9 +39406,15 @@ function RomExtractorGen3:extractMoveAnimations() local fn = t.fn and (t.fn - (t.fn % 2)) or -1 if fn == (surfTask or S.TASK) or fn == (surfTask or S.TASK) + 0x08000000 then - local moveShape = self:surfWave(surfTask, + local decoded = self:surfWave(surfTask, t.args and (t.args[1] or 0) ~= 0) - if not moveShape then break end + if not decoded then break end + -- The decoder caches by function/palette. Script timestamps and + -- operands belong to this invocation, not that shared template. + local moveShape={} + for key,value in pairs(decoded)do moveShape[key]=value end + moveShape.at=t.at or 0 + moveShape.visualTask=require('src.import.Gen3VisualTaskFacts').fromTask('surf',t) local record = row.record or {} record.surf = moveShape -- the coarse pass has already been past and given this move a @@ -39414,7 +39422,7 @@ function RomExtractorGen3:extractMoveAnimations() -- consolation goes if record.shake or record.flash then coarse = coarse - 1 end record.shake, record.flash = nil, nil - record.duration = math.max(record.duration or 0, moveShape.life) + record.duration = math.max(record.duration or 0, moveShape.at+moveShape.life) row.def.anim = record row.record = record marked = marked + 1 diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index bbd959cfd..af4bed86c 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1323,6 +1323,9 @@ local function markerFor(version) if version == "red" or version == "blue" or version == "yellow" then revision = "gen1-trade-art-v1:" end + if GameVersion.generation(version)==3 then + revision=revision..'gba-visual-task-v'..require('src.import.Gen3VisualTaskFacts').REVISION..':' + end return CACHE_FORMAT .. revision .. GameVersion.info(version).sha1 end diff --git a/tests/engine/native_animation_tasks.lua b/tests/engine/native_animation_tasks.lua new file mode 100644 index 000000000..c42ca6b29 --- /dev/null +++ b/tests/engine/native_animation_tasks.lua @@ -0,0 +1,139 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Tasks=require('src.core.game3.battle.anim_tasks') +local VM=require('src.core.game3.battle.anim_vm') +local Battle=require('src.core.game3.battle') +local Facts=require('src.import.Gen3VisualTaskFacts') +local G=require('src.core.Game3') +local spawn,draw,launch=Tasks.spawn,Tasks.draw,VM.launch +local original=package.loaded['src.core.Game'] +local function pack(...)return{n=select('#',...),...}end +local function meta(kind,at,order,priority) + return assert(Facts.fromTask(kind,{fn=0x1001+order*2,args={kind=='dig_up'and 1 or 0}, + at=at,order=order,priority=priority})) +end +T.eq(spawn('unknown'),nil);T.eq(draw(),false);T.eq(Tasks._pool,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.gen3BattleAnimGfx={} + local down={kind='dig_down',at=0,selector=0,visualTask=meta('dig_down',0,1,5)} + local up={kind='dig_up',at=3,selector=0,visualTask=meta('dig_up',3,3,1)} + local surf={at=3,life=134,waitLife=136,step=2,fade=13,hold=54,blendOf=16, + player={image='owned wave',x=0,y=-48,dx=-2,dy=1}, + opponent={image='owned wave',x=-224,y=256,dx=2,dy=-1},visualTask=meta('surf',3,2,9)} + game.data.moves.MOVE_033.anim={duration=150,scanlines={down,up},surf=surf} + local b=f.fresh('TACKLE').battle;b.enter=false;game.stack:push(b) + local native=b.gen3Anim;local births={};local parentVM + VM.launch=function(view)parentVM=view;local first=launch(view);T.eq(first,true);T.eq(launch(view),true);return first end + Tasks.spawn=function(name,priority,args,vm) + T.eq(vm,parentVM,'child task borrows the enclosing native launch handle') + local task=spawn(name,priority,args,vm);T.eq(spawn(name,priority,args,vm),task) + T.eq(task.active,true);T.eq(task.name,name);T.eq(task.priority,priority) + local arg=args[1];args[1]=99;T.eq(task.args[1],arg,'callback operands are detached') + births[#births+1]=name;return task + end + T.eq(native:start('MOVE_033',true,0,1,nil,b),true) + local pool=Tasks._pool;T.eq(pool,Tasks._pool,'pool view is stable without per-frame copies') + T.eq(#pool,1);local digging=pool[1];T.eq(digging.name,'AnimTask_DigDownMovement');T.eq(digging.draw,nil) + T.raises(function()digging.active=false end,'read-only');T.raises(function()digging.args[1]=2 end,'read-only') + T.raises(function()Tasks._pool={}end,'read-only') + native:update();T.eq(#pool,1);native:update();T.eq(#pool,1);native:update();T.eq(#pool,3) + T.same(births,{'AnimTask_DigDownMovement','AnimTask_CreateSurfWave','AnimTask_DigUpMovement'}, + 'birth notifications retain script order, independent of task priority') + local wave,upTask=pool[2],pool[3] + T.eq(wave.name,'AnimTask_CreateSurfWave');T.eq(wave.ga[0],0);T.eq(wave.ga.unknown,nil) + T.eq(wave.data[2],0);T.eq(wave._scan[1],0x1000);T.eq(parentVM:attackerSide(),'player') + native:update();T.eq(wave.data[2],1);T.eq(wave._scan[1]%32,1) + local scriptFrame,battleFrame=native.frame,b.frame + T.eq(wave.draw(wave,parentVM),false,'a retained draw function cannot paint between native phases') + local paints=0 + Tasks.draw=function(vm) + T.eq(vm,parentVM);local base=wave.draw + wave.draw=function(task,view)base(task,view);return base(task,view)end + local values=pack(draw(vm));T.eq(values.n,3);draw(vm);wave.draw=base + return false,nil,'source task result' + end + local a,middle,last=Tasks._nativeDraw(native,b,function(player,screen) + T.eq(player,native);T.eq(screen,b);paints=paints+1;return 7,nil,nil + end) + T.eq(a,false);T.eq(middle,nil);T.eq(last,'source task result');T.eq(paints,1) + T.eq(native.frame,scriptFrame);T.eq(b.frame,battleFrame) + local depth=love.graphics.getStackDepth and love.graphics.getStackDepth() + Tasks.draw=function()love.graphics.push('all');error('task source draw failed')end + T.raises(function()Tasks._nativeDraw(native,b,function()end)end,'task source draw failed') + if depth then T.eq(love.graphics.getStackDepth(),depth)end + Tasks.draw=function(vm)pcall(draw,vm);return true end + T.raises(function()Tasks._nativeDraw(native,b,function()error('native task draw failed')end)end,'native task draw failed') + Tasks.draw=function(vm)game.stack:push({});local a,why=draw(vm);game.stack:pop();return a,why end + paints=0;a,last=Tasks._nativeDraw(native,b,function()paints=paints+1 end) + T.eq(a,false);T.eq(last,'native task draw admission changed');T.eq(paints,0) + -- An already covered battle can still paint below the existing bag/menu. + Tasks.draw=function(vm)return draw(vm)end;game.stack:push({}) + Tasks._nativeDraw(native,b,function()paints=paints+1 end);game.stack:pop();T.eq(paints,1) + for _=native.frame+1,up.at+native:scanlineTaskLife(up,b)do native:update()end + T.eq(upTask.active,false,'upward task lifetime belongs to the actual native sampler') + T.eq(wave.active,true,'the longer native wave is still active') + local seam=require('src.mods.ModSeams');local owner=G._callbackOwner() + Tasks.draw=function(vm)T.eq(G._callbackOwner(),'task_owner');return draw(vm)end + seam._journal[#seam._journal+1]={tbl=Tasks,key='draw',after=Tasks.draw,owner='task_owner'} + Tasks._nativeDraw(native,b,function()paints=paints+1 end);table.remove(seam._journal);T.eq(G._callbackOwner(),owner) + Tasks._service.releaseOwner(nil,'unrelated_task_owner');T.eq(wave.active,true) + Tasks._service.releaseOwner(nil,'task_owner');T.eq(wave.active,false);T.eq(Tasks._pool,nil) + local held=native.frame;Tasks._nativeDraw(native,b,function()paints=paints+1 end) + native:update();T.eq(native.frame,held+1,'source teardown cannot cancel the native effect') + Tasks.spawn,Tasks.draw,VM.launch=spawn,draw,launch + T.eq(native:start('MOVE_033',true,0,1,nil,b),true) + for _=1,4 do native:update()end + local replaced=Tasks._pool[2] + Tasks.draw=function(vm) + local save=game.save;game.save={};local a,why=draw(vm);game.save=save;return a,why + end + paints=0;a,last=Tasks._nativeDraw(native,b,function()paints=paints+1 end) + T.eq(a,false);T.eq(last,'native task draw admission changed');T.eq(paints,0) + T.eq(replaced.active,false);T.eq(Tasks._pool,nil,'a replaced cohort cannot retain the old task pool') + Tasks.draw=draw + -- The downward task is longer than the wave. Qualify its actual last + -- active frame separately instead of assuming the wave is still drawing. + surf.visualTask=nil;up.visualTask=nil + T.eq(native:start('MOVE_033',true,0,1,nil,b),true) + local falling=Tasks._pool[1];local life=native:scanlineTaskLife(down,b) + for _=1,life-1 do native:update()end + T.eq(falling.active,true,'downward task stays active until its native final step') + native:update();T.eq(falling.active,false,'downward task retires on its native final step') + -- No inferred priority/name is used for old or unsupported caches; their + -- exact native painter remains available without a source task pool. + surf.visualTask=nil;down.visualTask=nil;up.visualTask=nil + T.eq(native:start('MOVE_033',false,nil,nil,nil,b),true);T.eq(Tasks._pool,nil) + local view=VM._nativeView(native);T.eq(view:attackerId(),1);T.eq(view:targetId(),0) + T.eq(view:attackerSide(),'enemy');T.eq(view:targetSide(),'player') + paints=0;Tasks._nativeDraw(native,b,function()paints=paints+1 end);T.eq(paints,1) + game.stack:pop();T.eq(digging.active,false);T.eq(wave.active,false) + Tasks._service.release();VM._service.release();Battle._service.release();f.close();G.release() +end +Tasks.spawn,Tasks.draw,VM.launch=spawn,draw,launch;package.loaded['src.core.Game']=original +local function retiredTask() + local f=require('tools.battle-source-runtime.fixture').new('firered');local game=f.game + package.loaded['src.core.Game']=game;game.data.constants.gen3BattleAnimGfx={} + local wave={at=0,life=134,waitLife=136,player={image='owned wave',dx=-2,dy=1}, + opponent={image='owned wave',dx=2,dy=-1},visualTask=meta('surf',0,1,2)} + game.data.moves.MOVE_033.anim={surf=wave} + local b=f.fresh('TACKLE').battle;b.enter=false;game.stack:push(b) + local player=b.gen3Anim;player:start('MOVE_033',true,0,1,nil,b) + local task=Tasks._pool[1];local ownedScene={} + local function painter(scene)return function()return scene end end + task.draw=painter(ownedScene) + local weak=setmetatable({game,game.save,game.data,b,player,ownedScene},{__mode='v'}) + T.eq(task.active,true);game.stack:pop();T.eq(task.active,false,'real native exit retires a live task') + T.eq(task.data[2],nil);T.eq(task.ga[0],nil);T.eq(task._scan[1],nil) + task.draw=nil;T.raises(function()task.draw=function()end end,'retired') + Tasks._service.release();VM._service.release();Battle._service.release();f.close();G.release() + package.loaded['src.core.Game']=original + return task,weak +end +local retained,weak=retiredTask() +require('src.render.Font').load(T.fixtures.fresh()) +for _=1,4 do collectgarbage('collect')end +for i=1,6 do T.eq(weak[i],nil,'retained task releases native/scene owner '..i)end +T.eq(retained.active,false);T.eq(retained.name,'AnimTask_CreateSurfWave') +T.finish('decoded native task births, borrowed Surf drawing and shared lifetimes') diff --git a/tests/engine/native_visual_task_facts.lua b/tests/engine/native_visual_task_facts.lua new file mode 100644 index 000000000..94bde0472 --- /dev/null +++ b/tests/engine/native_visual_task_facts.lua @@ -0,0 +1,62 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Facts=require('src.import.Gen3VisualTaskFacts') +local X=require('src.import.RomExtractorGen3') +local function pack(...)return{n=select('#',...),...}end +local function u32(n)return string.char(n%256,math.floor(n/256)%256,math.floor(n/65536)%256,math.floor(n/16777216)%256)end +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + for _,priority in ipairs{0,1,17,255}do + -- Primary createvisualtask layout, with a real delay and signed operands. + local bytes=string.rep('\0',256)..string.char(4,17,3)..u32(0x08001001) + ..string.char(priority,2,0,128,255,127,8)..string.rep('\0',32) + local reader=X.new(bytes,edition) + local result=pack(reader:animScriptRead(256)) + T.eq(result[2],true);local tasks=result[7];T.eq(#tasks,1) + local t=tasks[1];T.eq(t.priority,priority);T.eq(t.at,17);T.eq(t.fn,0x1001) + T.same(t.args,{-32768,32767}) + for _,kind in ipairs{'dig_down','dig_up','surf'}do + local d=assert(Facts.fromTask(kind,t));T.eq(d.schema,1);T.eq(d.kind,kind) + T.eq(d.priority,priority);T.eq(d.at,17);T.eq(d.nativeFunction,0x1000) + T.same(d.args,t.args);T.neq(d.args,t.args) + d.args[1]=123;T.eq(t.args[1],-32768,'source metadata owns its operand array') + end + end +end +local task={fn=0x1001,priority=2,at=0,args={0}} +T.eq(Facts.fromTask('unknown',task),nil) +for _,key in ipairs{'fn','priority','at','args'}do + local copy={fn=task.fn,priority=task.priority,at=task.at,args=task.args} + copy[key]=false;T.eq(Facts.fromTask('surf',copy),nil,'invalid operand '..key) +end +for _,args in ipairs{{[0]=1},{[2]=1},{1,2,3,4,5,6,7,8,9},{0/0},{32768},{-32769},{'0'}}do + T.eq(Facts.fromTask('surf',{fn=0x1001,priority=2,at=0,args=args}),nil) +end +local Player=require('src.battle.Gen3MoveAnim') +for _,at in ipairs{0,1,17,255}do + local shape={at=at,life=134,waitLife=136,step=2,fade=13,hold=54,blendOf=16, + player={image='native wave',x=10,y=20,dx=3,dy=-1},opponent={image='native wave',x=10,y=20,dx=-3,dy=1}} + local data={constants={gen3BattleAnimGfx={}},moves={SURF={anim={surf=shape}}}} + for _,playerSide in ipairs{true,false}do + local native=Player.new(data);T.eq(native:start('SURF',playerSide,playerSide and 0 or 1,playerSide and 1 or 0),true) + T.eq(native.total,at+136,'the native wait includes the script timestamp') + for frame=0,at+136 do + native.frame=frame;local age=frame-at;local actual=native:surfLayer() + local counter,halves,blend=1,0,0 + for _=1,math.max(0,age)do + counter=counter+1 + if counter==2 then + counter=0;halves=halves+1 + if halves<=13 then blend=blend+1 elseif halves>54 then blend=blend-1 end + end + end + if age<0 or age>=134 or blend<=0 then T.eq(actual,nil,'no wave outside the native task') + else + T.neq(actual,nil);T.eq(actual.alpha,blend/16) + T.eq(actual.x,10+(playerSide and 3 or -3)*age) + T.eq(actual.y,20+(playerSide and -1 or 1)*age) + end + end + end +end +T.finish('native visual-task operands, detached metadata and delayed Surf timing') diff --git a/tools/native-animation-tasks-gpu/conf.lua b/tools/native-animation-tasks-gpu/conf.lua new file mode 100644 index 000000000..c569e464a --- /dev/null +++ b/tools/native-animation-tasks-gpu/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + t.identity=assert(os.getenv('POKEPORT_IDENTITY'));t.version='12.0' + assert(t.identity:match('^oasc%-gen2recomp%-qa%-')) + t.window.width,t.window.height,t.window.vsync=160,96,0 + t.modules.audio,t.modules.joystick,t.modules.physics=false,false,false + if t.graphics then t.graphics.renderers={assert(os.getenv('POKEPORT_GRAPHICS_BACKEND'))}end +end diff --git a/tools/native-animation-tasks-gpu/main.lua b/tools/native-animation-tasks-gpu/main.lua new file mode 100644 index 000000000..1db8c3a43 --- /dev/null +++ b/tools/native-animation-tasks-gpu/main.lua @@ -0,0 +1,96 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +function love.errorhandler(err)io.stderr:write(debug.traceback(tostring(err),2)..'\n');return function()return 1 end end +local root=assert(os.getenv('NATIVE_BALL_ENGINE')) +package.path=root..'/?.lua;'..root..'/?/init.lua;'..package.path +local g=love.graphics +local checks=0 +local function check(ok,why)checks=checks+1;assert(ok,why)end +function love.load() + check(love.filesystem.getIdentity():match('^oasc%-gen2recomp%-qa%-'),'owned QA identity') + local renderer=g.getRendererInfo() + check(renderer:lower():find(assert(os.getenv('POKEPORT_GRAPHICS_BACKEND')),1,true),'actual requested backend') + local Tasks=require('src.core.game3.battle.anim_tasks');local draw=Tasks.draw + local f=require('tools.battle-source-runtime.fixture').new('firered');local game=f.game + package.loaded['src.core.Game']=game;game.data.constants.gen3BattleAnimGfx={} + local desc=assert(require('src.import.Gen3VisualTaskFacts').fromTask('surf', + {fn=0x1001,priority=2,args={0},at=3,order=1})) + local shape={at=3,life=134,waitLife=136,fade=13,hold=54,step=2,blendOf=16,width=16,height=8, + player={image='owned test wave',x=5,y=3,dx=-2,dy=1}, + opponent={image='owned test wave',x=-5,y=7,dx=2,dy=-1}, + scanline={player={top=48,bottom=112,topStep=-1,limit=112}, + opponent={top=0,bottom=0,bottomStep=1,limit=112}},visualTask=desc} + game.data.moves.MOVE_033.anim={surf=shape} + local b=f.fresh('TACKLE').battle;b.enter=false;game.stack:push(b) + local player=b.gen3Anim;local GB=require('src.battle.Gen3Battle') + local pixels=love.image.newImageData(16,8) + for y=0,7 do for x=0,15 do pixels:setPixel(x,y,(x%5)/4,(y%3)/2,((x+y)%7)/6,(x+y)%5==0 and 0 or 1)end end + local image=g.newImage(pixels);image:setFilter('nearest','nearest') + local assets={image=function(key)check(key=='owned test wave','native wave image identity');return image end} + local shader=g.newShader('vec4 effect(vec4 c,Image t,vec2 uv,vec2 p){return Texel(t,uv)*c;}') + local selected={[4]=true,[16]=true,[60]=true,[110]=true,[135]=true,[138]=true,[142]=true} + for _,dpi in ipairs{1,2}do + local target=g.newCanvas(240,160,{format='rgba8',dpiscale=dpi,msaa=0}) + for _,side in ipairs{true,false}do + Tasks.draw=draw;check(player:start('MOVE_033',side,side and 0 or 1,side and 1 or 0,nil,b),'native wave start') + for frame=1,142 do + player:update() + if selected[frame]then + local function render(mode) + Tasks.draw=draw + g.setCanvas(target);g.origin();g.setScissor();g.setShader();g.setColor(1,1,1,1) + g.setBlendMode('alpha','alphamultiply');g.clear(.2,.3,.4,1) + g.setShader(shader);g.setScissor(2,1,236,110) + local clock,script,depth=b.frame,player.frame,g.getStackDepth() + if mode=='base'then player:drawSurfNative(b,GB,assets) + else + if mode=='wrapped'or mode=='shifted'then + Tasks.draw=function(vm) + local pool=Tasks._pool;local task=pool and pool[1];local original=task and task.draw + if mode=='shifted'then g.translate(1,0)end + if task then task.draw=function(t,view)original(t,view);return original(t,view)end end + draw(vm);draw(vm) + if task then task.draw=original end + end + end + Tasks._nativeDraw(player,b,player.drawSurfNative,GB,assets) + end + check(b.frame==clock and player.frame==script,'painting has no native clock') + check(g.getCanvas()==target and g.getShader()==shader and g.getStackDepth()==depth,'borrowed target/shader/stack retained') + g.setCanvas();g.setShader();g.setScissor() + local readback=g.readbackTexture(target);local bytes=readback:getString();readback:release() + return bytes + end + local base=render('base') + check(render('present')==base,('default task surface dpi%d side%s frame%d'):format(dpi,tostring(side),frame)) + check(render('wrapped')==base,('wrapped task surface dpi%d side%s frame%d'):format(dpi,tostring(side),frame)) + if frame==60 then check(render('shifted')~=base,'positive control detects a displaced task surface')end + end + end + end + -- Failure after switching to another DPI must preserve the caller's + -- physical scissor, not merely the logical tuple reported by the API. + Tasks.draw=draw;player:start('MOVE_033',true,0,1,nil,b);for _=1,4 do player:update()end + local foreign=g.newCanvas(8,4,{format='rgba8',dpiscale=1,msaa=0}) + g.setCanvas(target);g.origin();g.setScissor();g.setShader();g.clear(.2,.3,.4,1) + g.setScissor(2,1,236,110);g.setShader(shader) + local depth=g.getStackDepth() + Tasks.draw=function()g.push('all');g.setCanvas(foreign);g.setScissor(1,1,2,2);error('intentional task target failure')end + local ok,why=pcall(Tasks._nativeDraw,player,b,player.drawSurfNative,GB,assets) + check(not ok and tostring(why):find('intentional task target failure',1,true),'task callback error retained') + check(g.getCanvas()==target and g.getShader()==shader and g.getStackDepth()==depth,'error restores borrowed state') + g.setShader();g.setColor(1,0,1,1);g.rectangle('fill',0,0,240,160) + g.setCanvas();g.setScissor() + local readback=g.readbackTexture(target) + for _,point in ipairs{{0,0},{1,1},{3,2},{4,2},{6,4},{237*dpi,110*dpi},{239*dpi,112*dpi}}do + local x,y=point[1],point[2];local r,green,blue=readback:getPixel(x,y) + local inside=x>=2*dpi and x<238*dpi and y>=dpi and y<111*dpi + local expected=inside and 1 or .2 + check(math.abs(r-expected)<=1.01/255, + ('physical clip after task error dpi%d x%d y%d got%.5f expected%.5f'):format(dpi,x,y,r,expected)) + end + readback:release();foreign:release();target:release() + end + Tasks.draw=draw;game.stack:pop();Tasks._service.release();f.close();require('src.core.Game3').release() + g.setCanvas();g.setShader();g.setScissor();shader:release();image:release();pixels:release() + print('NATIVE_ANIMATION_TASKS_GPU_OK',renderer,checks);love.event.quit(0) +end diff --git a/tools/native-visual-tasks/conf.lua b/tools/native-visual-tasks/conf.lua new file mode 100644 index 000000000..c714d9258 --- /dev/null +++ b/tools/native-visual-tasks/conf.lua @@ -0,0 +1,6 @@ +function love.conf(t) + t.identity=assert(os.getenv('POKEPORT_IDENTITY')) + assert(t.identity:match('^oasc%-gen2recomp%-qa%-')) + t.version='12.0';t.window=false + t.modules.graphics=false;t.modules.audio=false;t.modules.joystick=false;t.modules.physics=false +end diff --git a/tools/native-visual-tasks/main.lua b/tools/native-visual-tasks/main.lua new file mode 100644 index 000000000..866196f0e --- /dev/null +++ b/tools/native-visual-tasks/main.lua @@ -0,0 +1,60 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +io.stdout:setvbuf('no') +local root=assert(os.getenv('NATIVE_TEXT_ENGINE')) +package.path=root..'/?.lua;'..root..'/?/init.lua;'..package.path +function love.errorhandler(err)print(debug.traceback(tostring(err),2));return function()return 1 end end +local function read(path)local f=assert(io.open(path,'rb'));local s=f:read('*a');f:close();return s end +local function cacheMarker(edition) + local importer=require('src.import.RomImporter') + if importer.cacheContract then return importer.cacheContract(edition).marker end + -- Older public launchers keep this exact format gate private. Inspect the + -- actual readiness closure in QA; do not substitute a guessed marker. + for index=1,100 do + local name,value=debug.getupvalue(importer.readyReport,index) + if not name then break end + if name=='markerFor'then return value(edition)end + end + error('native importer format gate was not found') +end +function love.load() + assert(love.graphics==nil,'actual task import must remain windowless') + local J=require('src.link.Json');local V=require('src.core.GameVersion') + local X=require('src.import.RomExtractorGen3');local Facts=require('src.import.Gen3VisualTaskFacts') + local inputs=J.decode(read(assert(os.getenv('NATIVE_TEXT_INPUTS')))) + local result={editions={},playerProfilesUsed=false,gpu=false,fullGameplay=false} + for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + V.set(edition);require('src.import.CacheFs').prefix=edition..'/' + local raw=read(assert(inputs[edition]));local info=V.info() + assert(require('src.import.RomVerification').sha1(raw)==info.sha1,'exact native cartridge required') + local manifest=J.decode(read(root..'/'..info.manifest));local x=X.new(raw,info,manifest) + local worker=coroutine.create(function()x:extractConstants();x:extractMoves();x:extractMoveAnimations()end) + repeat local ok,why=coroutine.resume(worker);assert(ok,why)until coroutine.status(worker)=='dead' + local counts={dig_down=0,dig_up=0,surf=0};local seen={};local descriptors={} + local function visit(value) + if type(value)~='table'or seen[value]then return end;seen[value]=true + if value.visualTask then + local d=value.visualTask;assert(d.schema==Facts.SCHEMA and counts[d.kind]~=nil) + local p=assert(d.scriptOffset,'task has no original bytecode site') + assert(x.rom:u8(p)==3,'task metadata was attached to a different opcode') + local fn=x.rom:u32(p+1)-0x08000000 + assert(d.nativeFunction==fn-fn%2,'native task function mismatch') + assert(d.priority==x.rom:u8(p+5),'native task priority mismatch') + assert(#d.args==x.rom:u8(p+6),'native task operand count mismatch') + for i,arg in ipairs(d.args)do + local native=x.rom:u16(p+7+(i-1)*2);if native>=32768 then native=native-65536 end + assert(arg==native,'native signed task operand mismatch') + end + counts[d.kind]=counts[d.kind]+1;descriptors[#descriptors+1]=d + end + for _,child in pairs(value)do visit(child)end + end + visit(assert(x._moves));assert(counts.dig_down>0 and counts.dig_up>0 and counts.surf>=2,'required native task family missing') + local marker=cacheMarker(edition) + assert(marker:find('gba%-visual%-task%-v1:'),'native task cache profile was not advanced') + result.editions[edition]={sha1=info.sha1,counts=counts,descriptors=descriptors,marker=marker} + print('NATIVE_VISUAL_TASKS_EDITION',edition,counts.dig_down,counts.dig_up,counts.surf) + end + result.pass=true + local f=assert(io.open(assert(os.getenv('NATIVE_TEXT_RESULT')),'wb'));f:write(J.encode(result));f:close() + print('NATIVE_VISUAL_TASKS_OK');love.event.quit(0) +end