From dcd752a2c1c6b0cc6a5e1b766b9628b4c26cf099 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 18:53:10 +0100 Subject: [PATCH 001/129] Bridge compatibility collision queries to live native maps --- docs/native-field-collision.md | 52 ++++++++++++ src/compat/gen1/CollisionRuntime.lua | 94 ++++++++++++++++++++++ src/core/game3/collision.lua | 82 +++++++++++++++++++ src/mods/Loader.lua | 9 +++ tests/engine/native_field_collision.lua | 1 + tools/field-collision/check.lua | 101 ++++++++++++++++++++++++ 6 files changed, 339 insertions(+) create mode 100644 docs/native-field-collision.md create mode 100644 src/compat/gen1/CollisionRuntime.lua create mode 100644 src/core/game3/collision.lua create mode 100644 tests/engine/native_field_collision.lua create mode 100644 tools/field-collision/check.lua diff --git a/docs/native-field-collision.md b/docs/native-field-collision.md new file mode 100644 index 000000000..da94d2fbc --- /dev/null +++ b/docs/native-field-collision.md @@ -0,0 +1,52 @@ +# Compatibility collision reads and native entry permission + +The collision module reads the live native GBA session. Classified COLL bytes +remain detached source-format observations, while `canEnter` calls the existing +native permission owner. No collision grid, actor state, warp event, script or +save is changed by a query. Hypothetical origins, elevations and surf states +use an observational mover; its real native actor and connected-map aliases +are excluded from self-collision. + +Source behavior comes from original bank attributes and the existing semantic +translator, including neighboring banks and authored borders. The existing +map graph determines which metatile/pair a world coordinate samples. Native +grid revisions and source-bank replacements remain visible. Classified arrays +are cached by owned layout/revision; mutating such an observation cannot +change native entry permission. Native extents remain authoritative for entry +and bounds even where the source art layout has padding. + +The module exposes current-map/behavior/elevation/cell/grid observations, +native walkable/water queries, source-format active warp observations and +`canEnter`. Header-only warps on non-warp terrain stay inactive. Loader teardown +retires readers and arrays after wrapper unwind. Held detached cells do not +retain native maps or source banks. Sessions whose world belongs to an earlier +dataset cannot grant movement. + +## Verification and integration + +`luajit tools/field-collision/check.lua` runs 486 ROM-free checks across all five +editions using real native map and collision implementations. It covers native +permission/reason agreement, source-bank semantics, connected/border sampling, +grid changes, hypothetical requests, active/dead warps, detached overrides, +dataset replacement, real loader teardown and weak-reference retirement. +The player phase's 261 checks also pass with this session guard. + +The composed fork's full unchanged 1.0.9 source loader probe now reaches the +missing `src.core.game3.tileset_native` module for all five editions. Full +initialization, GPU rendering and gameplay remain unaccepted. These probes +use fixture game data and read the full source package without modifying it. + +The standalone upstream diff requires player/session PR #254 and map-read +PR #251 with its terrain/identity dependencies. All-five testing also requires +cartridge PRs #183, #186 and #187. Its focused fixture runs in the composed +fork; it cannot independently exercise missing prerequisite interfaces on +pristine upstream main. + +## Remaining work + +Custom source-authored collision providers, binding/loading a different map, +typed script writes and the rest of the source collision/field service +surface still need integration. They must enter native commands rather than +turning the detached presentation grid into a second simulation. The atlas, +distinct session facade, field/UI/battle services and end-to-end acceptance +remain separate workstreams. diff --git a/src/compat/gen1/CollisionRuntime.lua b/src/compat/gen1/CollisionRuntime.lua new file mode 100644 index 000000000..32b719ebe --- /dev/null +++ b/src/compat/gen1/CollisionRuntime.lua @@ -0,0 +1,94 @@ +-- Source observations and native permission queries have different alphabets: +-- classified COLL bytes are views; actual entry permission stays native. +local Session=require('src.compat.gen1.NativeSession') +local Map=require('src.core.game3.map') +local Tiles=require('src.render.Gen3Tiles') +local Behavior=require('src.compat.gen1.MetatileBehavior') +local Version=require('src.core.GameVersion') +local Native=require('src.world.Collision') +local M={} +local function integer(v)return type(v)=='number'and v==v and math.abs(v)=0 and mid<1024)then return nil end + local g,_,_,maps=context(game);if not g or not maps:isOwned(def)then return nil end + local native=g.data.maps[def.nativeId] + local pair=native and g.data.tilesets[native.tileset] + local sets=g.data.map_tilesets + local a=pair and sets and sets[pair.primaryKey] + local b=pair and sets and sets[pair.secondaryKey] + local geometry=g.data.constants and g.data.constants.gen3Layout + if not(a and b and geometry)then return nil end + local cached=readers[pair] + if not cached or cached.primary~=a or cached.secondary~=b or cached.geometry~=geometry + or cached.split~=geometry.metatilesInPrimary then + cached={primary=a,secondary=b,geometry=geometry,split=geometry.metatilesInPrimary, + reader=Tiles.new({primary=a,secondary=b},geometry)} + readers[pair]=cached + end + local raw=cached.reader:sourceBehavior(mid) + return raw~=nil and Behavior.fromRaw(Version.get(),raw)or nil + end + function runtime:behavior(def,x,y,game) + if not(integer(x)and integer(y))then return nil end + local layout=def and def.midLayout + if not layout or x<0 or y<0 or x>=layout.width or y>=layout.height then return nil end + return self:behaviorMid(def,layout:midAt(x,y),game) + end + function runtime:grid(game) + local def=self:current(game);local layout=def and def.midLayout + if not layout then return nil end + local cached=grids[layout] + if not cached or cached.revision~=layout._revision then + cached={revision=layout._revision,cells=layout:collArray()};grids[layout]=cached + end + return cached.cells + end + function runtime:canEnter(game,x,y,opts) + if not(integer(x)and integer(y))then return false,'bounds'end + local g,w,p=Session.resolve(game);if not g then return false,'bounds'end + opts=opts or {} + local fx,fy=opts.fromX or p.cellX,opts.fromY or p.cellY + if not(integer(fx)and integer(fy))then return false,'bounds'end + local direction=opts.dir + if direction~=nil and not directions[direction]then return false,'direction'end + if not direction then + if fx==x and fy~=y then direction=y>fy and'down'or'up' + elseif fy==y and fx~=x then direction=x>fx and'right'or'left'end + end + local elevation=opts.elevation + if elevation==nil then elevation=p.elevation end + local surfing=opts.surfing + if surfing==nil then surfing=p.surfing end + local cast=w.cast or w.entities or {} + local mover=p + if fx~=p.cellX or fy~=p.cellY or elevation~=p.elevation or surfing~=p.surfing then + mover={};for k,v in pairs(p)do mover[k]=v end + mover.cellX,mover.cellY,mover.elevation,mover.surfing=fx,fy,elevation,surfing + local filtered={} + for _,entity in ipairs(cast)do + if entity~=p and entity.of~=p then filtered[#filtered+1]=entity end + end + cast=filtered + end + return Native.mayEnter(w.map,cast,mover,x,y,direction) + end + function runtime:release() + readers=setmetatable({},{__mode='k'});grids=setmetatable({},{__mode='k'}) + end + return runtime +end +return M diff --git a/src/core/game3/collision.lua b/src/core/game3/collision.lua new file mode 100644 index 000000000..62daf4bc5 --- /dev/null +++ b/src/core/game3/collision.lua @@ -0,0 +1,82 @@ +-- Compatibility reads over owned map views; permissions use the live native +-- collision owner and never mutate it or run player/warp/script events. +local Session=require('src.compat.gen1.NativeSession') +local Map=require('src.core.game3.map') +local MB=require('src.compat.gen1.MetatileBehavior') +local runtime=require('src.compat.gen1.CollisionRuntime').new() +local Collision={} +local warpBehaviors={} +for _,name in ipairs({'WATER_DOOR','DEEP_SOUTH_WARP','LAVARIDGE_GYM_B1F_WARP','LAVARIDGE_GYM_1F_WARP', + 'AQUA_HIDEOUT_WARP','MT_PYRE_HOLE','MOSSDEEP_GYM_WARP','BRIDGE_OVER_OCEAN', + 'WATER_SOUTH_ARROW_WARP','SHOAL_CAVE_ENTRANCE','STAIRS_OUTSIDE_ABANDONED_SHIP'})do + warpBehaviors[MB.require(name)]=true +end +local function integer(v)return type(v)=='number'and v==v and math.abs(v)=layout.width or y>=layout.height then return nil end + return layout:elevAt(x,y) +end +function Collision.elevationAt(x,y) + local g,w=Session.resolve() + if g and integer(x)and integer(y)and w.map:inBounds(x,y)then return w.map:cellElevation(x,y)end +end +function Collision.canEnter(game,x,y,opts)return runtime:canEnter(game,x,y,opts)end +function Collision.isWalkable(x,y) + local g,w=Session.resolve() + return g~=nil and integer(x)and integer(y)and w.map:inBounds(x,y)and w.map:isWalkableCell(x,y)or false +end +function Collision.isWater(x,y) + local g,w=Session.resolve() + return g~=nil and integer(x)and integer(y)and w.map:inBounds(x,y)and w.map:isWaterCell(x,y)or false +end +function Collision.isWarpMetatileBehavior(behavior) + return type(behavior)=='number'and((behavior>=0x60 and behavior<=0x6f)or behavior==0x71 or warpBehaviors[behavior]==true)or false +end +function Collision.warpAt(x,y) + local def=current();if not(def and Collision.inBounds(x,y))then return nil end + local cell=Collision.cell(x,y);local behavior=Collision.behaviorOn(def,x,y) + if not((cell>=0x60 and cell<=0x7f)or warpBehaviors[behavior])then return nil end + for _,warp in ipairs(def.warps or{})do if warp.x==x and warp.y==y then return warp end end +end +function Collision.clear()runtime:release()end +Collision.release=Collision.clear +setmetatable(Collision,{__index=function(_,key) + if key=='_grid'then return runtime:grid()end + local def=current();if not def then return nil end + if key=='_mapDef'then return def end + if key=='_mapId'then return def.id end + local layout=def.midLayout + if key=='_widthCells'then return layout and layout.width or 0 end + if key=='_heightCells'then return layout and layout.height or 0 end + if key=='_warps'then + local out={};for _,warp in ipairs(def.warps or{})do + local active=Collision.warpAt(warp.x,warp.y);if active then out[warp.y*1024+warp.x]=active end + end + return out + end +end}) +return Collision diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..01a09ee8a 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1309,12 +1309,20 @@ local function stampAudioOwners(data, name, registry) owners[key] = map end +-- Optional collision observations retire after wrapper unwind, without +-- loading compatibility modules on an ordinary native boot. +local function retireCompatibilityCollision() + local module = package.loaded["src.core.game3.collision"] + if type(module) == "table" and type(module.release) == "function" then module.release() end +end + function Loader:load(data) -- A SECOND LOAD IN ONE PROCESS STARTS FROM THE ENGINE, NOT FROM THE LAST -- LOAD'S MODS. EXIT GAME -> launcher -> Play runs every mod's main.lua -- 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() + retireCompatibilityCollision() 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 +1662,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()) + retireCompatibilityCollision() pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) Runtime.currentMod = nil diff --git a/tests/engine/native_field_collision.lua b/tests/engine/native_field_collision.lua new file mode 100644 index 000000000..86c6f0863 --- /dev/null +++ b/tests/engine/native_field_collision.lua @@ -0,0 +1 @@ +dofile('tools/field-collision/check.lua') diff --git a/tools/field-collision/check.lua b/tools/field-collision/check.lua new file mode 100644 index 000000000..8d0d4cfbe --- /dev/null +++ b/tools/field-collision/check.lua @@ -0,0 +1,101 @@ +package.path='./?.lua;./?/init.lua;'..package.path +love=require('tests.love_stub') +local T=require('tests.harness') +local C=require('src.core.game3.collision') +local Map=require('src.core.game3.map') +local Native=require('src.world.Collision') +local Tiles=require('src.render.Gen3Tiles') +local Behavior=require('src.compat.gen1.MetatileBehavior') +T.eq(package.loaded['src.core.Game'],nil,'collision import does not bootstrap a game') +T.eq(C.behavior(0,0),nil);T.eq(C.canEnter(nil,0,0),false) +local previous=package.loaded['src.core.Game'] +for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'})do + local f=require('tools.map-runtime.fixture').new(edition) + local g,w,field=f.game,f.world,f.map + w.player={cellX=0,cellY=0,px=0,py=0,elevation=6,surfing=false} + w.entities={w.player};g.save={player={},flags={}} + package.loaded['src.core.Game']=g + local def=assert(Map.currentDef()) + T.eq(C._mapId,def.id);T.eq(C._mapDef,def);T.eq(C._widthCells,4);T.eq(C._heightCells,2) + T.eq(C.inBounds(3,0),false,'art padding is not native walkable space') + T.eq(C.cell(3,0),255);T.eq(C.elevationAt(3,0),nil) + T.eq(C._grid,C._grid,'classified array is cached by owned layout revision') + local sets=g.data.map_tilesets;local pair=g.data.tilesets[field.def.tileset] + local tiles=Tiles.new({primary=sets[pair.primaryKey],secondary=sets[pair.secondaryKey]},g.data.constants.gen3Layout) + for y=0,1 do for x=0,2 do + local raw=tiles:sourceBehavior(field:blockAt(x,y)) + T.eq(C.behavior(x,y),Behavior.fromRaw(edition,raw),'source behavior stays distinct from native numbering') + T.eq(C.elevationAt(x,y),field:cellElevation(x,y)) + for _,dir in ipairs({'up','down','left','right'})do + local actual,why=C.canEnter(g,x,y,{dir=dir}) + local expected,reason=Native.mayEnter(field,w.entities,w.player,x,y,dir) + T.eq(actual,expected);T.eq(why,reason) + end + end end + T.eq(C.warpAt(0,1),nil,'header-only warps on ordinary ground remain inactive') + local bank=sets[pair.primaryKey] + local key=bank.sourceAttributeBytes==4 and 'sourceAttributes'or'attributes' + local originalBytes=bank[key];local offset=bank.sourceAttributeBytes*2 + bank[key]=originalBytes:sub(1,offset)..string.char(105)..originalBytes:sub(offset+2) + local warp=assert(C.warpAt(0,1)) + T.eq(warp.destMap,f.neighborId,'active observations preserve canonical warp destinations') + T.eq(C._warps[1024],warp) + bank[key]=originalBytes;T.eq(C.warpAt(0,1),nil,'behavior changes retire a formerly live warp observation') + local old=C._grid;field:setGen3Cells({{x=0,y=0,mid=1,coll=0,elev=3}}) + T.eq(C.elevationAt(0,0),3);T.check(C._grid~=old,'native terrain edits invalidate classified observations') + T.eq(C.behavior(0,0),Behavior.fromRaw(edition,tiles:sourceBehavior(1))) + local mid,pairId=Map.worldMidAt(3,0,def) + local neighbor=Map.neighborList[1].def + T.eq(pairId,neighbor.pair) + T.eq(C.worldBehavior(3,0),C.behaviorOn(neighbor,0,1),'connected behavior follows native extent and offset') + T.eq(C.worldBehavior(-1,-1),C.behaviorOn(def,-1,-1)or C.behaviorOn(def,0,1), + 'border observations use the current source bank') + local actor=w.player;local x,y,elev,surf=actor.cellX,actor.cellY,actor.elevation,actor.surfing + C.canEnter(g,0,1,{fromX=1,fromY=1,elevation=3,surfing=true,dir='left'}) + T.eq(actor.cellX,x);T.eq(actor.cellY,y);T.eq(actor.elevation,elev);T.eq(actor.surfing,surf) + T.eq(C.canEnter(g,0/0,0),false);T.eq(C.canEnter(g,1.5,0),false) + T.eq(C.behavior(0/0,0),nil) + -- Observed layout edits remain detached from native collision permissions. + local layout=Map.currentDef().midLayout + local nativeAllowed=Native.mayEnter(field,w.entities,actor,0,1,'down') + layout:applyOverride(0,1,0,7,0) + T.eq(C.cell(0,1),7) + T.eq(C.canEnter(g,0,1,{dir='down'}),nativeAllowed,'observational overrides cannot rewrite native permissions') + T.eq(C._grid[5],7,'classified array tracks owned override revisions') + -- A live native actor must not collide with itself when the request asks + -- about a hypothetical origin/surf/elevation instead of its current one. + local allowed,reason=C.canEnter(g,0,1,{fromX=1,fromY=1,elevation=6,surfing=true,dir='left'}) + local hypothetical={cellX=1,cellY=1,elevation=6,surfing=true} + local expected,why=Native.mayEnter(field,{},hypothetical,0,1,'left') + T.eq(allowed,expected);T.eq(reason,why) + -- Source readers see replaced bank records, not old renderer/cached bytes. + local original=sets[pair.primaryKey];local replacement={} + for k,v in pairs(original)do replacement[k]=v end + replacement.attributes=string.rep('\0',#original.attributes) + if replacement.sourceAttributes then replacement.sourceAttributes=string.rep('\0',#replacement.sourceAttributes)end + sets[pair.primaryKey]=replacement + T.eq(C.behavior(0,0),Behavior.fromRaw(edition,0)) + local activeDef=g.data.maps[field.id];g.data.maps[field.id]={} + T.eq(C.canEnter(g,0,1),false,'a world from a retired dataset cannot grant movement') + T.eq(C.elevationAt(0,0),nil) + g.data.maps[field.id]=activeDef + w.player={cellX=0,cellY=0,elevation=3};w.entities={w.player} + g.stack.states={{isTitle=true}} + T.eq(C._mapDef,nil);T.eq(C.behavior(0,0),nil);T.eq(C.canEnter(g,0,1),false) + C.release();Map.release();f.views:release() +end +do + local f=require('tools.map-runtime.fixture').new('firered') + f.world.player={cellX=0,cellY=0,elevation=6};f.world.entities={f.world.player} + package.loaded['src.core.Game']=f.game + local held=C._grid;C.behavior(0,0) + local weak=setmetatable({f.map,f.game.data.map_tilesets.P},{__mode='v'}) + require('src.mods.Loader').teardown() + f.views:release();f=nil;package.loaded['src.core.Game']=nil + collectgarbage('collect');collectgarbage('collect') + T.eq(weak[1],nil,'held classified cells cannot retain a native map after teardown') + T.eq(weak[2],nil,'retired behavior readers cannot retain a native source bank') + T.check(type(held[1])=='number','detached observations remain readable after retirement') +end +package.loaded['src.core.Game']=previous +T.finish('native field collision interface') From 6bbc5a42bda85a94c4da4aa1e0bea43ea66b9105 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 03:06:57 +0100 Subject: [PATCH 002/129] Expose owned field-effect artwork with native records and scoped cache leases --- docs/native-field-effect-sheets.md | 44 ++++++ src/compat/gen1/FieldEffectCache.lua | 67 ++++++++ src/compat/gen1/FieldEffectRuntime.lua | 147 ++++++++++++++++++ src/core/game3/field_effects.lua | 33 ++++ src/render/Gen3FieldEffectCatalog.lua | 33 ++++ tests/engine/native_field_effect_sheets.lua | 1 + tools/field-effect-sheets/check.lua | 110 +++++++++++++ tools/field-effect-sheets/host/conf.lua | 8 + tools/field-effect-sheets/host/main.lua | 87 +++++++++++ tools/field-effect-sheets/lifecycle-check.lua | 60 +++++++ 10 files changed, 590 insertions(+) create mode 100644 docs/native-field-effect-sheets.md create mode 100644 src/compat/gen1/FieldEffectCache.lua create mode 100644 src/compat/gen1/FieldEffectRuntime.lua create mode 100644 src/core/game3/field_effects.lua create mode 100644 src/render/Gen3FieldEffectCatalog.lua create mode 100644 tests/engine/native_field_effect_sheets.lua create mode 100644 tools/field-effect-sheets/check.lua create mode 100644 tools/field-effect-sheets/host/conf.lua create mode 100644 tools/field-effect-sheets/host/main.lua create mode 100644 tools/field-effect-sheets/lifecycle-check.lua diff --git a/docs/native-field-effect-sheets.md b/docs/native-field-effect-sheets.md new file mode 100644 index 000000000..7be235b44 --- /dev/null +++ b/docs/native-field-effect-sheets.md @@ -0,0 +1,44 @@ +# Owned field-effect artwork + +`src.core.game3.field_effects` now supplies the resource part of the compatible +field-effect interface: `install`, `manifest`, `manifestObject`, `loadSheet` +(`getSpriteSheet`), `invalidate`, `release` and owner cancellation. It does not +implement effect tasks, timers, start/wait commands, actor lists, reflections +or draw phases. No empty task observations or additional simulation are used +to imply that those contracts have been completed. + +The native catalogue uses identified imported tall-grass, surf-blob, ripple, +sparkle, FRLG bird and independent emote-strip records when present. It does +not claim that every edition imports each record. Each record's own geometry +and frame order remain authoritative. Independent native emote strips are +not renamed into a presumed combined fifteen-frame strip. Additional artwork +and the native effect phase/state bridge remain required. + +A loaded sheet owns its ImageData, Image, full-frame quads and eight-pixel +front crops. Neither Assets' image cache nor an actor SpriteRenderer is lent. +Repeated requests share these owned resources with per-mod leases. Retirement +uses a private allocation list: replacing public sheet fields cannot cause a +borrowed image to be released. Actual generated-asset overrides are resolved +at each request; changed paths/geometry retire the preceding owned sheet. + +An explicitly installed cache may provide the original `field_effects/objects.lua` +and raw RGBA payloads through its own `read` method. No conversion file is +written. Manifests run with an empty environment and bounded instructions, +then validate scalar/table metadata, finite geometry, names, paths and duplicate +identities. Payload length must exactly match the declared geometry. An authored +manifest remains authoritative; a missing/malformed declared asset is not +silently substituted with unrelated native artwork. Public manifest results +are detached copies; ordinary cached sheet requests avoid copying the manifest. + +Providers bind to the current game/dataset/save/mod-set/edition cohort. +Replacement, failed-entry rollback, late validation rejection, asset flushes +and teardown retire owned allocations. Reentrant cache reads revalidate the +cohort before publication. Preparation failures release partial allocations +and allow a later retry. There is a bounded texture-memory budget; exhausting +it reports an unavailable resource instead of returning a partial sheet. + +Validation uses synthetic, asymmetric pixel fixtures for all five GBA editions, +actual SDK owner rollback/teardown and real Metal/OpenGL image/frame/crop +readbacks. These are resource tests, not original effect-task parity, cartridge +artwork extraction parity, unchanged package initialization, gameplay, full +renderer or complete mod-coexistence acceptance. diff --git a/src/compat/gen1/FieldEffectCache.lua b/src/compat/gen1/FieldEffectCache.lua new file mode 100644 index 000000000..4c7817682 --- /dev/null +++ b/src/compat/gen1/FieldEffectCache.lua @@ -0,0 +1,67 @@ +-- Explicit cache input only. No cartridge import or player filesystem I/O. +local M={} +local function integer(n,lo,hi)return type(n)=='number'and n==n and n%1==0 and n>=lo and n<=hi end +local function copy(v,state,depth) + local kind=type(v) + if kind=='number'then assert(v==v and math.abs(v)1048576 then return nil,'field manifest size limit'end + if bytes:byte(1)==27 then return nil,'field manifest must be text'end + local chunk,why + if setfenv then chunk,why=loadstring(bytes,'@field-effect-manifest');if chunk then setfenv(chunk,{})end + else chunk,why=load(bytes,'@field-effect-manifest','t',{})end + if not chunk then return nil,why end + if jit and jit.off then jit.off(chunk,true)end + local hook,mask,count=debug.gethook() + debug.sethook(function()error('field manifest instruction limit')end,'',200000) + local ok,result=pcall(chunk);debug.sethook(hook,mask,count) + if not ok or type(result)~='table'then return nil,'invalid field manifest'end + local safe,value=pcall(copy,result,{nodes=0,seen={}},0) + if not safe then return nil,tostring(value)end + return value +end +local function relative(path) + if type(path)~='string'or#path==0 or#path>256 or path:sub(1,1)=='/'or path:find('[\\:%z]')then return false end + for part in path:gmatch('[^/]+')do if part=='..'or part=='.'then return false end end + return not path:find('//',1,true) +end +function M.manifest(cache,root,family) + local bytes=cache:read(root..'/objects.lua') + if bytes==nil then return nil end + local manifest,why=decode(bytes);if not manifest then return nil,why end + if manifest.family and manifest.family~=family then return nil,'field manifest belongs to another cartridge family'end + local names={} + for _,key in ipairs({'objects','extras'})do + local list=manifest[key] + if list~=nil and type(list)~='table'then return nil,'invalid field object list'end + for _,row in pairs(list or{})do + if type(row)~='table'or type(row.name)~='string'or not row.name:match('^[%w_%-]+$')or#row.name>80 + or not integer(row.fw,8,128)or not integer(row.fh,8,128)or not integer(row.frames,1,256) + or row.fw*row.fh*row.frames>1048576 or not relative(row.rgba)then + return nil,'invalid field object geometry or path' + end + if names[row.name]then return nil,'ambiguous field object name'end + names[row.name]=row + end + end + manifest._byName=names;return manifest +end +function M.rgba(cache,root,row) + local bytes=cache:read(root..'/'..row.rgba) + if type(bytes)~='string'or#bytes~=row.fw*row.fh*row.frames*4 then return nil,'invalid field RGBA payload'end + return bytes +end +M.copy=function(value)return copy(value,{nodes=0,seen={}},0)end +return M diff --git a/src/compat/gen1/FieldEffectRuntime.lua b/src/compat/gen1/FieldEffectRuntime.lua new file mode 100644 index 000000000..c06e2cfe4 --- /dev/null +++ b/src/compat/gen1/FieldEffectRuntime.lua @@ -0,0 +1,147 @@ +-- Owned field-effect artwork over the native dataset or an explicit cache. +-- This service has no actor, animation, task, RNG or gameplay clock. +local Session=require('src.compat.gen1.NativeSession') +local Version=require('src.core.GameVersion') +local Catalog=require('src.render.Gen3FieldEffectCatalog') +local Cache=require('src.compat.gen1.FieldEffectCache') +local Assets=require('src.render.Assets') +local R={};R.__index=R +local ENGINE={} +local function owner() + local runtime=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + return runtime and runtime.currentMod or facade and facade._callbackOwner and facade._callbackOwner()or ENGINE +end +local function release(value)if value and value.release then pcall(value.release,value)end end +local function root() + local extract=package.loaded['src.import.gba.extract_island1'] + return ((extract and extract.CACHE_ROOT)or'data/generated/gba')..'/field_effects' +end +local function signature(r)return table.concat({r.path or r.rgba,r.fw,r.fh,r.frames},'\0')end +function R.new() + return setmetatable({loaded={},owned={},providers={},bytes=0,maxBytes=64*1024*1024},R) +end +function R:drop(sheet) + local held=self.owned[sheet];if not held then return end + self.owned[sheet]=nil;self.bytes=self.bytes-held.bytes + -- Public sheet tables are mutable. Retire only allocations recorded at + -- construction, never an image/quad a mod later assigned to that table. + for _,part in ipairs(held.parts)do release(part)end + sheet.image,sheet.imageData,sheet.quads,sheet.quadsFront=nil,nil,{},{};sheet._retired=true +end +function R:invalidate() + for sheet in pairs(self.owned)do self:drop(sheet)end + self.loaded={};self.context=nil +end +function R:release()self:invalidate();self.providers={}end +function R:current() + local game,why=Session.game();local edition=Version.get() + if not(game and type(game.data)=='table'and type(game.data.constants)=='table')then + self:release();return nil,why or'no native field-effect dataset' + end + local provider + for i=#self.providers,1,-1 do + local p=self.providers[i] + if p.game==game and p.data==game.data and p.save==game.save and p.mods==game.mods and p.edition==edition then + provider=provider or p + else table.remove(self.providers,i)end + end + local c=self.context;local prefix=root() + if c and(c.game~=game or c.data~=game.data or c.constants~=game.data.constants or c.sprites~=game.data.sprites + or c.save~=game.save or c.mods~=game.mods or c.edition~=edition or c.provider~=provider + or c.root~=prefix or c.assets~=Assets.loader)then self:invalidate();c=nil end + if not c then + c={game=game,data=game.data,constants=game.data.constants,sprites=game.data.sprites, + save=game.save,mods=game.mods,edition=edition,provider=provider,root=prefix,assets=Assets.loader} + self.context=c + end + return c +end +function R:install(cache) + if cache~=nil and(type(cache)~='table'or type(cache.read)~='function')then return nil,'invalid field cache provider'end + local c,why=self:current();if not c then return nil,why end + local who=owner() + for i=#self.providers,1,-1 do if self.providers[i].owner==who then table.remove(self.providers,i)end end + if cache then self.providers[#self.providers+1]={owner=who,cache=cache,game=c.game,data=c.data,save=c.save,mods=c.mods,edition=c.edition}end + self:invalidate();return self:manifest() +end +function R:catalog() + local c,why=self:current();if not c then return nil,why end + if c.provider and not c.providerRead then + c.providerRead=true + local ok,manifest,err=pcall(Cache.manifest,c.provider.cache,c.root,Version.isFRLG(c.edition)and'frlg'or'rse') + if not ok then c.manifestError=tostring(manifest)else c.manifest,c.manifestError=manifest,err end + if self:current()~=c then return nil,'field cache cohort retired during manifest read'end + end + if c.manifestError then return nil,c.manifestError end + return c +end +function R:manifest() + local c,why=self:catalog();if not c then return nil,why end + if c.manifest then return Cache.copy(c.manifest)end + local manifest={family=Version.isFRLG(c.edition)and'frlg'or'rse',objects={},extras={},_byName={}} + for name,row in pairs(Catalog.records(c.data))do + local item={name=name,fw=row.fw,fh=row.fh,frames=row.frames,nativePath=row.path} + manifest.objects[#manifest.objects+1]=item;manifest._byName[name]=item + end + table.sort(manifest.objects,function(a,b)return a.name80 or not name:match('^[%w_%-]+$')then return nil,'invalid field sheet name'end + local c,why=self:catalog();if not c then return nil,why end + if c.manifest then + local row=c.manifest._byName[name] + if not row then return nil,'field sheet absent from installed manifest'end + return row,c,true + end + local row=Catalog.records(c.data)[name] + if not row then return nil,'native field sheet unavailable'end + return row,c,false +end +function R:get(name) + local row,c,explicit=self:record(name);if not row then return nil,c end + local sig=signature(row) + if not explicit then sig=sig..'\0'..Assets.resolve(row.path)end + local cached=self.loaded[name];local held=cached and self.owned[cached] + if held and held.signature==sig then held.owners[owner()]=true;return cached end + if cached then self:drop(cached);self.loaded[name]=nil end + if not(love and love.image and love.graphics)then return nil,'field sheet graphics unavailable'end + local size=row.fw*row.fh*row.frames*8 + if self.bytes+size>self.maxBytes then return nil,'owned field sheet budget exhausted'end + local parts={};local function keep(x)parts[#parts+1]=x;return x end + local ok,sheet=pcall(function() + local data + if explicit then + local bytes,why=Cache.rgba(c.provider.cache,c.root,row);assert(bytes,why) + data=keep(love.image.newImageData(row.fw,row.fh*row.frames,'rgba8',bytes)) + else data=keep(love.image.newImageData(Assets.resolve(row.path)))end + assert(data:getWidth()==row.fw and data:getHeight()==row.fh*row.frames,'field sheet image dimensions differ') + local limits=love.graphics.getSystemLimits and love.graphics.getSystemLimits() + assert(not limits or row.fh*row.frames<=limits.texturesize,'field sheet exceeds GPU limit') + local image=keep(love.graphics.newImage(data));image:setFilter('nearest','nearest') + local made={image=image,imageData=data,fw=row.fw,fh=row.fh,frames=row.frames,quads={},quadsFront={}} + for i=0,row.frames-1 do + made.quads[i]=keep(love.graphics.newQuad(0,i*row.fh,row.fw,row.fh,row.fw,row.fh*row.frames)) + made.quadsFront[i]=keep(love.graphics.newQuad(0,i*row.fh+row.fh-8,row.fw,8,row.fw,row.fh*row.frames)) + end + assert(self:current()==c,'field sheet cohort retired during preparation') + local latest,context,provider=self:record(name) + assert(context==c and provider==explicit and signature(latest)==signature(row) + and(explicit or sig==signature(latest)..'\0'..Assets.resolve(latest.path)),'field sheet record changed during preparation') + assert(self.bytes+size<=self.maxBytes,'owned field sheet budget exhausted during preparation') + return made + end) + if not ok then for _,part in ipairs(parts)do release(part)end;return nil,tostring(sheet)end + self.owned[sheet]={signature=sig,bytes=size,parts=parts,owners={[owner()]=true}};self.bytes=self.bytes+size + self.loaded[name]=sheet;return sheet +end +function R:releaseOwner(who) + for i=#self.providers,1,-1 do if self.providers[i].owner==who then table.remove(self.providers,i)end end + for name,sheet in pairs(self.loaded)do + local held=self.owned[sheet] + if held then held.owners[who]=nil;if next(held.owners)==nil then self:drop(sheet);self.loaded[name]=nil end end + end + self:current() +end +function R:poll()self:current()end +return R diff --git a/src/core/game3/field_effects.lua b/src/core/game3/field_effects.lua new file mode 100644 index 000000000..ff6a164d0 --- /dev/null +++ b/src/core/game3/field_effects.lua @@ -0,0 +1,33 @@ +-- Field artwork resources. Native effect tasks and phase observations are +-- separate contracts; no empty animation lists or substitute clocks here. +local Runtime=require('src.compat.gen1.FieldEffectRuntime') +local Assets=require('src.render.Assets') +local Stream=require('src.core.game3.asset_stream') +local service=Runtime.new() +local F={} +Stream.register(service) +Assets.register(function()service:invalidate()end) +function F.install(cache)return service:install(cache)end +function F.invalidate()service:invalidate()end +function F.release()service:release()end +function F.cancelOwner(who)service:releaseOwner(who)end +function F.manifest()return service:manifest()end +function F.manifestObject(name) + local manifest,why=F.manifest();if not manifest then return nil,why end + return manifest._byName[name] +end +function F.loadSheet(name)return service:get(name)end +F.getSpriteSheet=F.loadSheet +setmetatable(F,{__index=function(_,key) + if key=='_cache'then local c=service:current();return c and c.provider and c.provider.cache end + if key=='_sheets'then service:current();return service.loaded end + if key=='_manifest'then return F.manifest()end +end,__newindex=function(t,key,value) + if key=='_cache'then return F.install(value)end + if key=='_sheets'then + assert(type(value)=='table'and next(value)==nil,'field sheet cache replacement must be empty') + return F.invalidate() + end + rawset(t,key,value) +end}) +return F diff --git a/src/render/Gen3FieldEffectCatalog.lua b/src/render/Gen3FieldEffectCatalog.lua new file mode 100644 index 000000000..b3dd84993 --- /dev/null +++ b/src/render/Gen3FieldEffectCatalog.lua @@ -0,0 +1,33 @@ +-- Imported field artwork, without allocating images or observing an actor. +-- Names describe identified native records; unavailable effects stay absent. +local M={} +local function integer(n,lo,hi) + return type(n)=='number'and n==n and n%1==0 and n>=lo and n<=hi +end +function M.record(row) + if type(row)~='table'then return nil end + local path=row.image or row.path + local w,h,n=row.frameWidth,row.frameHeight,row.frames + if type(path)~='string'or#path==0 or#path>1024 or not integer(w,8,128) + or not integer(h,8,128)or not integer(n,1,256)or w*h*n>1048576 then return nil end + return {path=path,fw=w,fh=h,frames=n} +end +function M.records(data) + local c=type(data)=='table'and data.constants or{};local out={} + for name,key in pairs({tall_grass='gen3TallGrass',surf_blob='gen3SurfBlob',fly_bird='gen3FRLGFlyBird'})do + out[name]=M.record(c[key]) + end + for name,key in pairs({ripple='gen3Ripple',sparkle='gen3Sparkle'})do + local set=c[key];local row=set and data.sprites and data.sprites[set.key] + if row and row.trueColor==true and not row.big and not row.monIcon then out[name]=M.record(row)end + end + -- Native emote strips have independent geometry/order. Do not concatenate + -- them into an assumed fifteen-frame source strip. + for _,name in ipairs({'exclamation','question','heart'})do + local row=c.gen3Emotes and c.gen3Emotes[name] + if row then out['emote_'..name]=M.record({image=row.image,frameWidth=row.width, + frameHeight=row.height,frames=row.frames})end + end + return out +end +return M diff --git a/tests/engine/native_field_effect_sheets.lua b/tests/engine/native_field_effect_sheets.lua new file mode 100644 index 000000000..4da051d23 --- /dev/null +++ b/tests/engine/native_field_effect_sheets.lua @@ -0,0 +1 @@ +dofile('tools/field-effect-sheets/check.lua') diff --git a/tools/field-effect-sheets/check.lua b/tools/field-effect-sheets/check.lua new file mode 100644 index 000000000..473c92e81 --- /dev/null +++ b/tools/field-effect-sheets/check.lua @@ -0,0 +1,110 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.harness') +local F=require('src.core.game3.field_effects') +T.eq(package.loaded['src.core.Game'],nil,'resource require cannot bootstrap a game') +T.eq(F.loadSheet('tall_grass'),nil,'no dataset cannot invent an image') +love=require('tests.love_stub') +local read,imageFile,created=require('tools.sprite-runtime.cpu-gpu').install(love) +local Runtime=require('src.compat.gen1.FieldEffectRuntime') +local Host=require('src.mods.Runtime') +local Assets=require('src.render.Assets') +local Version=require('src.core.GameVersion') +local decode=require('src.compat.gen1.FieldEffectCache') +local function pixel(seed,w,h) + local out={};for y=0,h-1 do for x=0,w-1 do out[#out+1]=string.char((x*31+seed)%256,(y*17+seed)%256,(x+y*7)%256,255)end end + return table.concat(out) +end +local manifests={} +local function cache(text,rgba) + local out={reads={}} + function out:read(path) + self.reads[#self.reads+1]=path + T.check(path:find('data/generated/gba/field_effects/',1,true)==1,'only the explicit effect cache root is read') + if path:sub(-11)=='objects.lua'then return text end + if path:sub(-10)=='grass.rgba'then return rgba end + end + return out +end +for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'})do + F.release();local f=require('tools.session-runtime.fixture').new(edition);local game=f.game + local c=game.data.constants;game.data.sprites={} + local expected={} + for i,key in ipairs({'tall_grass','surf_blob','fly_bird','ripple','sparkle','emote_heart'})do + local path='assets/generated/field-test/'..key..'.png';local bytes=pixel(i,16,32) + imageFile(path,16,32,bytes);expected[key]=bytes + local row={image=path,frameWidth=16,frameHeight=16,frames=2,trueColor=true} + if key=='tall_grass'then c.gen3TallGrass=row + elseif key=='surf_blob'then c.gen3SurfBlob=row + elseif key=='fly_bird'then c.gen3FRLGFlyBird={path=path,frameWidth=16,frameHeight=16,frames=2} + elseif key=='emote_heart'then c.gen3Emotes={heart={image=path,width=16,height=16,frames=2}} + else c[key=='ripple'and'gen3Ripple'or'gen3Sparkle']={key=key};game.data.sprites[key]=row end + end + local original=game.data.constants.gen3TallGrass + for name,bytes in pairs(expected)do + Host.currentMod='effects-a';local sheet=assert(F.loadSheet(name,128,128,256));Host.currentMod=nil + T.eq(sheet.fw,16);T.eq(sheet.fh,16);T.eq(sheet.frames,2,'supplied geometry cannot reinterpret native images') + T.eq(read(sheet.image),bytes,'native source pixels remain in their original order') + local x,y,w,h=sheet.quadsFront[1]:getViewport();T.eq(x,0);T.eq(y,24);T.eq(w,16);T.eq(h,8) + end + local view=F.manifest();view._byName.tall_grass.fw=88 + T.eq(F.manifestObject('tall_grass').fw,16,'metadata readers receive detached records') + T.eq(F.loadSheet('emoticons'),nil,'separate native bubbles cannot masquerade as a combined source strip') + T.eq(F.loadSheet('../tall_grass'),nil) + local native=assert(F.loadSheet('tall_grass'));local nativeImage=native.image + local borrowed=love.graphics.newImage(love.image.newImageData(original.image)) + Host.currentMod='effects-b';T.eq(F.loadSheet('tall_grass'),native);Host.currentMod=nil + F.cancelOwner('effects-a');T.eq(native._retired,nil,'a peer lease keeps the shared owned sheet alive') + F.cancelOwner('effects-b');T.eq(native._retired,nil,'an engine request still owns this sheet') + native.image=borrowed + native.quads={external={release=function()error('borrowed quad must never be released')end}} + F.invalidate();T.eq(native._retired,true);T.eq(read(borrowed),expected.tall_grass,'public table mutation cannot transfer ownership of a borrowed image') + T.eq(game.data.constants.gen3TallGrass,original);T.check(not pcall(nativeImage.getWidth,nativeImage)) + borrowed:release() + local family=Version.isFRLG(edition)and'frlg'or'rse' + local bytes=pixel(93,8,16) + local text="return {family='"..family.."',objects={[0]={name='tall_grass',fw=8,fh=8,frames=2,rgba='grass.rgba',anims={{{'frame',0,3},{'frame',1,4}}}}},extras={}}" + local source=cache(text,bytes) + Host.currentMod='provider-owner';T.check(F.install(source)~=nil) + local sheet=assert(F.loadSheet('tall_grass',16,16,5));Host.currentMod=nil + T.eq(sheet.fw,8);T.eq(sheet.fh,8);T.eq(sheet.frames,2);T.eq(read(sheet.image),bytes,'explicit source RGBA remains exact') + T.eq(F.loadSheet('surf_blob'),nil,'an explicit authored manifest remains authoritative') + local copy=F.manifestObject('tall_grass');copy.anims[1][1][2]=99 + T.eq(F.manifestObject('tall_grass').anims[1][1][2],0) + F.cancelOwner('provider-owner');T.eq(sheet._retired,true);T.eq(F._cache,nil) + T.eq(assert(F.loadSheet('tall_grass')).fw,16,'native resources resume after provider retirement') + Host.currentMod='provider-owner';assert(F.install(source));local previous=assert(F.loadSheet('tall_grass'));Host.currentMod=nil + game.save={};T.eq(assert(F.loadSheet('tall_grass')).fw,16,'a save cohort switch cannot replay an old cache lease') + T.eq(previous._retired,true);T.eq(F._cache,nil) + local runtime=Runtime.new();runtime.maxBytes=1 + local missing,why=runtime:get('tall_grass');T.eq(missing,nil);T.check(why:find('budget',1,true)~=nil) + runtime.maxBytes=1024*1024 + local before=#created;local quad=love.graphics.newQuad + love.graphics.newQuad=function(...)if#created>=before+1 then error('injected second quad failure')end;return quad(...)end + T.eq(runtime:get('tall_grass'),nil);love.graphics.newQuad=quad + T.eq(runtime.bytes,0);T.eq(next(runtime.loaded),nil) + for i=before+1,#created do T.eq(created[i].dead,true,'failed preparation releases every partial allocation')end + T.check(runtime:get('tall_grass')~=nil,'a failed upload is retryable');runtime:release() + local wrong=cache(text:gsub(family,family=='frlg'and'rse'or'frlg'),bytes) + local m,err=decode.manifest(wrong,'data/generated/gba/field_effects',family) + T.eq(m,nil);T.check(err:find('family',1,true)~=nil) + for _,bad in ipairs({text:gsub('grass.rgba','../grass.rgba'),text:gsub('frames=2','frames=999'), + text:gsub('extras={}',"extras={{name='tall_grass',fw=8,fh=8,frames=2,rgba='grass.rgba'}}"), + 'return {objects=function()end}','while true do end',string.char(27)..'bytecode'})do + T.eq(decode.manifest(cache(bad,bytes),'data/generated/gba/field_effects',family),nil,'malformed source metadata is rejected') + end + local sentinel=function()end;debug.sethook(sentinel,'',1000000) + T.eq(decode.manifest(cache('error()'), 'data/generated/gba/field_effects',family),nil) + local restored=debug.gethook();debug.sethook();T.eq(restored,sentinel,'manifest execution restores the host debug hook') + local changing=cache(text,bytes);local reader=changing.read + function changing:read(path) + if path:sub(-10)=='grass.rgba'then F.release()end + return reader(self,path) + end + Host.currentMod='stale-owner';assert(F.install(changing));local stale=F.loadSheet('tall_grass');Host.currentMod=nil + T.eq(stale,nil,'a reentrant cache reader cannot publish retired resources') + T.eq(assert(F.loadSheet('tall_grass')).fw,16) + F.release();T.eq(F._cache,nil) +end +F.release();package.loaded['src.core.Game']=nil +T.eq(F.manifest(),nil) +T.finish('owned field-effect artwork and cache leases') diff --git a/tools/field-effect-sheets/host/conf.lua b/tools/field-effect-sheets/host/conf.lua new file mode 100644 index 000000000..11ea0ed0f --- /dev/null +++ b/tools/field-effect-sheets/host/conf.lua @@ -0,0 +1,8 @@ +function love.conf(t) + t.identity=assert(os.getenv('POKEPORT_IDENTITY')) + assert(t.identity:match('^oasc%-gen2recomp%-qa%-field%-sheets%-gpu%-')) + t.version='12.0';t.gammacorrect=false + t.window.width,t.window.height=320,240;t.window.vsync=0;t.window.highdpi=false + t.modules.audio,t.modules.joystick,t.modules.physics=false,false,false + t.graphics.renderers={assert(os.getenv('POKEPORT_GRAPHICS_BACKEND'))} +end diff --git a/tools/field-effect-sheets/host/main.lua b/tools/field-effect-sheets/host/main.lua new file mode 100644 index 000000000..e78e27652 --- /dev/null +++ b/tools/field-effect-sheets/host/main.lua @@ -0,0 +1,87 @@ +io.stdout:setvbuf('no') +local root=assert(os.getenv('FIELD_SHEETS_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 g=love.graphics;local T=require('tests.harness').suite('owned field-effect sheet GPU resources') + local renderer,version,vendor,device=g.getRendererInfo() + local backend=assert(os.getenv('POKEPORT_GRAPHICS_BACKEND'));assert(renderer:lower():find(backend,1,true)) + local identity=assert(os.getenv('POKEPORT_IDENTITY'));assert(love.filesystem.getIdentity()==identity) + local F=require('src.core.game3.field_effects');local V=require('src.core.GameVersion') + local Assets=require('src.render.Assets');local Host=require('src.mods.Runtime') + 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 pixels(w,h,seed) + local rows={} + for y=0,h-1 do for x=0,w-1 do + if(x+y)%7==0 then rows[#rows+1]=string.char(0,0,0,0) + else rows[#rows+1]=string.char((x*7+seed)%256,(y*13+seed)%256,(x+y*3+seed)%256,255)end + end end + return table.concat(rows) + end + local function crop(bytes,w,y,h)return bytes:sub(y*w*4+1,(y+h)*w*4)end + love.filesystem.createDirectory('assets/generated/qa-field') + local sizes={tall_grass={16,16,5},surf_blob={32,32,6},fly_bird={64,64,5},ripple={16,16,5},sparkle={16,16,5},emote_heart={16,16,3}} + for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'})do + F.release();V.set(edition);local data={constants={},sprites={}} + local game={data=data,save={},mods={}};package.loaded['src.core.Game']=game + local source={} + for name,size in pairs(sizes)do + local w,h,n=size[1],size[2],size[3];local bytes=pixels(w,h*n,n) + local path='assets/generated/qa-field/'..name..'.png' + local image=love.image.newImageData(w,h*n,'rgba8',bytes);image:encode('png',path);image:release() + source[name]={path=path,bytes=bytes,w=w,h=h,n=n} + local row={image=path,frameWidth=w,frameHeight=h,frames=n,trueColor=true} + if name=='tall_grass'then data.constants.gen3TallGrass=row + elseif name=='surf_blob'then data.constants.gen3SurfBlob=row + elseif name=='fly_bird'then data.constants.gen3FRLGFlyBird={path=path,frameWidth=w,frameHeight=h,frames=n} + elseif name=='emote_heart'then data.constants.gen3Emotes={heart={image=path,width=w,height=h,frames=n}} + else data.constants[name=='ripple'and'gen3Ripple'or'gen3Sparkle']={key=name};data.sprites[name]=row end + end + for name,s in pairs(source)do + local borrowed=Assets.image(s.path);local nativeBefore=read(borrowed) + Host.currentMod='gpu-a';local sheet=assert(F.loadSheet(name));Host.currentMod=nil + T.eq(read(sheet.image),s.bytes,edition..' '..name..' complete image matches its actual PNG') + T.check(sheet.image~=borrowed,'native asset-cache images are never lent') + for frame=0,s.n-1 do + for _,front in ipairs({false,true})do + local h=front and 8 or s.h;local target=g.newCanvas(s.w,h,{format='rgba8',dpiscale=1,msaa=0}) + g.push('all');g.origin();g.setCanvas(target);g.setShader();g.setScissor();g.setColor(1,1,1,1) + g.clear(0,0,0,0);g.setBlendMode('replace','premultiplied') + g.draw(sheet.image,(front and sheet.quadsFront or sheet.quads)[frame],0,0);g.pop() + T.eq(read(target),crop(s.bytes,s.w,frame*s.h+(front and s.h-8 or 0),h),'original frame/crop pixels') + comparisons=comparisons+1;target:release() + end + end + Host.currentMod='gpu-b';T.eq(F.loadSheet(name),sheet);Host.currentMod=nil + F.cancelOwner('gpu-a');T.eq(sheet._retired,nil) + sheet.image=borrowed + F.cancelOwner('gpu-b');T.eq(sheet._retired,true) + T.eq(read(borrowed),nativeBefore,'native cached images survive peer retirement') + end + -- Native generated-asset overrides remain authoritative, with exact geometry. + local base=source.tall_grass;local path='assets/generated/qa-field/override.png' + local changed=pixels(base.w,base.h*base.n,193) + local image=love.image.newImageData(base.w,base.h*base.n,'rgba8',changed);image:encode('png',path);image:release() + local old=Assets.resolve + Assets.resolve=function(p)return p==base.path and path or old(p)end + local override=assert(F.loadSheet('tall_grass'));T.eq(read(override.image),changed) + Assets.resolve=old + local restored=assert(F.loadSheet('tall_grass'));T.eq(read(restored.image),base.bytes);T.eq(override._retired,true) + local raw=pixels(8,32,73);local manifest="return {family='"..(V.isFRLG(edition)and'frlg'or'rse').."',objects={{name='custom',fw=8,fh=8,frames=4,rgba='custom.rgba'}}}" + local provider={read=function(_,p)if p:sub(-11)=='objects.lua'then return manifest end;if p:sub(-11)=='custom.rgba'then return raw end end} + Host.currentMod='gpu-cache';assert(F.install(provider));local custom=assert(F.loadSheet('custom'));Host.currentMod=nil + T.eq(read(custom.image),raw,'explicit source RGBA requires no conversion file') + Assets.invalidate();T.eq(custom._retired,true) + local again=assert(F.loadSheet('custom'));T.eq(read(again.image),raw) + F.release();T.eq(again._retired,true);T.eq(F._cache,nil) + end + T.finish() + local result={status='pass',checks=T.checks,frameComparisons=comparisons,readbacks=readbacks,backend=backend,renderer=renderer, + version=version,vendor=vendor,device=device,identity=identity,playerProfilesUsed=false,fullFieldEffectApi=false,fullRendererAcceptance=false} + local f=assert(io.open(assert(os.getenv('FIELD_SHEETS_OUT'))..'/result.json','w'));f:write(require('src.link.Json').encode(result));f:close() + print('Field sheet GPU pass',comparisons,readbacks);love.event.quit(0) +end diff --git a/tools/field-effect-sheets/lifecycle-check.lua b/tools/field-effect-sheets/lifecycle-check.lua new file mode 100644 index 000000000..c5e1b31d6 --- /dev/null +++ b/tools/field-effect-sheets/lifecycle-check.lua @@ -0,0 +1,60 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +require('tools.sprite-runtime.cpu-gpu').install(love) +local native=require('tools.session-runtime.fixture').new('emerald').game +local Json=require('src.link.Json') +local api=tonumber(os.getenv('SESSION_RUNTIME_API'))or require('src.core.Version').modApi +assert(api==2 or api==3) +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 +local function entry(seed) + return ([[local F=require('src.core.game3.field_effects') + local metadata="return {family='rse',objects={{name='tall_grass',fw=8,fh=8,frames=1,rgba='grass.rgba'}}}" + assert(F.install({read=function(_,path) + if path:sub(-11)=='objects.lua'then return metadata end + if path:sub(-10)=='grass.rgba'then return string.rep(string.char(%d,0,0,255),64)end + end})) + local view=assert(F.loadSheet('tall_grass')) + ]]):format(seed) +end +mod('field_owner',100,[[local mod=...;]]..entry(17)..[[ + local previous=F.loadSheet;mod.exports.base=previous + F.loadSheet=function(...)return previous(...)end + mod.exports.observe=function()return F.loadSheet('tall_grass')end +]]) +mod('field_bad',200,entry(29)..[[ + F.loadSheet=function()error('failed field wrapper survived')end + error('intentional field entry failure') +]]) +mod('field_late',300,[[local mod=...;]]..entry(31)..[[ + F.loadSheet=function()error('late field wrapper survived')end + mod.content.map_scripts:register('FIELD_VALIDATION',{scripts={invalid={{'field_intentionally_unknown_command'}}}}) +]]) +local run=T.sdk.loadMods({'mods/field_owner','mods/field_bad','mods/field_late'}, + {fs=T.sdk.memfs(files),data=T.fixtures.fresh()}) +T.eq(#run.errors,2) +local F=require('src.core.game3.field_effects') +T.check(F._cache~=nil,'failed owners leave the successful provider installed') +local peer=run.loader.exports.field_owner +local sheet=assert(peer.observe());local bytes=sheet.imageData:getString() +T.eq(bytes,string.rep(string.char(17,0,0,255),64),'failed and late-rejected providers cannot shadow the successful one') +native.mods=run.loader;run.loader.game=native +local fallback,why=F.loadSheet('tall_grass');T.eq(fallback,nil,'changing the mod-set cohort retires stale provider leases') +T.eq(sheet._retired,true) +-- A fresh callback in the active cohort can establish a new owned lease. +local Host=require('src.mods.Runtime');Host.currentMod='field_owner' +local cache={read=function(_,path) + if path:sub(-11)=='objects.lua'then return "return {family='rse',objects={{name='tall_grass',fw=8,fh=8,frames=1,rgba='grass.rgba'}}}"end + return string.rep(string.char(17,0,0,255),64) +end} +assert(F.install(cache));local current=assert(F.loadSheet('tall_grass'));Host.currentMod=nil +local image=current.image +require('src.mods.Loader').teardown() +T.eq(current._retired,true);T.check(not pcall(image.getWidth,image)) +T.eq(F._cache,nil);T.eq(F.loadSheet,peer.base,'successful field wrappers unwind on teardown') +run.release() +T.finish('field-effect resources through SDK lifecycle API '..api) From 780b25985d0e1421d8062a8f7f7a300ffe247988 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 04:31:13 +0100 Subject: [PATCH 003/129] Read uploaded field images through actual GPU render targets --- tools/field-effect-sheets/host/main.lua | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tools/field-effect-sheets/host/main.lua b/tools/field-effect-sheets/host/main.lua index e78e27652..8e089ebe0 100644 --- a/tools/field-effect-sheets/host/main.lua +++ b/tools/field-effect-sheets/host/main.lua @@ -10,9 +10,17 @@ function love.load() local F=require('src.core.game3.field_effects');local V=require('src.core.GameVersion') local Assets=require('src.render.Assets');local Host=require('src.mods.Runtime') 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 + local function read(texture) + -- Some native backends cannot read an uploaded Image directly. Stage its + -- actual GPU pixels through a render target, preserving all draw state. + local w,h=texture:getDimensions() + local target=g.newCanvas(w,h,{format='rgba8',dpiscale=1,msaa=0}) + g.push('all');g.origin();g.setCanvas(target);g.setShader();g.setScissor() + g.clear(0,0,0,0);g.setColor(1,1,1,1);g.setBlendMode('replace','premultiplied') + g.draw(texture,0,0);g.pop() + local data=g.readbackTexture and g.readbackTexture(target)or target:newImageData() + local bytes=data:getString();data:release();target:release() + readbacks=readbacks+1;return bytes end local function pixels(w,h,seed) local rows={} From 186081a44faabb7ec6d1fe51983c360042087ec4 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 04:24:57 +0100 Subject: [PATCH 004/129] Preserve contribution credit and upstream integration permission --- LICENSE-ROXAS2712.md | 88 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 LICENSE-ROXAS2712.md 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. From a8b3096f1cbaa3a68cfd8305bccc596c777eb3c2 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 04:45:46 +0100 Subject: [PATCH 005/129] Retire loaded field resources after mod wrapper unwind on reload and teardown --- src/mods/Loader.lua | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..94366096a 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -32,6 +32,13 @@ require("src.core.ModCompat").install() local Loader = {} Loader.__index = Loader +-- Only an already-loaded field service owns resources. Retire it after +-- wrapper unwind without importing a compatibility module on ordinary boot. +local function retireFieldEffects() + local service=package.loaded['src.core.game3.field_effects'] + if type(service)=='table' and type(service.release)=='function' then service.release()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 +1322,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() + retireFieldEffects() 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 +1662,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()) + retireFieldEffects() pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) Runtime.currentMod = nil From b1388f158604e8ad58815ba583c539cec15ff0e0 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 04:57:40 +0100 Subject: [PATCH 006/129] Bind field-move presentation to native admissions and owned resources --- docs/native-field-move-presentation.md | 34 ++++ src/core/game3/field_move_show_mon.lua | 100 +++++++++ src/render/Renderer.lua | 1 + src/world/Gen3FieldMove.lua | 192 ++++++++++++++++-- .../engine/native_field_move_presentation.lua | 124 +++++++++++ tools/field-move-runtime/host/conf.lua | 6 + tools/field-move-runtime/host/main.lua | 81 ++++++++ 7 files changed, 518 insertions(+), 20 deletions(-) create mode 100644 docs/native-field-move-presentation.md create mode 100644 src/core/game3/field_move_show_mon.lua create mode 100644 tests/engine/native_field_move_presentation.lua create mode 100644 tools/field-move-runtime/host/conf.lua create mode 100644 tools/field-move-runtime/host/main.lua diff --git a/docs/native-field-move-presentation.md b/docs/native-field-move-presentation.md new file mode 100644 index 000000000..90718a135 --- /dev/null +++ b/docs/native-field-move-presentation.md @@ -0,0 +1,34 @@ +# Native field-move presentation ownership + +`src.core.game3.field_move_show_mon` uses the existing `Gen3FieldMove` +StateStack admission. `start` and the native entry seam use that admission's +Pokemon, optional pose and guarded continuation. `step` consumes only the +current native update scope. Native draw wrappers consume one draw body; +explicit source draws render the current native picture in the caller's +coordinates. The renderer's frame serial prevents the normal UI pass from +painting the same externally rendered cut-in again. Drawing never advances +the streak or player-pose clock. + +The native admission owns its front image and resolves actual generated-asset +overrides. Completion, explicit reset, failed entry and owner retirement +release it and restore the prior player pose. Covered admissions cannot pop a +foreign screen or advance through direct source ticks. Cancelling a source +owner under another screen retires its callback immediately and leaves that +cover in place. Continuations preserve nil returns/errors and cannot execute +twice or before native completion. Retired callbacks release captured data, +and cohort references are weak so retained wrappers cannot keep a retired +save alive. Observer release leaves engine-owned continuations intact. + +This is not original cut-in visual/task/audio equivalence. The current native +band, dimensions and phase lengths are still the existing reconstructed +presentation, not the original indoors/outdoors streak artwork and window +tasks. Exact cry modes/ducking, original `_fx` internals and shared band-image +cache semantics remain open. `noDuck` starts explicitly refuse unsupported +options; `invalidate` reports that native resources belong to each admission. +There are no placeholder task tables or additional gameplay clocks. + +The focused lifecycle suite covers all five GBA edition identities. The real +GPU host checks upright asymmetric pictures, repeated/explicit/native draw +composition, two view sizes, one native clock and borrowed-image preservation. +Those synthetic checks do not establish original cartridge artwork parity, +unchanged mod initialization, complete renderer behavior or gameplay. diff --git a/src/core/game3/field_move_show_mon.lua b/src/core/game3/field_move_show_mon.lua new file mode 100644 index 000000000..913950f74 --- /dev/null +++ b/src/core/game3/field_move_show_mon.lua @@ -0,0 +1,100 @@ +-- The actual native field-move admission owns its clock, pose and picture. +local Session=require('src.compat.gen1.NativeSession') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local Stream=require('src.core.game3.asset_stream') +local M={} +local owned=setmetatable({},{__mode='k'}) +local starting +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 and facade._callbackOwner() +end +local function current() + local game=Session.game();if not game then return end + local class=package.loaded['src.world.Gen3FieldMove'];if not class then return end + for i=#(game.stack and game.stack.states or{}),1,-1 do + local state=game.stack.states[i] + if getmetatable(state)==class and not state.done and not state.cancelled and class.present(state)then return state end + end +end +local function call(name,...) + local facade=package.loaded['src.core.Game3'] + if facade and facade._withCallbackOwner then + local seams=package.loaded['src.mods.ModSeams'];local who + 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 who=change.owner;break end + end + return facade._withCallbackOwner(who or facade._callbackOwner(),M[name],...) + end + return M[name](...) +end +function M.isActive()return current()~=nil end +function M.start(mon,opts,onDone) + if Boundary.active('showmon.start')then + if starting.startConfigured then return true end + local ok,why=starting:configure(mon,opts or{},onDone) + if not ok then return false,why end + starting.startConfigured=true;return Boundary.invoke('showmon.start') + end + local game,why=Session.resolve();if not game then return false,why end + if current()then return false,'native field move is busy'end + if opts~=nil and type(opts)~='table'then return false,'invalid field move options'end + opts=opts or{} + if opts.noDuck then return false,'native field-move cry ducking options are not yet available'end + if onDone~=nil and type(onDone)~='function'then return false,'invalid field move continuation'end + if type(mon)=='number'then mon={species=mon}end + if type(mon)~='table'then return false,'invalid field move Pokemon'end + local native=require('src.world.Gen3FieldMove') + local shown,state=native.show(game,mon,onDone,{pose=opts.pose==true,sourceOwned=true,sourceOwner=owner()}) + if shown then owned[state]=true end + return shown +end +function M.step()return Boundary.invoke('showmon.step')end +function M.draw() + if Boundary.active('showmon.draw')then return Boundary.invoke('showmon.draw')end + local state=current();if not state then return false end + return state:drawExternal() +end +function M.reset() + local state=current();if not state then return false end + local who=owner() + if state.sourceOwned and state.sourceOwner and who and who~=state.sourceOwner then return false,'field move belongs to another mod'end + return state:cancel() +end +function M.invalidate() + -- There is no shared band-image cache: the native admission owns its + -- immutable picture and retires it on exit. Dataset/asset cache ownership + -- stays with the existing importer and Assets subsystem. + return false,'native field-move resources are owned per admission' +end +function M.cancelOwner(who) + for state in pairs(owned)do if state.sourceOwner==who then state:cancel();owned[state]=nil end end +end +function M.release() + for state in pairs(owned)do state:cancel()end + owned=setmetatable({},{__mode='k'}) +end +local service={release=M.release,releaseOwner=function(_,who)return M.cancelOwner(who)end,poll=function() + for state in pairs(owned)do + if not state:cohortCurrent()then state:cancel();owned[state]=nil end + end +end} +Stream.register(service) +-- Keep the weakly registered service alive with this module. +M._service=service +function M._nativeStart(state) + if state.sourceOwned then return true end + local previous=starting;starting=state + local ok,result=pcall(Boundary.dispatch,'showmon.start',function()return true end,function() + return call('start',state.mon,{pose=state.player~=nil},state.onDone) + end) + starting=previous;if not ok then error(result,0)end;return result +end +function M._nativeStep(state,body) + return Boundary.dispatch('showmon.step',body,function()return call('step')end) +end +function M._nativeDraw(state,body) + return Boundary.dispatch('showmon.draw',body,function()return call('draw')end) +end +return M diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 7cff87285..d9e5ddcc1 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -320,6 +320,7 @@ end -- transparent: the world pass shows through (UI pass draws overlays only) function Renderer:beginFrame(transparent) + self.frameSerial=(self.frameSerial or 0)+1 -- a frame that ends early -- a state that returns before endFrame, an error -- caught upstream -- must not leak a queued native-resolution draw into the -- next frame's composite diff --git a/src/world/Gen3FieldMove.lua b/src/world/Gen3FieldMove.lua index 4d810425a..07ab0ac92 100644 --- a/src/world/Gen3FieldMove.lua +++ b/src/world/Gen3FieldMove.lua @@ -33,9 +33,13 @@ -- the cache carries them. The timings below are measured off the scene. local Font = require("src.render.Font") +local Version = require("src.core.GameVersion") local Gen3FieldMove = {} Gen3FieldMove.__index = Gen3FieldMove +local preparing=setmetatable({},{__mode='k'}) +local unpack=table.unpack or unpack +local function pack(...)return {n=select('#',...),...}end -- RECONSTRUCTED: the beats of the sweep, in frames. -- @@ -65,6 +69,28 @@ local OUT_FRAMES = 18 local BAND_TOP, BAND_HEIGHT = 52, 56 local STREAKS = 11 +local function sameCohort(self) + local c=self.cohort + return c and type(c.data)=='table' and type(c.stack)=='table' and self.game.data==c.data and self.game.save==c.save + and self.game.mods==c.mods and self.game.stack==c.stack and Version.get()==c.edition +end +local function present(self) + if not sameCohort(self) then return false end + for _,state in ipairs(self.cohort.stack.states or {})do if state==self then return true end end + return false +end +local function restorePose(self) + if self.player and not self.poseRestored then + self.poseRestored=true + self.player.fieldMove,self.player.fieldMovePose=self.hadPose,self.hadPoseFrame + self.player=nil + end +end +local function dropSprite(self) + local sprite=self.sprite;self.sprite=nil + if sprite and sprite.release then sprite:release()end +end + function Gen3FieldMove:uiSize() return require("src.ui.Theme").uiSize() end @@ -84,39 +110,74 @@ local function bandColours(game) return shade, ink end -local function frontSprite(game, mon) +local function frontPath(game, mon) if not (mon and mon.species) then return nil end local ok, path = pcall(function() return require("src.pokemon.Sprites").path( game.data, mon.species, "front", { mon = mon }) end) if not (ok and type(path) == "string") then return nil end - local okImg, img = pcall(love.graphics.newImage, path) - return okImg and img or nil + return require('src.render.Assets').resolve(path) +end +local function frontSprite(game, mon) + local path=frontPath(game,mon);if not path then return nil end + local ok,img=pcall(love.graphics.newImage,path) + return ok and img or nil,path end -- Push the sweep over whatever is on screen and call `onDone` when it has -- passed. Returns false when there is nothing to show -- no Pokemon, or a -- dataset with no picture for it -- so the caller can simply get on with the -- move rather than waiting on a state that would draw nothing. -function Gen3FieldMove.show(game, mon, onDone) - if not (game and game.stack) then return false end - local sprite = frontSprite(game, mon) +local function create(game, mon, onDone, opts) + if not (game and game.stack and game.stack.push) then return false end + if onDone~=nil and type(onDone)~='function'then return false end + for _,state in ipairs(game.stack.states or {})do + if getmetatable(state)==Gen3FieldMove and not state.done and not state.cancelled then return false end + end + opts=opts or {} + local cohort=setmetatable({data=game.data,save=game.save,mods=game.mods,stack=game.stack,edition=Version.get()},{__mode='v'}) + local sprite,path = frontSprite(game, mon) if not sprite then return false end local self = setmetatable({}, Gen3FieldMove) self.game = game + self.cohort=cohort + if not sameCohort(self)then if sprite.release then sprite:release()end;return false end self.mon = mon self.sprite = sprite - self.onDone = onDone + self.spritePath=path + self.sourceOwned,self.sourceOwner=opts.sourceOwned,opts.sourceOwner + local callback=onDone + local completed,failed,result=false,nil,nil + self.discardContinuation=function() + callback=nil + if self.cancelled or not sameCohort(self)then failed,result=nil,nil end + end + self.onDone=function() + if self.cancelled or not sameCohort(self)then return false,'native field move admission retired'end + if failed then error(failed,0)end + if completed then + if not result then return false,'native field move continuation is already executing'end + return unpack(result,1,result.n) + end + if not self.completed or self.cancelled or not sameCohort(self)then return false,'native field move has not completed'end + completed=true + local fn=callback;callback=nil + local values=pack(pcall(function()if fn then return fn()end end));fn=nil + if not values[1]then failed=values[2];error(failed,0)end + result={n=values.n-1};for i=2,values.n do result[i-1]=values[i]end + return unpack(result,1,result.n) + end self.t = 0 self.phase = "raise" self.shade, self.ink = bandColours(game) -- THE POSE GOES ON HERE and comes off in finish(), so it is on screen for -- exactly as long as the window is -- which is what the cartridge does. local player = game.overworld and game.overworld.player - if player then + if player and opts.pose~=false then self.player = player self.hadPose = player.fieldMove + self.hadPoseFrame=player.fieldMovePose player.fieldMove = true -- the pose's own frame counter. It lives here rather than on the player -- because the overworld does not update while this state is on top, so @@ -124,23 +185,87 @@ function Gen3FieldMove.show(game, mon, onDone) self.age = 0 player.fieldMovePose = 0 end - game.stack:push(self) + local ok,why=pcall(game.stack.push,game.stack,self) + if not ok then + self:cancel() + if game.stack.removeAll then game.stack:removeAll({self})end + error(why,0) + end + if self.entryRejected then self:cancel();return false end + return true,self +end + +function Gen3FieldMove.show(game,mon,onDone,opts) + if type(game)~='table'or preparing[game]then return false,'native field move preparation is busy'end + preparing[game]=true + local result=pack(pcall(create,game,mon,onDone,opts));preparing[game]=nil + if not result[1]then error(result[2],0)end + return unpack(result,2,result.n) +end + +function Gen3FieldMove:enter() + local source=package.loaded['src.core.game3.field_move_show_mon'] + if source and source._nativeStart then + local result=source._nativeStart(self) + if result==false then self.entryRejected=true end + return result + end +end + +-- Start wrappers configure the already-admitted native state. Changing the +-- Pokemon replaces only this admission's owned image, before its first tick. +function Gen3FieldMove:configure(mon,opts,onDone) + if self.t~=0 or self.cancelled or not present(self)then return false,'native field-move start phase retired'end + if type(mon)=='number'then mon={species=mon}end + if type(mon)~='table'or type(opts)~='table'or(onDone~=nil and type(onDone)~='function')then return false,'invalid field-move start'end + if opts.noDuck then return false,'native field-move cry ducking options are not yet available'end + local path=frontPath(self.game,mon);if not path then return false,'native field-move picture unavailable'end + if path~=self.spritePath then + local ok,image=pcall(love.graphics.newImage,path) + if not ok then return false,'native field-move picture unavailable'end + if not present(self)or self.cancelled then if image.release then image:release()end;return false,'native field-move start phase retired'end + dropSprite(self);self.sprite,self.spritePath=image,path + end + self.mon,self.onDone=mon,onDone + if opts.pose==true and not self.player then + local player=self.game.overworld and self.game.overworld.player + if player then + self.player=player;self.hadPose,self.hadPoseFrame=player.fieldMove,player.fieldMovePose + self.poseRestored=nil;self.age=0;player.fieldMove,player.fieldMovePose=true,0 + end + elseif opts.pose~=true and self.player then + restorePose(self);self.player=nil + end + return true +end + +function Gen3FieldMove:exit() + restorePose(self);dropSprite(self) + if not self.completed then self.discardContinuation()end +end + +function Gen3FieldMove:cancel() + self.cancelled=true;self.discardContinuation();self.onDone=nil + restorePose(self);dropSprite(self) + local stack=self.cohort.stack + if stack and self.game.stack==stack and stack:top()==self then stack:pop()end return true end function Gen3FieldMove:finish() - if self.done then return end - self.done = true - if self.player then - self.player.fieldMove = self.hadPose - self.player.fieldMovePose = nil - end - if self.game.stack then self.game.stack:pop() end - if self.onDone then self.onDone() end + if self.done then return false end + if not present(self) or self.cohort.stack:top()~=self then return false,'native field move is covered or retired'end + self.done,self.completed = true,true + local callback=self.onDone;self.onDone=nil + self.cohort.stack:pop() + if callback then return callback()end end -function Gen3FieldMove:update() +local function updateBody(self) + if self.cancelled or not sameCohort(self)then return self:cancel()end + if self.done or not present(self) or self.cohort.stack:top()~=self then return false end self.t = self.t + 1 + self.frame=(self.frame or 0)+1 if self.player then self.age = (self.age or 0) + 1 -- drawPose clamps, so this simply counts upward and the pose settles on @@ -158,6 +283,14 @@ function Gen3FieldMove:update() end end +function Gen3FieldMove:update() + if self.cancelled or not sameCohort(self)then return self:cancel()end + if self.done or not present(self) or self.cohort.stack:top()~=self then return false end + local source=package.loaded['src.core.game3.field_move_show_mon'] + if source and source._nativeStep then return source._nativeStep(self,function()return updateBody(self)end)end + return updateBody(self) +end + -- A cutscene swallows the pad: pressing A during the sweep must not reach the -- map underneath and start something else. function Gen3FieldMove:keypressed() end @@ -176,7 +309,8 @@ function Gen3FieldMove:offset(width) return 0 end -function Gen3FieldMove:draw() +local function drawBody(self) + if self.cancelled or self.done or not present(self) or not self.sprite then return false end -- nothing at all while the ball goes up: see RAISE_FRAMES if self.phase == "raise" then return end local w = select(1, self:uiSize()) @@ -190,7 +324,6 @@ function Gen3FieldMove:draw() -- sweep rather than a bar g.setColor(self.ink[1], self.ink[2], self.ink[3], 0.35) local frame = (self.frame or 0) - self.frame = frame + 1 for i = 1, STREAKS do local y = BAND_TOP + 3 + (i - 1) * math.floor((BAND_HEIGHT - 6) / STREAKS) local len = 40 + (i * 37) % 90 @@ -224,4 +357,23 @@ function Gen3FieldMove:draw() g.setColor(1, 1, 1, 1) end +function Gen3FieldMove:drawExternal() + if self.cancelled or self.done or not present(self)then return false end + local result=drawBody(self) + self.externalDrawFrame=require('src.render.Renderer').frameSerial + return result~=false +end + +function Gen3FieldMove:draw() + if self.cancelled or self.done or not present(self)then return false end + local serial=require('src.render.Renderer').frameSerial + if serial and self.externalDrawFrame==serial then return true end + local source=package.loaded['src.core.game3.field_move_show_mon'] + if source and source._nativeDraw then return source._nativeDraw(self,function()return drawBody(self)end)end + return drawBody(self) +end + +Gen3FieldMove.present=present +Gen3FieldMove.cohortCurrent=sameCohort + return Gen3FieldMove diff --git a/tests/engine/native_field_move_presentation.lua b/tests/engine/native_field_move_presentation.lua new file mode 100644 index 000000000..d6c6b5ef7 --- /dev/null +++ b/tests/engine/native_field_move_presentation.lua @@ -0,0 +1,124 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local _,imageFile=require('tools.sprite-runtime.cpu-gpu').install(love) +local Source=require('src.core.game3.field_move_show_mon') +local Native=require('src.world.Gen3FieldMove') +local Host=require('src.mods.Runtime') +local Renderer=require('src.render.Renderer') +T.eq(Source.isActive(),false,'requiring the bridge does not invent a native admission') +local saved={start=Source.start,step=Source.step,draw=Source.draw} +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + Source.release() + local fixture=require('tools.session-runtime.fixture').new(edition) + local game=fixture.game;local stack=game.stack + game.data.pokemon={[1]={name='TEST',spriteFront='assets/generated/field-mon.png'}} + imageFile('assets/generated/field-mon.png',16,16,string.rep(string.char(17,31,47,255),256)) + local completions=0 + local mon={species=1} + local starts,steps,draws=0,0,0 + Source.start=function(...)starts=starts+1;return saved.start(...)end + Source.step=function(...)steps=steps+1;local a,b=saved.step(...);saved.step(...);return a,b end + Source.draw=function(...)draws=draws+1;local a,b=saved.draw(...);saved.draw(...);return a,b end + local shown,state=Native.show(game,mon,function()completions=completions+1;return 'continued',nil,7 end) + T.eq(shown,true);T.eq(starts,1,'native entry reaches the existing public start wrapper once') + T.eq(Source.isActive(),true);T.eq(fixture.player.fieldMove,true) + T.eq(Native.show(game,mon),false,'one native field-move admission cannot overlap another') + Source.release();T.eq(Source.isActive(),true,'observer teardown preserves an engine-owned field move') + local retained=state.onDone + T.eq(retained(),false,'a retained callback cannot finish before its native admission') + T.eq(completions,0) + local before=state.t;saved.step();T.eq(state.t,before,'source calls outside the phase cannot advance native logic') + stack:update(1/60);T.eq(state.t,before+1,'duplicate base calls advance the real native tick once') + T.eq(steps,1) + local age=state.frame + Renderer.frameSerial=(Renderer.frameSerial or 0)+1 + state:draw();T.eq(state.frame,age,'native drawing cannot advance animation') + T.eq(draws,1) + local cover={};stack:push(cover) + T.eq(state:finish(),false,'covered field moves cannot pop another native screen') + state:update();T.eq(state.t,before+1,'direct updates cannot advance a covered admission') + T.eq(stack:top(),cover);stack:pop() + for i=1,100 do stack:update(1/60)end + T.eq(completions,1,'real native completion calls the pending callback once') + T.eq(Source.isActive(),false);T.eq(state.sprite,nil,'native exit releases its owned image') + T.eq(fixture.player.fieldMove,nil);T.eq(fixture.player.fieldMovePose,nil) + local a,b,c=retained();T.eq(a,'continued');T.eq(b,nil);T.eq(c,7);T.eq(completions,1,'retained callbacks cannot replay') + Source.start=saved.start;Source.step=saved.step;Source.draw=saved.draw + Host.currentMod='source-owner' + T.eq(Source.start(1,{pose=false},function()completions=completions+1 end),true) + Host.currentMod=nil + state=stack:top();T.eq(state.player,nil,'source pose=false does not change the native player') + local cancelCallback=state.onDone + local payload=setmetatable({},{__mode='v'}) + do + local held={};payload[1]=held + Source.reset() + Host.currentMod='source-owner' + Source.start(1,{pose=true},(function(value)return function()return value end end)(held)) + Host.currentMod=nil + end + state=stack:top();local heldCallback=state.onDone + stack:push(cover);Source.cancelOwner('source-owner') + T.eq(stack:top(),cover,'owner retirement preserves a covering native screen') + T.eq(Source.isActive(),false);T.eq(fixture.player.fieldMove,nil) + collectgarbage('collect');collectgarbage('collect') + T.eq(payload[1],nil,'even peer-held wrappers release cancelled callback captures') + T.eq(heldCallback(),false);T.eq(cancelCallback(),false) + stack:pop();stack:update(1/60);T.eq(stack:top(),fixture.world,'cancelled covered admission retires when uncovered') + local count=completions + Native.show(game,mon,function()completions=completions+1 end) + state=stack:top();local stale=state.onDone + local oldSave=setmetatable({game.save},{__mode='v'}) + game.save={};state:update() + T.eq(stack:top(),fixture.world,'save replacement retires the exact native admission') + T.eq(stale(),false);T.eq(completions,count) + collectgarbage('collect');collectgarbage('collect') + T.eq(oldSave[1],nil,'peer-held callbacks cannot retain an entire retired save cohort') + Native.show(game,mon,function()error('deliberate continuation error')end) + state=stack:top();local failed=state.onDone + T.raises(function()state:finish()end,'deliberate continuation error','callback failure remains observable') + T.raises(failed,'deliberate continuation error','retained callback preserves error without replay') + T.eq(stack:top(),fixture.world);T.eq(state.sprite,nil) + -- The normal draw and an explicit source draw share one render admission. + Native.show(game,mon,nil,{pose=false});state=stack:top() + state.phase='in';state.t=1 + local rectangles=0;local rectangle=love.graphics.rectangle + love.graphics.rectangle=function(...)rectangles=rectangles+1;return rectangle(...)end + Renderer.frameSerial=(Renderer.frameSerial or 0)+1 + saved.draw();local external=rectangles + T.check(external>0,'explicit source draw paints the actual native effect') + state:draw();T.eq(rectangles,external,'native UI does not paint the same externally drawn cut-in again') + Renderer.frameSerial=Renderer.frameSerial+1 + state:draw();T.check(rectangles>external,'a following frame retains the ordinary native draw') + love.graphics.rectangle=rectangle + state:cancel() + local nested,again,invocations + invocations=0 + Native.show(game,mon,function()invocations=invocations+1;nested=again();return 'native-result'end) + state=stack:top() + again=state.onDone + -- Reentrant continuations retain the already-executing guard, not a new + -- native admission or a second callback execution. + state:finish();T.eq(nested,false);T.eq(invocations,1);T.eq(again(),'native-result') + game.data.pokemon[2]={name='REPLACEMENT',spriteFront='assets/generated/field-mon-two.png'} + imageFile('assets/generated/field-mon-two.png',16,16,string.rep(string.char(91,73,53,255),256)) + Source.start=function(_,_,callback)return saved.start({species=2},{pose=false},callback)end + local changed + Native.show(game,mon,function()changed=true end) + state=stack:top();T.eq(state.mon.species,2,'native start wrappers configure the actual admitted Pokemon') + T.eq(state.player,nil,'native start wrappers can disable their native pose') + T.eq(state.spritePath,'assets/generated/field-mon-two.png','configured picture uses the native art resolver') + state:finish();T.eq(changed,true) + Source.start=function()return false end + T.eq(Native.show(game,mon,function()error('vetoed field move continued')end),false,'explicit native start veto retires its provisional admission') + T.eq(stack:top(),fixture.world);T.eq(fixture.player.fieldMove,nil) + Source.start=saved.start + Host.currentMod='completed-owner';Source.start(1,{},function()return {}end);Host.currentMod=nil + state=stack:top();local completedCallback=state.onDone + state:finish();T.eq(type(completedCallback()),'table') + Source.cancelOwner('completed-owner');T.eq(completedCallback(),false,'retired source callbacks cannot replay cached results') + T.eq(Source.start(1,{noDuck=true}),false,'unimplemented cry options are not silently accepted') + T.eq(Source.start({},{}),false,'missing real picture cannot create a fake successful effect') +end +Source.release();T.finish('native field-move admission, one clock, explicit draw and continuation lifetime') diff --git a/tools/field-move-runtime/host/conf.lua b/tools/field-move-runtime/host/conf.lua new file mode 100644 index 000000000..23c1046fe --- /dev/null +++ b/tools/field-move-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.renderers={assert(os.getenv('POKEPORT_GRAPHICS_BACKEND'))} +end diff --git a/tools/field-move-runtime/host/main.lua b/tools/field-move-runtime/host/main.lua new file mode 100644 index 000000000..186b1d982 --- /dev/null +++ b/tools/field-move-runtime/host/main.lua @@ -0,0 +1,81 @@ +io.stdout:setvbuf('no') +local root=assert(os.getenv('FIELD_MOVE_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 g=love.graphics + local T=require('tests.harness').suite('native field-move presentation GPU') + local Native=require('src.world.Gen3FieldMove') + local Source=require('src.core.game3.field_move_show_mon') + local Renderer=require('src.render.Renderer');Renderer:init() + local Assets=require('src.render.Assets') + 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 backend') + T.eq(love.filesystem.getIdentity(),os.getenv('POKEPORT_IDENTITY'),'owned QA profile') + local readbacks,comparisons=0,0 + local function canvas(w,h)return g.newCanvas(w,h,{format='rgba8',dpiscale=1,msaa=0})end + local function read(texture) + local w,h=texture:getDimensions();local copy=canvas(w,h) + g.push('all');g.origin();g.setCanvas(copy);g.setShader();g.setScissor();g.clear(0,0,0,0) + g.setColor(1,1,1,1);g.setBlendMode('replace','premultiplied');g.draw(texture,0,0);g.pop() + local data=g.readbackTexture and g.readbackTexture(copy)or copy:newImageData();copy:release() + readbacks=readbacks+1;return data + end + local function draw(target,fn,w,h) + g.push('all');g.origin();g.setCanvas(target);g.setShader();g.setScissor();g.clear(0,0,0,0) + g.setColor(1,1,1,1);g.setBlendMode('alpha');g.scale(w/240,h/160);fn();g.pop() + return read(target) + end + local pixels={} + for y=0,15 do for x=0,15 do pixels[#pixels+1]=y<8 and string.char(255,0,0,255)or string.char(0,255,0,255)end end + local image=love.image.newImageData(16,16,'rgba8',table.concat(pixels)) + love.filesystem.createDirectory('assets/generated/qa-field-move') + local path='assets/generated/qa-field-move/mon.png';image:encode('png',path);image:release() + for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + Source.release();local fixture=require('tools.session-runtime.fixture').new(edition) + local game=fixture.game;game.renderer=Renderer + game.data.pokemon={[1]={spriteFront=path}} + local borrowed=Assets.image(path) + local continued=0 + local shown,state=Native.show(game,{species=1},function()continued=continued+1 end,{pose=false}) + T.eq(shown,true);T.check(state.sprite~=borrowed,'native cut-in owns its image') + state.phase,state.t,state.frame='hold',0,7 + for _,size in ipairs{{240,160},{480,320}}do + local w,h=size[1],size[2];local target=canvas(w,h);local second=canvas(w,h) + Renderer.frameSerial=(Renderer.frameSerial or 0)+1 + local baseline=draw(target,function()state:draw()end,w,h) + local clock,phase=state.frame,state.t + local repeated=draw(second,function()state:draw()end,w,h) + T.eq(repeated:getString(),baseline:getString(),'repeated actual GPU draws do not advance streaks');comparisons=comparisons+1 + T.eq(state.frame,clock);T.eq(state.t,phase) + local scale=w/240 + for _,point in ipairs{{73,73,1,0,0},{73,86,0,1,0}}do + local r,green,b,a=baseline:getPixel(math.floor(point[1]*scale),math.floor(point[2]*scale)) + T.check(math.abs(r-point[3])<1/255 and math.abs(green-point[4])<1/255 and math.abs(b-point[5])<1/255 and a==1,'asymmetric native Pokemon is upright') + end + Renderer.frameSerial=Renderer.frameSerial+1 + local explicit=draw(second,function()Source.draw()end,w,h) + T.eq(explicit:getString(),baseline:getString(),'source draw paints the same real native frame');comparisons=comparisons+1 + local suppressed=draw(target,function()state:draw()end,w,h) + T.eq(suppressed:getString(),string.rep('\0',w*h*4),'same-frame native UI does not repeat an explicit world cut-in');comparisons=comparisons+1 + Renderer.frameSerial=Renderer.frameSerial+1 + local nextFrame=draw(target,function()state:draw()end,w,h) + T.eq(nextFrame:getString(),baseline:getString(),'native UI returns on the next render frame');comparisons=comparisons+1 + T.eq(state.frame,clock);T.eq(state.t,phase) + for _,data in ipairs{baseline,repeated,explicit,suppressed,nextFrame}do data:release()end + target:release();second:release() + end + local tick=state.frame;Source.step();T.eq(state.frame,tick,'source tick outside native scope cannot advance gameplay') + game.stack:update(1/60);T.eq(state.frame,tick+1) + local held=state.sprite;state:finish();T.eq(continued,1);T.eq(state.sprite,nil) + T.check(not pcall(held.getWidth,held),'native completion releases its GPU image') + local still=read(borrowed);T.eq(still:getString(),table.concat(pixels),'borrowed cached picture survives native completion');still: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, + scope='Actual native field-move draw/clock/resource ownership over synthetic artwork; reconstructed native band, no original task/art/audio or gameplay parity.'} + local file=assert(io.open(assert(os.getenv('FIELD_MOVE_REPORT')),'w'));file:write(require('src.link.Json').encode(report));file:close() + print('NATIVE_FIELD_MOVE_GPU_PASS',readbacks,comparisons);love.event.quit(0) +end From 487e1e5058d05d0fd29b0f1f6099afbf4e85d4bc Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 05:01:32 +0100 Subject: [PATCH 007/129] Run field-move GPU qualification on both supported host versions --- tools/field-move-runtime/host/conf.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/field-move-runtime/host/conf.lua b/tools/field-move-runtime/host/conf.lua index 23c1046fe..363802762 100644 --- a/tools/field-move-runtime/host/conf.lua +++ b/tools/field-move-runtime/host/conf.lua @@ -2,5 +2,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 From c83ef08a5f02f1c855b9c9277ef2a64497cf00a6 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 05:04:42 +0100 Subject: [PATCH 008/129] Keep native GBA field cut-ins independent of dialogue theme dimensions --- docs/native-field-move-presentation.md | 3 +++ src/world/Gen3FieldMove.lua | 4 +++- tests/engine/native_field_move_presentation.lua | 6 ++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/native-field-move-presentation.md b/docs/native-field-move-presentation.md index 90718a135..ae6195a45 100644 --- a/docs/native-field-move-presentation.md +++ b/docs/native-field-move-presentation.md @@ -9,6 +9,9 @@ coordinates. The renderer's frame serial prevents the normal UI pass from painting the same externally rendered cut-in again. Drawing never advances the streak or player-pose clock. +The cut-in retains its own 240x160 GBA surface. A mod's dialogue-box theme +cannot shrink it to the Game Boy surface or enlarge it to a DS surface. + The native admission owns its front image and resolves actual generated-asset overrides. Completion, explicit reset, failed entry and owner retirement release it and restore the prior player pose. Covered admissions cannot pop a diff --git a/src/world/Gen3FieldMove.lua b/src/world/Gen3FieldMove.lua index 07ab0ac92..44161491f 100644 --- a/src/world/Gen3FieldMove.lua +++ b/src/world/Gen3FieldMove.lua @@ -92,7 +92,9 @@ local function dropSprite(self) end function Gen3FieldMove:uiSize() - return require("src.ui.Theme").uiSize() + -- The field cut-in has GBA coordinates of its own. A mod's smaller or + -- wider dialogue box cannot change this effect's physical screen. + return 240,160 end -- The two colours the band is drawn in. The cartridge's own window palette diff --git a/tests/engine/native_field_move_presentation.lua b/tests/engine/native_field_move_presentation.lua index d6c6b5ef7..4b9c78958 100644 --- a/tests/engine/native_field_move_presentation.lua +++ b/tests/engine/native_field_move_presentation.lua @@ -22,6 +22,12 @@ for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do Source.draw=function(...)draws=draws+1;local a,b=saved.draw(...);saved.draw(...);return a,b end local shown,state=Native.show(game,mon,function()completions=completions+1;return 'continued',nil,7 end) T.eq(shown,true);T.eq(starts,1,'native entry reaches the existing public start wrapper once') + local theme=require('src.ui.Theme');local previousBox=theme.textBox + for _,box in ipairs{{tx=0,ty=0,tw=20,th=18},{tx=0,ty=0,tw=32,th=24}}do + theme.textBox=box + local width,height=state:uiSize();T.eq(width,240,'dialogue themes cannot resize a native field cut-in');T.eq(height,160) + end + theme.textBox=previousBox T.eq(Source.isActive(),true);T.eq(fixture.player.fieldMove,true) T.eq(Native.show(game,mon),false,'one native field-move admission cannot overlap another') Source.release();T.eq(Source.isActive(),true,'observer teardown preserves an engine-owned field move') From f2cd89d15cda45c5344ba780b48eaf1eb2a66f10 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 05:08:12 +0100 Subject: [PATCH 009/129] Verify field-move rollback and teardown through the actual mod SDK --- tests/engine/native_field_move_lifecycle.lua | 1 + tools/field-move-runtime/lifecycle-check.lua | 55 ++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 tests/engine/native_field_move_lifecycle.lua create mode 100644 tools/field-move-runtime/lifecycle-check.lua diff --git a/tests/engine/native_field_move_lifecycle.lua b/tests/engine/native_field_move_lifecycle.lua new file mode 100644 index 000000000..c8a81da29 --- /dev/null +++ b/tests/engine/native_field_move_lifecycle.lua @@ -0,0 +1 @@ +dofile('tools/field-move-runtime/lifecycle-check.lua') diff --git a/tools/field-move-runtime/lifecycle-check.lua b/tools/field-move-runtime/lifecycle-check.lua new file mode 100644 index 000000000..fa4ee6b5a --- /dev/null +++ b/tools/field-move-runtime/lifecycle-check.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 _,imageFile=require('tools.sprite-runtime.cpu-gpu').install(love) +local newImage=love.graphics.newImage +love.graphics.newImage=function(source) + if type(source)=='string'then source=love.image.newImageData(source)end + return newImage(source) +end +local fixture=require('tools.session-runtime.fixture').new('emerald') +local game=fixture.game +game.data.pokemon={[1]={spriteFront='assets/generated/lifecycle-mon.png'}} +imageFile('assets/generated/lifecycle-mon.png',16,16,string.rep(string.char(7,9,11,255),256)) +local Source=require('src.core.game3.field_move_show_mon') +local Host=require('src.mods.Runtime') +local api=tonumber(os.getenv('SESSION_RUNTIME_API'))or require('src.core.Version').modApi +local files={};local Json=require('src.link.Json') +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('show_owner',100,[[local mod=...;local F=require('src.core.game3.field_move_show_mon') + local previous=F.draw;mod.exports.base=previous + F.draw=function(...)return previous(...)end +]]) +mod('show_bad',200,[[local F=require('src.core.game3.field_move_show_mon') + assert(F.start(1,{pose=true},function()error('failed source owner continued')end)) + F.draw=function()error('failed source wrapper survived')end + error('intentional field-move entry failure') +]]) +mod('show_late',300,[[local mod=...;local F=require('src.core.game3.field_move_show_mon') + assert(F.start(1,{pose=true},function()error('late source owner continued')end)) + F.draw=function()error('late source wrapper survived')end + mod.content.map_scripts:register('SHOW_LATE_VALIDATION',{scripts={invalid={{'show_intentionally_unknown'}}}}) +]]) +local run=T.sdk.loadMods({'mods/show_owner','mods/show_bad','mods/show_late'},{fs=T.sdk.memfs(files),data=T.fixtures.fresh()}) +T.eq(#run.errors,api==1 and 1 or 2,'entry errors and strict validation failures retain their native API policy') +if api==1 then + T.eq(Source.isActive(),true,'legacy script warnings do not turn into strict load failures') + Source.cancelOwner('show_late') +end +T.eq(Source.isActive(),false);T.eq(game.stack:top(),fixture.world) +T.eq(fixture.player.fieldMove,nil);T.eq(fixture.player.fieldMovePose,nil) +local peer=run.loader.exports.show_owner +game.mods=run.loader;run.loader.game=game +Host.currentMod='show_owner';T.eq(Source.start(1,{pose=true}),true);Host.currentMod=nil +local state=game.stack:top();local image=state.sprite;local callback=state.onDone +require('src.mods.Loader').teardown() +T.eq(Source.isActive(),false);T.eq(game.stack:top(),fixture.world) +T.eq(fixture.player.fieldMove,nil);T.eq(state.sprite,nil) +T.check(not pcall(image.getWidth,image),'actual loader teardown releases the owned native image') +T.eq(callback(),false,'teardown disables peer-held source continuations') +T.eq(Source.draw,peer.base,'successful wrappers unwind during teardown') +run.release() +T.finish('field-move presentation through actual SDK lifecycle API '..api) From 4af592e41cb607ffbf1ba92a1c0b042505a4aa8a Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 05:08:12 +0100 Subject: [PATCH 010/129] Retire source-owned field-move admissions after mod wrapper unwind --- src/mods/Loader.lua | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 94366096a..1bf5784b7 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -35,8 +35,10 @@ Loader.__index = Loader -- Only an already-loaded field service owns resources. Retire it after -- wrapper unwind without importing a compatibility module on ordinary boot. local function retireFieldEffects() - local service=package.loaded['src.core.game3.field_effects'] - if type(service)=='table' and type(service.release)=='function' then service.release()end + for _,name in ipairs({'src.core.game3.field_effects','src.core.game3.field_move_show_mon'})do + local service=package.loaded[name] + if type(service)=='table' and type(service.release)=='function' then service.release()end + end end local MOD_STATE_FILE = "mod_state.lua" -- legacy migration only From 455fbbc9b2e606dae11cc704f2313f62d8aaf3ef Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 09:55:17 +0100 Subject: [PATCH 011/129] Add authenticated cartridge field-move artwork and presentation profiles --- docs/native-field-move-presentation.md | 18 +- docs/native-field-move-runtime.md | 23 ++ src/compat/gen1/FieldMoveRuntime.lua | 136 ++++++++ src/core/Music.lua | 9 +- src/core/Sound.lua | 9 +- src/core/StateStack.lua | 34 +- src/core/game3/field_move_show_mon.lua | 150 ++++---- src/import/Gen3FieldMoveArt.lua | 72 ++++ src/import/Gen3FieldMoveProfiles.lua | 28 ++ src/import/RomExtractorGen3.lua | 8 + src/import/RomImporter.lua | 1 + src/mods/Loader.lua | 2 + src/mods/ModSeams.lua | 3 + src/world/Gen3FieldMove.lua | 325 ++++++++++-------- src/world/Gen3FieldMovePresentation.lua | 101 ++++++ tests/engine/native_field_move_audio.lua | 1 + .../engine/native_field_move_presentation.lua | 5 +- tests/engine/native_field_move_runtime.lua | 1 + tests/engine/native_state_selection.lua | 16 + tools/field-move-runtime/art-oracle.lua | 40 +++ tools/field-move-runtime/audio-check.lua | 27 ++ tools/field-move-runtime/check.lua | 102 ++++++ tools/field-move-runtime/host/conf.lua | 9 +- tools/field-move-runtime/host/main.lua | 148 ++++---- tools/field-move-runtime/lifecycle-check.lua | 76 ++-- tools/field-move-runtime/oracle.lua | 54 +++ tools/gen3_discover.py | 2 + tools/gen3_field_move_profiles.py | 26 ++ 28 files changed, 1048 insertions(+), 378 deletions(-) create mode 100644 docs/native-field-move-runtime.md create mode 100644 src/compat/gen1/FieldMoveRuntime.lua create mode 100644 src/import/Gen3FieldMoveArt.lua create mode 100644 src/import/Gen3FieldMoveProfiles.lua create mode 100644 src/world/Gen3FieldMovePresentation.lua create mode 100644 tests/engine/native_field_move_audio.lua create mode 100644 tests/engine/native_field_move_runtime.lua create mode 100644 tests/engine/native_state_selection.lua create mode 100644 tools/field-move-runtime/art-oracle.lua create mode 100644 tools/field-move-runtime/audio-check.lua create mode 100644 tools/field-move-runtime/check.lua create mode 100644 tools/field-move-runtime/oracle.lua create mode 100644 tools/gen3_field_move_profiles.py diff --git a/docs/native-field-move-presentation.md b/docs/native-field-move-presentation.md index ae6195a45..a130ad565 100644 --- a/docs/native-field-move-presentation.md +++ b/docs/native-field-move-presentation.md @@ -22,16 +22,8 @@ twice or before native completion. Retired callbacks release captured data, and cohort references are weak so retained wrappers cannot keep a retired save alive. Observer release leaves engine-owned continuations intact. -This is not original cut-in visual/task/audio equivalence. The current native -band, dimensions and phase lengths are still the existing reconstructed -presentation, not the original indoors/outdoors streak artwork and window -tasks. Exact cry modes/ducking, original `_fx` internals and shared band-image -cache semantics remain open. `noDuck` starts explicitly refuse unsupported -options; `invalidate` reports that native resources belong to each admission. -There are no placeholder task tables or additional gameplay clocks. - -The focused lifecycle suite covers all five GBA edition identities. The real -GPU host checks upright asymmetric pictures, repeated/explicit/native draw -composition, two view sizes, one native clock and borrowed-image preservation. -Those synthetic checks do not establish original cartridge artwork parity, -unchanged mod initialization, complete renderer behavior or gameplay. +The optional cartridge profile, authenticated strip import and supported cry +options are documented in [native-field-move-runtime.md](native-field-move-runtime.md). +The ordinary native style is retained without an active observer. Full +portrait/form/personality handling, complete field task scheduling and the +unchanged complete renderer/gameplay remain open acceptance work. diff --git a/docs/native-field-move-runtime.md b/docs/native-field-move-runtime.md new file mode 100644 index 000000000..8c9a39d19 --- /dev/null +++ b/docs/native-field-move-runtime.md @@ -0,0 +1,23 @@ +# Imported field-move presentation on the native state + +This extends the native `Gen3FieldMove` admission with a cartridge presentation program. The normal native style remains the default when there is no active compatible observer. Existing native sprite hooks and asset overrides still resolve the front picture. No second world, player or task clock is created. + +The compatible field-move namespace exposes `start`, `isActive`, `step`, `draw`, `reset` and `invalidate`. Entry configures an actual stack state. Only that state's native update advances the presentation. Wrapped base calls share a once-only phase; an arbitrary `step()` cannot drive another frame. Read-only `_fx` observations are detached from the actual state. Rendering an effect through the source world pass suppresses the same admission's native UI draw within the renderer's frame serial. + +The optional program uses separate indoor and outdoor 256×80 strips, a 24-tick optional pose, cartridge-style window reveal/hide, picture motion/hold and one cry. `noDuck` and per-call cry volume feed the existing audio engine; the next ordinary cry restores its normal volume. An unrelated active cry retains ownership of its music attenuation. + +## Imported data and ownership + +The existing field-effects import stage authenticates the actual complete ROM SHA-1 before extracting strips. Bounds and declared tile counts are checked. The compact cache contains indices and palette words; RGBA is expanded only on admission. An older manifest can use the audited exact-digest profile, while discovery writes explicit optional `fieldMoveStreaks` metadata. Unknown revisions decline this artifact. The GBA-only cache revision forces an import refresh without migrating player data. + +Profiles cover FireRed, LeafGreen, Emerald, Ruby and Sapphire. Source locations and presentation facts were checked against the corresponding primary cartridge projects: [FireRed](https://github.com/pret/pokefirered/blob/master/src/field_effect.c), [Emerald](https://github.com/pret/pokeemerald/blob/master/src/field_effect.c), [Ruby/Sapphire](https://github.com/pret/pokeruby/blob/master/src/field_effect.c). No cartridge payloads are bundled. + +A mounted field-art provider can supply the same raw strips directly. Missing or malformed declared provider data fails admission; it cannot silently select another provider. Front images, strips, image data and quads are privately tracked per admission, including failed preparation and reload. Changing a public sprite field never transfers ownership of a borrowed native image. Pose ownership uses an admission token, so an old admission cannot restore over a newer pose. + +Callbacks and states belong to the current game/data/save/mod-set/edition/stack/world. A covered state cannot complete another screen. Source-owner cancellation removes only its own admission, including behind another screen, and discards captured continuations. Observer teardown preserves native continuations. Duplicate, early, reentrant and failed continuations retain their guards. + +## Validation and remaining scope + +ROM-free checks exercise admission, wrappers, cohort changes, resource ownership, actual SDK entry/late-validation/teardown, and the existing music/cry engines. Independent private oracles use immutable original blobs for per-tick state comparison and separately decode both strips from all five ROMs. The GPU host compares the original drawing routine against the native program on Metal/OpenGL, including repeated and externally composed draws. Front artwork in that host is an asymmetric synthetic fixture; it does **not** prove every cartridge portrait, form or personality variant. + +Full special-form/personality picture handling, native field-effect tasks and actor/overlay scheduling, party-slot selection, the unchanged complete renderer and real full-package gameplay are still separate acceptance work. A loadable module or matching cut-in is not full mod compatibility. diff --git a/src/compat/gen1/FieldMoveRuntime.lua b/src/compat/gen1/FieldMoveRuntime.lua new file mode 100644 index 000000000..b0dc00fb0 --- /dev/null +++ b/src/compat/gen1/FieldMoveRuntime.lua @@ -0,0 +1,136 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Bind compatible field-move operations to the actual native stack state. +local Session=require('src.compat.gen1.NativeSession') +local Version=require('src.core.GameVersion') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local R={};R.__index=R +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +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 and facade._callbackOwner() +end +local function current(c) + return not c.retired and c.game.data==c.data and c.game.save==c.save and c.game.mods==c.mods and Version.get()==c.edition +end +function R.new()return setmetatable({records=setmetatable({},{__mode='kv'}),callbacks=setmetatable({},{__mode='k'})},R)end +function R:bind(state) + local c=self.records[state];if c then return current(c)and c or nil end + local game=Session.game(state.game);if not(game and state:valid(false))then return nil end + c={state=state,game=game,data=game.data,save=game.save,mods=game.mods,edition=Version.get(),owner=owner(),sourceStarted=state.sourceOwned==true} + c.nativeCallback=state.onDone + local callback=c.nativeCallback + local admission=setmetatable({state},{__mode='v'}) + c.continue=function() + if c.detachedCallback then return c.detachedCallback()end + if c.cancelled then return false,'native field move owner retired'end + if c.error then error(c.error,0)end + if c.result then return unpack(c.result,1,c.result.n)end + if c.running then return false,'native field move continuation is already executing'end + local live=admission[1] + if not(c.exited and c.completed and live and live.callbackReady)then return false,'native field move has not completed'end + c.running=true;local fn=callback;callback=nil;c.nativeCallback=nil;admission[1]=nil + local result=pack(pcall(function()if fn then return fn()end end));fn=nil;c.running=false + if not result[1]then c.error=result[2];error(result[2],0)end + c.result={n=result.n-1};for i=2,result.n do c.result[i-1]=result[i]end + return unpack(c.result,1,c.result.n) + end + c.forgetCallback=function()callback=nil;c.nativeCallback=nil;admission[1]=nil end + state.onDone=c.continue;c.installed=c.continue;self.records[state]=c;self.callbacks[c]=true + return c +end +function R:current() + local game=Session.game();if not game then return nil end + local class=package.loaded['src.world.Gen3FieldMove'] + for i=#(game.stack and game.stack.states or{}),1,-1 do + local state=game.stack.states[i] + if getmetatable(state)==class and not state.done then return self:bind(state)end + end +end +function R:exit(state) + local c=self.records[state];if not c or c.exited then return end + c.exited=true;c.completed=state.completed==true and current(c) + if not c.completed then c.forgetCallback()end + self.records[state]=nil;c.retired=true;c.state,c.game,c.data,c.save,c.mods=nil,nil,nil,nil,nil +end +function R:detach(c) + local state=c.state + if state and state.onDone==c.installed then state.onDone=c.nativeCallback + elseif state then c.detachedCallback=c.nativeCallback end + c.forgetCallback();c.retired=true + if state then self.records[state]=nil end + c.state,c.game,c.data,c.save,c.mods=nil,nil,nil,nil,nil +end +local function cancelCallback(c) + c.cancelled=true;c.result=nil;c.error=nil;c.forgetCallback() +end +function R:release() + local selected={};for c in pairs(self.callbacks)do selected[#selected+1]=c end + for _,c in ipairs(selected)do + if c.sourceStarted then + if c.state then c.state:retire()end;cancelCallback(c) + elseif c.state then self:detach(c)end + end + self.records=setmetatable({},{__mode='kv'}) +end +function R:cancelOwner(who) + local selected={} + for c in pairs(self.callbacks)do if c.owner==who then selected[#selected+1]=c end end + for _,c in ipairs(selected)do + if c.sourceStarted then + if c.state then c.state:retire()end;cancelCallback(c) + elseif c.state then self:detach(c)end + end + return #selected>0 +end +function R:mon(game,value) + local mon=type(value)=='table'and value or{species=value};local out={} + for k,v in pairs(mon)do out[k]=v end + if type(out.species)=='number'and not(game.data.pokemon and game.data.pokemon[out.species])then out.species=(game.data.constants.speciesOrder or{})[out.species]or out.species end + if not(game.data.pokemon and game.data.pokemon[out.species])then return nil,'native species artwork unavailable'end + if mon.isShiny~=nil then out.shiny=mon.isShiny==true end + return out +end +function R:start(mon,opts,callback) + local game,world=Session.resolve() + if not(game and world and Session.acceptsInput(game,world,world.player))then return false,'no available native field-move owner'end + if self:current()then return false,'native field move is busy'end + local converted,why=self:mon(game,mon);if not converted then return false,why end + opts=opts or{};if type(opts)~='table'or(callback~=nil and type(callback)~='function')then return false,'invalid field move options'end + return require('src.world.Gen3FieldMove').show(game,converted,callback, + {presentation='cartridge',pose=opts.pose==true,noDuck=opts.noDuck==true,sourceStarted=true,sourceOwner=owner()}) +end +function R:configure(c,mon,opts,callback) + if not(current(c)and c.state:valid(true))then return false,'native field move admission retired'end + if c.configured then return true end + opts=opts or{};assert(type(opts)=='table','invalid field move options') + local converted,why=self:mon(c.game,mon);if not converted then return false,why end + local ok,err=c.state:configure(converted,opts,callback);if not ok then return false,err end + c.installed=callback + c.owner=c.owner or owner();c.configured=true + return true +end +function R:phase(c,name,body,fn,...) + if not c or not current(c)or not c.state:valid(name=='step')then return false,'native field move admission retired'end + local result=pack(Boundary.dispatch('field_move.'..name,body,fn,...)) + if c.error then error(c.error,0)end + return unpack(result,1,result.n) +end +function R:reset() + local c=self:current();if not c then return false end + local who=owner();if c.owner and who and c.owner~=who then return false,'native field move belongs to another mod'end + return c.state:complete(false) +end +function R:snapshot() + local c=self:current();local fx=c and c.state.effect + if not fx then return nil end + local out={} + for k,v in pairs(fx)do if type(v)~='table'and type(v)~='function'then out[k]=v end end + if fx.sprite then out.sprite={};for k,v in pairs(fx.sprite)do out.sprite[k]=v end end + local order=c.data.constants.speciesOrder or{} + for id,species in pairs(order)do if species==fx.species then out.species=id;break end end + out.shiny=require('src.pokemon.Pokemon').isShiny(c.state.mon) + out.picSpecies=out.species + return out +end +return R diff --git a/src/core/Music.lua b/src/core/Music.lua index e5b95baf6..fca19f123 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -136,7 +136,14 @@ end -- the jingle ends (see update()). -- Called by Sound.playCry: unlike a fanfare, the song keeps playing and is -- merely attenuated for as long as the cry sounds. -function Music.duckForCry(data, src) +function Music.duckForCry(data, src, noDuck) + if noDuck then + if cryDuck and cryDuck.src==src then + cryDuck=nil + if not state.fade then applyVolume(state.source);applyVolume(state.loopSource)end + end + return + end local c = data and data.constants and data.constants.gen3Cry local duck = c and tonumber(c.duckBgm) local scale = c and tonumber(c.scale) diff --git a/src/core/Sound.lua b/src/core/Sound.lua index 7093a32f5..d6a51d697 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -630,7 +630,12 @@ function Sound.playCry(data, species, opts) end -- set every time rather than at build: the cached source outlives a change -- of game, and the scale is the loaded cartridge's - pcall(src.setVolume, src, volumeFor(key) * cryScale(data)) + local scale=cryScale(data) + if opts and type(opts.volume)=='number'and opts.volume==opts.volume and opts.volume>=0 and opts.volume<=256 then + local unit=data.constants and data.constants.gen3Cry and tonumber(data.constants.gen3Cry.scale)or 256 + if unit and unit>0 then scale=opts.volume/unit end + end + pcall(src.setVolume, src, volumeFor(key) * scale) -- Interactive Dex playback owns its source; loop/effect settings must never -- leak onto the cached cry used by battles, NPC scripts or other menus. if opts and opts.isolated and src.clone then @@ -638,7 +643,7 @@ function Sound.playCry(data, species, opts) if ok and copy then src=copy end end if not restart(src) then return nil end - require("src.core.Music").duckForCry(data, src) + require("src.core.Music").duckForCry(data, src,opts and opts.noDuck==true) played("cry", species, species) return src end diff --git a/src/core/StateStack.lua b/src/core/StateStack.lua index baf622c4c..69796aa16 100644 --- a/src/core/StateStack.lua +++ b/src/core/StateStack.lua @@ -105,15 +105,14 @@ function StateStack:push(state, ...) end end -function StateStack:pop() - local state = table.remove(self.states) +local function finishRemoval(self, state) if state and state.exit then state:exit() end -- A POP THAT EMPTIES THE STACK is not debug-level news. The engine does it -- on purpose in exactly two places and pushes the replacement in the same -- breath; everywhere else it is one pop too many, and the thing it leaves -- behind -- a screen that never changes, with no error -- gives the reader -- nothing to search for. So it is said in words, once per culprit. - if state ~= nil and #self.states == 0 then + if state ~= nil and #self.states == 0 and not self.worldState then Logger.warn("state stack: popping %s emptied the stack -- nothing will " .. "update or draw until something is pushed back", StateStack.describe(state)) @@ -127,6 +126,35 @@ function StateStack:pop() return state end +function StateStack:pop() + local state = table.remove(self.states) + if state and self._modalTracker then self._modalTracker:removed(state) end + return finishRemoval(self, state) +end + +-- Remove actual native states, including a named modal beneath another one. +-- Detach the whole selection before callbacks: a new screen opened by exit +-- must survive and must never receive another removed screen's exit event. +function StateStack:removeAll(selected) + local wanted, removed = {}, {} + for _, state in ipairs(selected) do wanted[state] = true end + for i = #self.states, 1, -1 do + local state = self.states[i] + if wanted[state] then + table.remove(self.states, i) + if self._modalTracker then self._modalTracker:removed(state) end + removed[#removed + 1] = state + end + end + local firstError + for _, state in ipairs(removed) do + local ok, err = pcall(finishRemoval, self, state) + if not ok and not firstError then firstError = err end + end + if firstError then error(firstError, 0) end + return #removed +end + function StateStack:top() return self.states[#self.states] end diff --git a/src/core/game3/field_move_show_mon.lua b/src/core/game3/field_move_show_mon.lua index 913950f74..03306c53b 100644 --- a/src/core/game3/field_move_show_mon.lua +++ b/src/core/game3/field_move_show_mon.lua @@ -1,100 +1,76 @@ --- The actual native field-move admission owns its clock, pose and picture. -local Session=require('src.compat.gen1.NativeSession') +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Source phases over the engine's actual field-move StateStack admission. +local Runtime=require('src.compat.gen1.FieldMoveRuntime') local Boundary=require('src.compat.gen1.ScriptBoundary') -local Stream=require('src.core.game3.asset_stream') -local M={} -local owned=setmetatable({},{__mode='k'}) -local starting -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 and facade._callbackOwner() -end -local function current() - local game=Session.game();if not game then return end - local class=package.loaded['src.world.Gen3FieldMove'];if not class then return end - for i=#(game.stack and game.stack.states or{}),1,-1 do - local state=game.stack.states[i] - if getmetatable(state)==class and not state.done and not state.cancelled and class.present(state)then return state end - end -end +local runtime=Runtime.new() +local S={} +local observers={} local function call(name,...) - local facade=package.loaded['src.core.Game3'] - if facade and facade._withCallbackOwner then - local seams=package.loaded['src.mods.ModSeams'];local who - 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 who=change.owner;break end - end - return facade._withCallbackOwner(who or facade._callbackOwner(),M[name],...) + local facade=package.loaded['src.core.Game3'];local seams=package.loaded['src.mods.ModSeams'];local who + for i=#(seams and seams._journal or{}),1,-1 do + local v=seams._journal[i];if v.tbl==S and v.key==name and v.after==S[name]then who=v.owner;break end end - return M[name](...) + if facade and facade._withCallbackOwner then return facade._withCallbackOwner(who or facade._callbackOwner(),S[name],...)end + return S[name](...) end -function M.isActive()return current()~=nil end -function M.start(mon,opts,onDone) - if Boundary.active('showmon.start')then - if starting.startConfigured then return true end - local ok,why=starting:configure(mon,opts or{},onDone) - if not ok then return false,why end - starting.startConfigured=true;return Boundary.invoke('showmon.start') +function S.start(mon,opts,callback) + if Boundary.active('field_move.start')then + local c=runtime.starting;local ok,why=runtime:configure(c,mon,opts,callback) + if ok then Boundary.invoke('field_move.start')end + return ok,why end - local game,why=Session.resolve();if not game then return false,why end - if current()then return false,'native field move is busy'end - if opts~=nil and type(opts)~='table'then return false,'invalid field move options'end - opts=opts or{} - if opts.noDuck then return false,'native field-move cry ducking options are not yet available'end - if onDone~=nil and type(onDone)~='function'then return false,'invalid field move continuation'end - if type(mon)=='number'then mon={species=mon}end - if type(mon)~='table'then return false,'invalid field move Pokemon'end - local native=require('src.world.Gen3FieldMove') - local shown,state=native.show(game,mon,onDone,{pose=opts.pose==true,sourceOwned=true,sourceOwner=owner()}) - if shown then owned[state]=true end - return shown -end -function M.step()return Boundary.invoke('showmon.step')end -function M.draw() - if Boundary.active('showmon.draw')then return Boundary.invoke('showmon.draw')end - local state=current();if not state then return false end - return state:drawExternal() + return runtime:start(mon,opts,callback) end -function M.reset() - local state=current();if not state then return false end - local who=owner() - if state.sourceOwned and state.sourceOwner and who and who~=state.sourceOwner then return false,'field move belongs to another mod'end - return state:cancel() +function S.isActive()return runtime:current()~=nil end +function S.step()return Boundary.invoke('field_move.step')end +function S.draw() + if Boundary.active('field_move.draw')then return Boundary.invoke('field_move.draw')end + local c=runtime:current();if not c or not c.state:valid(false)then return false end + local result=c.state:drawExternal();c.manualDrawn=not c.state.externalDrawSerial;return result end -function M.invalidate() - -- There is no shared band-image cache: the native admission owns its - -- immutable picture and retires it on exit. Dataset/asset cache ownership - -- stays with the existing importer and Assets subsystem. - return false,'native field-move resources are owned per admission' +function S.reset()return runtime:reset()end +function S.invalidate() + local c=runtime:current();if not c then return true end + return c.state:reloadArtwork() end -function M.cancelOwner(who) - for state in pairs(owned)do if state.sourceOwner==who then state:cancel();owned[state]=nil end end +function S._observeMod(who)if type(who)=='string'then observers[who]=true end end +function S.release()runtime:release();observers={}end +function S.cancelOwner(who)observers[who]=nil;return runtime:cancelOwner(who)end +function S._nativeOptions(game) + local host=package.loaded['src.mods.Runtime'];local exports=game.mods and game.mods.exports or{} + local active=false + for who in pairs(observers)do if exports[who]or host and host.currentMod==who then active=true;break end end + if not active then return end + local f=package.loaded['src.core.game3.field_effects'] + if require('src.compat.gen1.NativeSession').game()==game and f and f._cache + or game.data.constants and game.data.constants.gen3FieldMovePresentation then return{presentation='cartridge',pose=true}end end -function M.release() - for state in pairs(owned)do state:cancel()end - owned=setmetatable({},{__mode='k'}) -end -local service={release=M.release,releaseOwner=function(_,who)return M.cancelOwner(who)end,poll=function() - for state in pairs(owned)do - if not state:cohortCurrent()then state:cancel();owned[state]=nil end - end -end} -Stream.register(service) --- Keep the weakly registered service alive with this module. -M._service=service -function M._nativeStart(state) - if state.sourceOwned then return true end - local previous=starting;starting=state - local ok,result=pcall(Boundary.dispatch,'showmon.start',function()return true end,function() - return call('start',state.mon,{pose=state.player~=nil},state.onDone) +function S._nativeStart(state) + local c=runtime:bind(state);if not c then return end + if c.sourceStarted then return true end + local previous=runtime.starting;runtime.starting=c + local ok,why=pcall(runtime.phase,runtime,c,'start',function()return true end,function() + return call('start',state.mon,{pose=state.poseInstalled==true,noDuck=state.options.noDuck},c.continue) end) - starting=previous;if not ok then error(result,0)end;return result + runtime.starting=previous + if not ok then error(why,0)end + return why end -function M._nativeStep(state,body) - return Boundary.dispatch('showmon.step',body,function()return call('step')end) +function S._nativeStep(state,body) + local c=runtime:bind(state);if not c then return body()end + c.manualDrawn=false + return runtime:phase(c,'step',body,function()return call('step')end) end -function M._nativeDraw(state,body) - return Boundary.dispatch('showmon.draw',body,function()return call('draw')end) +function S._nativeDraw(state,body) + local c=runtime:bind(state);if not c then return body()end + if c.manualDrawn then c.manualDrawn=false;return true end + return runtime:phase(c,'draw',body,function()return call('draw')end) end -return M +local service={release=S.release,releaseOwner=function(_,who)return S.cancelOwner(who)end,poll=function() + local stale={};for state in pairs(runtime.records)do if not state:cohortCurrent()then stale[#stale+1]=state end end + for _,state in ipairs(stale)do state:retire()end +end} +require('src.core.game3.asset_stream').register(service);S._service=service +function S._nativeExit(state)runtime:exit(state)end +setmetatable(S,{__index=function(_,key)if key=='_fx'then return runtime:snapshot()end end}) +return S diff --git a/src/import/Gen3FieldMoveArt.lua b/src/import/Gen3FieldMoveArt.lua new file mode 100644 index 000000000..397efedbe --- /dev/null +++ b/src/import/Gen3FieldMoveArt.lua @@ -0,0 +1,72 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Digest-bound GBA tilemap artwork. Store compact indices and palette words; +-- compose RGBA only for a consumer, without changing existing native art. +local M={SCHEMA=1,WIDTH=256,HEIGHT=80} +local function integer(n,lo,hi)return type(n)=='number'and n==n and n%1==0 and n>=lo and n<=hi end +local function range(rom,at,n)return integer(at,0,rom.size-n)end +local function strip(rom,spec,checkpoint) + assert(type(spec)=='table'and integer(spec.tileCount,1,1024),'invalid field streak geometry') + assert(range(rom,spec.tiles,spec.tileCount*32)and range(rom,spec.palette,32)and range(rom,spec.tilemap,640),'field streak ROM bounds') + local palette={};for i=1,16 do palette[i]=rom:u16(spec.palette+(i-1)*2)end + local tiles={} + for t=0,spec.tileCount-1 do + local variants={} + for flip=0,3 do + local rows={} + for y=0,7 do + local row={};local sy=flip>=2 and 7-y or y + for x=0,7 do + local sx=flip%2==1 and 7-x or x + local b=rom:u8(spec.tiles+t*32+sy*4+math.floor(sx/2)) + row[x+1]=string.char(sx%2==0 and b%16 or math.floor(b/16)) + end + rows[y+1]=table.concat(row) + end + variants[flip]=rows + end + tiles[t]=variants + end + local rows={} + for ty=0,9 do + local cells={} + for tx=0,31 do + local e=rom:u16(spec.tilemap+(ty*32+tx)*2) + assert(e%1024= RAISE_FRAMES then self.phase, self.t = "in", 0 elseif self.phase == "in" and self.t >= IN_FRAMES then @@ -284,13 +320,12 @@ local function updateBody(self) self:finish() end end - function Gen3FieldMove:update() - if self.cancelled or not sameCohort(self)then return self:cancel()end - if self.done or not present(self) or self.cohort.stack:top()~=self then return false end - local source=package.loaded['src.core.game3.field_move_show_mon'] - if source and source._nativeStep then return source._nativeStep(self,function()return updateBody(self)end)end - return updateBody(self) + if not valid(self,false)then if not self.done then self:retire()end;return false end + if not valid(self,true)then return false end + local facade=source() + if facade and facade._nativeStep then return facade._nativeStep(self,function()return self:updateBody()end)end + return self:updateBody() end -- A cutscene swallows the pad: pressing A during the sweep must not reach the @@ -311,8 +346,13 @@ function Gen3FieldMove:offset(width) return 0 end -local function drawBody(self) - if self.cancelled or self.done or not present(self) or not self.sprite then return false end +function Gen3FieldMove:drawBody() + if not valid(self,false)then return false end + if self.effect then + local own=allocations[self] + if own and own.presentation then return Presentation.draw(self.effect,own.presentation,self.sprite or own.front)end + return false + end -- nothing at all while the ball goes up: see RAISE_FRAMES if self.phase == "raise" then return end local w = select(1, self:uiSize()) @@ -325,7 +365,7 @@ local function drawBody(self) -- ...and the streaks running through it, which are what make it read as a -- sweep rather than a bar g.setColor(self.ink[1], self.ink[2], self.ink[3], 0.35) - local frame = (self.frame or 0) + local frame=math.max(0,self.frame-RAISE_FRAMES) for i = 1, STREAKS do local y = BAND_TOP + 3 + (i - 1) * math.floor((BAND_HEIGHT - 6) / STREAKS) local len = 40 + (i * 37) % 90 @@ -358,24 +398,21 @@ local function drawBody(self) end g.setColor(1, 1, 1, 1) end - -function Gen3FieldMove:drawExternal() - if self.cancelled or self.done or not present(self)then return false end - local result=drawBody(self) - self.externalDrawFrame=require('src.render.Renderer').frameSerial - return result~=false -end - function Gen3FieldMove:draw() - if self.cancelled or self.done or not present(self)then return false end - local serial=require('src.render.Renderer').frameSerial - if serial and self.externalDrawFrame==serial then return true end - local source=package.loaded['src.core.game3.field_move_show_mon'] - if source and source._nativeDraw then return source._nativeDraw(self,function()return drawBody(self)end)end - return drawBody(self) + if not valid(self,false)then return false end + local renderer=package.loaded['src.render.Renderer'] + if self.externalDrawSerial and renderer and self.externalDrawSerial==renderer.frameSerial then return true end + local facade=source() + if facade and facade._nativeDraw then return facade._nativeDraw(self,function()return self:drawBody()end)end + return self:drawBody() +end +Gen3FieldMove.valid=valid +Gen3FieldMove.present=function(self)return valid(self,false)end +Gen3FieldMove.cancel=Gen3FieldMove.retire +function Gen3FieldMove:drawExternal() + local result=self:drawBody() + local renderer=package.loaded['src.render.Renderer'];self.externalDrawSerial=renderer and renderer.frameSerial + return result end - -Gen3FieldMove.present=present -Gen3FieldMove.cohortCurrent=sameCohort return Gen3FieldMove diff --git a/src/world/Gen3FieldMovePresentation.lua b/src/world/Gen3FieldMovePresentation.lua new file mode 100644 index 000000000..cfc992bb3 --- /dev/null +++ b/src/world/Gen3FieldMovePresentation.lua @@ -0,0 +1,101 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- An optional cartridge presentation on the native field-move state. The +-- StateStack owns its updates; rendering never advances these observations. +local Art=require('src.import.Gen3FieldMoveArt') +local M={} +local OUTDOORS={TOWN=true,CITY=true,ROUTE=true,UNDERWATER=true,OCEAN_ROUTE=true,[1]=true,[2]=true,[3]=true,[5]=true,[6]=true} +function M.outdoors(game) + local map=game.overworld and game.overworld.map + return OUTDOORS[map and map.def and map.def.mapType]==true +end +function M.new(mon,opts,game) + return{species=mon.species,personality=mon.personality,noDuck=opts.noDuck==true, + outdoors=M.outdoors(game),phase=opts.pose and'pose'or 1,posed=opts.pose==true, + poseWait=24,hofs=0,winL=240,winT=80,winB=81,revealed=0,cleared=0} +end +function M.advance(fx,cry) + if fx.phase=='pose'then + fx.poseWait=fx.poseWait-1 + if fx.poseWait>0 then return false end + fx.phase=1 + end + local phase=fx.phase + if phase<3 then fx.phase=phase+1 + elseif phase==7 then return true + else + -- Every active band phase scrolls except the outdoor hide frame. + if not(fx.outdoors and phase==6)then fx.hofs=fx.hofs-16 end + if phase==3 then + local ready + if fx.outdoors then + fx.winL=math.max(0,fx.winL-16);fx.winT=math.max(40,fx.winT-2);fx.winB=math.min(120,fx.winB+2) + ready=fx.winL==0 and fx.winT==40 and fx.winB==120 + else + ready=fx.revealed>=16 + if not ready then fx.revealed=fx.revealed+1 end + end + if ready then fx.sprite={x=320,y=80,state='on'};fx.phase=4 end + elseif phase==4 then + if fx.sprite and fx.sprite.done then fx.phase=5 end + elseif phase==5 then + if fx.outdoors then + fx.winT=math.min(80,fx.winT+6);fx.winB=math.max(81,fx.winB-6) + if fx.winT==80 and fx.winB==81 then fx.phase=6 end + else fx.phase=6 end + elseif phase==6 then + if fx.outdoors then fx.hidden=true;fx.phase=7 + elseif fx.cleared>=16 then fx.phase=7 else fx.cleared=fx.cleared+1 end + end + end + local s=fx.sprite + if s and not s.done then + if s.state=='on'then + s.x=s.x-20 + if s.x<=120 then s.x=120;s.wait=30;s.state='wait';if cry then cry()end end + elseif s.state=='wait'then s.wait=s.wait-1;if s.wait==0 then s.state='off'end + elseif s.x < -64 then s.done=true else s.x=s.x-20 end + end + return false +end +function M.rgba(game,kind) + local facade=package.loaded['src.core.game3.field_effects'] + local cache=require('src.compat.gen1.NativeSession').game()==game and facade and facade._cache + if cache then + local extract=package.loaded['src.import.gba.extract_island1'] + local path=((extract and extract.CACHE_ROOT)or'data/generated/gba')..'/field_effects/field_move_streaks_'..kind..'.rgba' + local bytes=cache:read(path) + if bytes~=nil then assert(type(bytes)=='string'and#bytes==256*80*4,'invalid field move streak payload');return bytes end + return nil,'installed field move streak artwork is unavailable' + end + local record=game.data.constants and game.data.constants.gen3FieldMovePresentation + local Version=require('src.core.GameVersion');local edition=Version.get() + if not(record and record.schema==Art.SCHEMA and record.edition==edition + and Version.acceptsSha1(edition,record.romSha1)and record.strips and record.strips[kind])then + return nil,'native field move artwork requires a current import' + end + return Art.rgba(record.strips[kind]) +end +function M.draw(fx,resources,front) + if fx.phase=='pose'then return end + local g=love.graphics;g.setColor(1,1,1,1) + local x0,x1,y0,y1 + if fx.outdoors then + if not fx.hidden and fx.phase>=3 then x0,x1,y0,y1=fx.winL,240,math.max(fx.winT,40),math.min(fx.winB,120)end + elseif fx.phase>=3 and fx.phase<=5 then + local revealing=fx.phase==3 and fx.revealed<16 + x0,x1,y0,y1=revealing and 16 or 0,revealing and math.min(240,16*(fx.revealed+1))or 240,40,120 + elseif fx.phase==6 then x0,x1,y0,y1=math.min(240,16*fx.cleared),240,40,120 end + if x0 and x1>x0 and y1>y0 then + local x=x0 + while x Date: Mon, 26 Oct 2026 09:55:17 +0100 Subject: [PATCH 012/129] Keep the parent native streak phase and freeze its GPU baseline --- src/world/Gen3FieldMove.lua | 2 +- tools/field-move-runtime/host/main.lua | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/world/Gen3FieldMove.lua b/src/world/Gen3FieldMove.lua index 93c887380..365e9121c 100644 --- a/src/world/Gen3FieldMove.lua +++ b/src/world/Gen3FieldMove.lua @@ -365,7 +365,7 @@ function Gen3FieldMove:drawBody() -- ...and the streaks running through it, which are what make it read as a -- sweep rather than a bar g.setColor(self.ink[1], self.ink[2], self.ink[3], 0.35) - local frame=math.max(0,self.frame-RAISE_FRAMES) + local frame=self.frame for i = 1, STREAKS do local y = BAND_TOP + 3 + (i - 1) * math.floor((BAND_HEIGHT - 6) / STREAKS) local len = 40 + (i * 37) % 90 diff --git a/tools/field-move-runtime/host/main.lua b/tools/field-move-runtime/host/main.lua index 769137409..cfcd65736 100644 --- a/tools/field-move-runtime/host/main.lua +++ b/tools/field-move-runtime/host/main.lua @@ -50,7 +50,9 @@ function love.load() end -- Compare the ordinary native presentation to its frozen pre-change class. Source.release() - local previousClass=assert(loadfile(assert(os.getenv('FIELD_MOVE_NATIVE_BASELINE'))))() + local baseline=assert(loadfile(assert(os.getenv('FIELD_MOVE_NATIVE_BASELINE')))) + local loaded={};for k,v in pairs(package.loaded)do if k~='src.core.game3.field_move_show_mon'then loaded[k]=v end end + setfenv(baseline,setmetatable({package={loaded=loaded}},{__index=_G}));local previousClass=baseline() local classicComparisons=0 for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'})do local f=require('tools.session-runtime.fixture').new(edition);local game=f.game @@ -60,7 +62,7 @@ function love.load() frames[#frames+1]=render(a,function()old:draw()end) old:update() until game.stack:top()==f.world - old.sprite:release() + if old.sprite then old.sprite:release()end assert(Native.show(game,{species='TEST'}));local state=game.stack:top() T.eq(state.effect,nil,'cached inactive facade preserves ordinary native style') for tick,expected in ipairs(frames)do From 24a975e1c7586a5b5be096ea7ae7b65f51c44824 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 09:55:17 +0100 Subject: [PATCH 013/129] Honor admitted worlds, retired stacks and mixed validation contracts --- src/compat/gen1/FieldMoveRuntime.lua | 2 +- src/world/Gen3FieldMove.lua | 28 ++++++++++++++------ src/world/Gen3FieldMovePresentation.lua | 9 ++++--- tools/field-move-runtime/check.lua | 7 +++++ tools/field-move-runtime/lifecycle-check.lua | 16 ++++++++--- 5 files changed, 45 insertions(+), 17 deletions(-) diff --git a/src/compat/gen1/FieldMoveRuntime.lua b/src/compat/gen1/FieldMoveRuntime.lua index b0dc00fb0..731904651 100644 --- a/src/compat/gen1/FieldMoveRuntime.lua +++ b/src/compat/gen1/FieldMoveRuntime.lua @@ -15,7 +15,7 @@ local function current(c) end function R.new()return setmetatable({records=setmetatable({},{__mode='kv'}),callbacks=setmetatable({},{__mode='k'})},R)end function R:bind(state) - local c=self.records[state];if c then return current(c)and c or nil end + local c=self.records[state];if c then return current(c)and state:valid(false)and c or nil end local game=Session.game(state.game);if not(game and state:valid(false))then return nil end c={state=state,game=game,data=game.data,save=game.save,mods=game.mods,edition=Version.get(),owner=owner(),sourceStarted=state.sourceOwned==true} c.nativeCallback=state.onDone diff --git a/src/world/Gen3FieldMove.lua b/src/world/Gen3FieldMove.lua index 365e9121c..575f8a4f5 100644 --- a/src/world/Gen3FieldMove.lua +++ b/src/world/Gen3FieldMove.lua @@ -46,11 +46,18 @@ local unpack=table.unpack or unpack local function pack(...)return{n=select('#',...),...}end local function release(x)if x and x.release then pcall(x.release,x)end end local function source()return Version.isGen3()and package.loaded['src.core.game3.field_move_show_mon']end +local function nativeWorld(game) + local session=Version.isGen3()and package.loaded['src.compat.gen1.NativeSession'] + if session and session.resolve then + local owner,world=session.resolve(game);if owner==game then return world end + return nil + end + return game.overworld +end local function valid(self,top) if self.done then return false end local c=self.cohort;local game=self.game - if c and(game.data~=c.data or game.save~=c.save or game.mods~=c.mods or Version.get()~=c.edition - or game.stack~=c.stack or game.overworld~=c.world or c.world and c.world.player~=self.actor)then return false end + if c and not self:cohortCurrent()then return false end local stack=game.stack;local states=stack and stack.states if type(states)=='table'then local found=false;for _,s in ipairs(states)do if s==self then found=true;break end end @@ -128,7 +135,9 @@ end local function create(game, mon, onDone, opts) if not(game and game.stack and game.stack.push)or(onDone~=nil and type(onDone)~='function')then return false end for _,state in ipairs(game.stack.states or{})do if getmetatable(state)==Gen3FieldMove and not state.done then return false,'native field move is busy'end end - local cohort=setmetatable({data=game.data,save=game.save,mods=game.mods,stack=game.stack,edition=Version.get(),world=game.overworld,actor=game.overworld and game.overworld.player},{__mode='v'}) + local world=nativeWorld(game) + if Version.isGen3()and not world then return false,'no admitted native field world'end + local cohort=setmetatable({data=game.data,save=game.save,mods=game.mods,stack=game.stack,edition=Version.get(),world=world,actor=world and world.player,map=world and world.map},{__mode='v'}) local facade=source() opts=opts or(facade and facade._nativeOptions and facade._nativeOptions(game))or{} local sprite,path = frontSprite(game, mon) @@ -143,7 +152,7 @@ local function create(game, mon, onDone, opts) self.shade, self.ink = bandColours(game) -- THE POSE GOES ON HERE and comes off in finish(), so it is on screen for -- exactly as long as the window is -- which is what the cartridge does. - local player = game.overworld and game.overworld.player + local player = world and world.player self.actor=player self.cohort=cohort if not self:cohortCurrent()then release(sprite);return false,'native field move preparation cohort retired'end @@ -163,7 +172,7 @@ local function create(game, mon, onDone, opts) end allocations[self]={front=sprite} if opts.presentation=='cartridge'then - self.effect=Presentation.new(mon,opts,game) + self.effect=Presentation.new(mon,opts,game,world) local ok,why=self:reloadArtwork() if not ok then self:exit();return false,why end end @@ -183,7 +192,8 @@ local function create(game, mon, onDone, opts) end local ok,why=pcall(game.stack.push,game.stack,self) if not ok then - if game.stack.removeAll then game.stack:removeAll({self})else self:exit()end + local stack=cohort.stack + if stack and stack.removeAll then stack:removeAll({self})else self:exit()end return false,why end if self.entryRejected then self:retire();return false,'native field move start vetoed'end @@ -199,7 +209,8 @@ end function Gen3FieldMove:cohortCurrent() local c=self.cohort;local game=self.game return c and game.data==c.data and game.save==c.save and game.mods==c.mods and game.stack==c.stack - and game.overworld==c.world and(not c.world or c.world.player==c.actor)and Version.get()==c.edition + and Version.get()==c.edition and nativeWorld(game)==c.world + and(not c.world or c.world.player==c.actor and c.world.map==c.map) end function Gen3FieldMove:configurePresentation(opts) if self.frame~=0 then return false,'field move options belong to admission'end @@ -273,7 +284,8 @@ end function Gen3FieldMove:retire() self.done=true;self.cancelled=true;self.completed=false;self.onDone=nil if self.discardContinuation then self.discardContinuation()end - if self.game.stack and self.game.stack.removeAll then self.game.stack:removeAll({self})end + local stack=self.cohort and self.cohort.stack + if stack and stack.removeAll then stack:removeAll({self})end self:exit() end function Gen3FieldMove:complete(completed) diff --git a/src/world/Gen3FieldMovePresentation.lua b/src/world/Gen3FieldMovePresentation.lua index cfc992bb3..d9667a8b7 100644 --- a/src/world/Gen3FieldMovePresentation.lua +++ b/src/world/Gen3FieldMovePresentation.lua @@ -4,13 +4,14 @@ local Art=require('src.import.Gen3FieldMoveArt') local M={} local OUTDOORS={TOWN=true,CITY=true,ROUTE=true,UNDERWATER=true,OCEAN_ROUTE=true,[1]=true,[2]=true,[3]=true,[5]=true,[6]=true} -function M.outdoors(game) - local map=game.overworld and game.overworld.map +function M.outdoors(game,world) + world=world or game.overworld + local map=world and world.map return OUTDOORS[map and map.def and map.def.mapType]==true end -function M.new(mon,opts,game) +function M.new(mon,opts,game,world) return{species=mon.species,personality=mon.personality,noDuck=opts.noDuck==true, - outdoors=M.outdoors(game),phase=opts.pose and'pose'or 1,posed=opts.pose==true, + outdoors=M.outdoors(game,world),phase=opts.pose and'pose'or 1,posed=opts.pose==true, poseWait=24,hofs=0,winL=240,winT=80,winB=81,revealed=0,cleared=0} end function M.advance(fx,cry) diff --git a/tools/field-move-runtime/check.lua b/tools/field-move-runtime/check.lua index 19d455298..0ed541ebb 100644 --- a/tools/field-move-runtime/check.lua +++ b/tools/field-move-runtime/check.lua @@ -73,6 +73,13 @@ for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'})do state=g.stack:top();g.save={};state:update();T.eq(g.stack:top(),f.world) T.eq(calls,3,'a retired save cannot receive the old continuation') T.eq(state.done,true) + T.eq(Native.show(g,{species='TEST'},function()calls=calls+1 end),true) + state=g.stack:top();local admittedStack=g.stack;local foreign={} + g.stack=setmetatable({states={foreign}},{__index=require('src.core.StateStack')}) + T.eq(Source.isActive(),false);state:update() + T.eq(g.stack:top(),foreign,'retiring an old admission cannot remove a foreign screen') + T.eq(#admittedStack.states,1,'retire the admission from its actual original stack') + T.eq(calls,3);g.stack=admittedStack -- A public sprite replacement cannot transfer ownership of that image. f=fixture(edition);g=f.game T.eq(Native.show(g,{species='TEST'},nil,{presentation='cartridge',pose=false}),true) diff --git a/tools/field-move-runtime/lifecycle-check.lua b/tools/field-move-runtime/lifecycle-check.lua index 224163836..9f0ff3809 100644 --- a/tools/field-move-runtime/lifecycle-check.lua +++ b/tools/field-move-runtime/lifecycle-check.lua @@ -10,8 +10,8 @@ local strip={width=256,height=80,indexed=string.rep('\1',256*80),palette={}} for i=1,16 do strip.palette[i]=i end native.data.constants.gen3FieldMovePresentation={schema=1,edition='emerald',romSha1=require('src.core.GameVersion').info('emerald').sha1,strips={outdoors=strip,indoors=strip}} 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'}}) +local files={};local function mod(id,priority,body,manifestApi) + files['mods/'..id..'/manifest.json']=Json.encode({id=id,name=id,version='1.0.0',api=manifestApi or api,entry='main.lua',games={'gen3'},priority=priority,permissions={'engine_internals'}}) files['mods/'..id..'/main.lua']=body end mod('move_owner',100,[[local mod=...;local F=require('src.core.game3.field_move_show_mon');local base=F.start @@ -21,9 +21,17 @@ mod('move_owner',100,[[local mod=...;local F=require('src.core.game3.field_move_ mod('move_bad',200,[[local F=require('src.core.game3.field_move_show_mon');assert(F.start(1,{pose=true}));F.step=function()error('failed wrapper survived')end;error('intentional move failure')]]) mod('move_late',300,[[local mod=...;local F=require('src.core.game3.field_move_show_mon');assert(F.start(1,{pose=true}));F.draw=function()error('late wrapper survived')end mod.content.map_scripts:register('FIELD_VALIDATION',{scripts={invalid={{'field_intentionally_unknown_command'}}}}) -]]) +]],math.max(2,api)) local F=require('src.core.game3.field_move_show_mon');local step,draw=F.step,F.draw -local run=T.sdk.loadMods({'mods/move_owner','mods/move_bad','mods/move_late'},{fs=T.sdk.memfs(files),data=T.fixtures.fresh()}) +local paths={'mods/move_owner','mods/move_bad','mods/move_late'} +-- Legacy script findings are warnings. Keep that contract while using an +-- explicitly strict peer to exercise actual late rejection in the mixed set. +if api==1 then + mod('move_warning',400,[[local mod=...;mod.content.map_scripts:register('FIELD_WARNING',{scripts={invalid={{'field_intentionally_unknown_command'}}}})]],1) + paths[#paths+1]='mods/move_warning' +end +local run=T.sdk.loadMods(paths,{fs=T.sdk.memfs(files),data=T.fixtures.fresh()}) +if api==1 then T.check(run.loader.exports.move_warning~=nil,'legacy validation remains a warning')end T.eq(#run.errors,2);T.eq(F.isActive(),false);T.eq(#native.stack.states,1);T.eq(native.overworld.player.fieldMove,nil) T.eq(F.step,step);T.eq(F.draw,draw) native.mods=run.loader;run.loader.game=native From 965868a22db7f437d5b8e8b03bfe37f2a8f60fb3 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 10:05:42 +0100 Subject: [PATCH 014/129] Cover authenticated strip profiles when edition metadata is supplied separately --- tools/field-move-runtime/host/main.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/field-move-runtime/host/main.lua b/tools/field-move-runtime/host/main.lua index cfcd65736..78ecf0201 100644 --- a/tools/field-move-runtime/host/main.lua +++ b/tools/field-move-runtime/host/main.lua @@ -27,7 +27,10 @@ function love.load() local oldCry=require('src.core.Sound').playCry;require('src.core.Sound').playCry=function()end for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'})do local raw=file(inputs[edition].path);local sha=love.data.encode('string','hex',love.data.hash('sha1',raw));assert(sha==inputs[edition].sha1) - local manifest=J.decode(file(root..'/tools/rom_manifest_'..edition..'.json')) + local handle=io.open(root..'/tools/rom_manifest_'..edition..'.json','rb') + local manifest + if handle then manifest=J.decode(handle:read('*a'));handle:close() + else manifest={romSha1=sha}end -- exercise the authenticated legacy-metadata profile local art=assert(Art.extract(require('src.import.RomGba').new(raw),manifest,sha,edition));raw=nil for _,outside in ipairs({true,false})do for _,pose in ipairs({true,false})do for _,noDuck in ipairs({true,false})do Source.release();local f=require('tools.session-runtime.fixture').new(edition);local game=f.game From 84f4eb4b0e6886814c952cb489600522a194b82f Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 11:30:13 +0100 Subject: [PATCH 015/129] 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 016/129] 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 017/129] 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 018/129] 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 019/129] 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 020/129] 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 021/129] Assert the native GPU controller comes from its frozen source tree --- tools/battle-source-runtime/host/main.lua | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/battle-source-runtime/host/main.lua b/tools/battle-source-runtime/host/main.lua index 74190b1f1..1aba9934d 100644 --- a/tools/battle-source-runtime/host/main.lua +++ b/tools/battle-source-runtime/host/main.lua @@ -1,6 +1,8 @@ io.stdout:setvbuf('no') local root=assert(os.getenv('BATTLE_SOURCE_ENGINE')) -package.path=root..'/?.lua;'..root..'/?/init.lua;'..package.path +-- Keep the absolute frozen engine first when the SDK checks for its Lua +-- package-path sentinel. Relative fixture IO runs from that same engine. +package.path=root..'/?.lua;'..root..'/?/init.lua;./?/init.lua;'..package.path function love.errorhandler(err)io.stderr:write(debug.traceback(tostring(err),2)..'\n');return function()return 1 end end function love.load() local T=require('tests.harness').suite('native battle source GPU') @@ -9,6 +11,8 @@ function love.load() local Ui=require('src.core.game3.battle.ui') local Bg=require('src.core.game3.battle.bg') local baseDraw,uiDraw,bgDraw=Battle.draw,Ui.draw,Bg.draw + local nativeBattleSource=debug.getinfo(require('src.battle.BattleState').update,'S').source + T.eq(nativeBattleSource,'@'..root..'/src/battle/BattleState.lua','native battle code comes from the frozen engine') local renderer,version,vendor,device=g.getRendererInfo() local backend=assert(os.getenv('POKEPORT_GRAPHICS_BACKEND')) T.check(renderer:lower():find(backend,1,true)~=nil,'requested actual GPU backend') @@ -94,6 +98,7 @@ function love.load() backend=backend,renderer=renderer,version=version,vendor=vendor,device=device, playerProfilesUsed=false,gameplayProven=false,originalModLoaded=false, syntheticBattlerArt=true, + engineRoot=root,nativeBattleSource=nativeBattleSource, scope='Actual native BattleState and Gen3Battle draws over synthetic imported Pokemon; five GBA editions. Source/UI/background wrappers and GPU error restoration; no original-mod boot or gameplay claim.'} local file=assert(io.open(assert(os.getenv('BATTLE_SOURCE_REPORT')),'w')) file:write(require('src.link.Json').encode(report));file:close() From 316ad42348ecdfcfa2b67f6945aa4a89ef4f115c Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 13:31:27 +0100 Subject: [PATCH 022/129] Share native GBA animation frames, screen effects and layout queries --- docs/native-battle-animation-runtime.md | 72 +++++ src/battle/BattleState.lua | 77 ++++- src/battle/Gen3MoveAnim.lua | 1 + src/battle/PresentationClock.lua | 34 +++ src/core/GameVersion.lua | 8 + src/core/game3/battle.lua | 10 +- src/core/game3/battle/anim.lua | 267 ++++++++++++++++++ src/core/game3/battle/anim_coords.lua | 131 +++++++++ src/mods/Loader.lua | 2 +- src/mods/ModSeams.lua | 2 +- src/render/BattleScreenEffect.lua | 62 ++++ src/render/GraphicsScope.lua | 56 ++++ tests/engine/native_battle_animation.lua | 137 +++++++++ tests/engine/native_version_layout.lua | 19 ++ .../battle-animation-runtime/coords-check.lua | 42 +++ tools/battle-animation-runtime/host/conf.lua | 6 + tools/battle-animation-runtime/host/main.lua | 159 +++++++++++ .../lifecycle-check.lua | 45 +++ 18 files changed, 1114 insertions(+), 16 deletions(-) create mode 100644 docs/native-battle-animation-runtime.md create mode 100644 src/battle/PresentationClock.lua create mode 100644 src/core/game3/battle/anim.lua create mode 100644 src/core/game3/battle/anim_coords.lua create mode 100644 src/render/BattleScreenEffect.lua create mode 100644 src/render/GraphicsScope.lua create mode 100644 tests/engine/native_battle_animation.lua create mode 100644 tests/engine/native_version_layout.lua create mode 100644 tools/battle-animation-runtime/coords-check.lua create mode 100644 tools/battle-animation-runtime/host/conf.lua create mode 100644 tools/battle-animation-runtime/host/main.lua create mode 100644 tools/battle-animation-runtime/lifecycle-check.lua diff --git a/docs/native-battle-animation-runtime.md b/docs/native-battle-animation-runtime.md new file mode 100644 index 000000000..5baca515a --- /dev/null +++ b/docs/native-battle-animation-runtime.md @@ -0,0 +1,72 @@ +# Native battle animation bridge + +`src.core.game3.battle.anim` presents the admitted GBA battle through its native +`BattleState`, `Gen3MoveAnim` and `Gen3BallAnim` owners. It does not load another +battle engine or rewrite a mod package. FireRed, LeafGreen, Emerald, Ruby and +Sapphire use the same boundary and their own imported identifiers and records. + +## Shared clock and ownership + +The native update opens one presentation phase. A source `Anim.update()` can +advance that phase while a custom ending or pose holds the combat update. +Repeated source calls and a later native base call consume the same display +steps. The native dt drives sprite animations. Outside that phase, source +updates cannot manufacture frames. + +Move/ball effects, visual waits, HP drain and EXP fill remain native operations. +Only the native combat update consumes command rows, input and outcomes. +Source-launched clips occupy the native animation player and hold the command +queue until that clip finishes. A presentation serial distinguishes a finished +source clip from a newer native animation on the same player. Reset/unload +cancels only a clip still owned by that source admission. + +`present(id)` has stable identity for an admitted battler. It observes native +display HP/maximum/level/status/EXP, visibility, offsets and affine effects. +Source visual overrides compose around the existing native sprite draw; +reading a native effect never applies it a second time. `shownBattler` carries +an owned presentation override without replacing the native combatant. +Replacing the session, save, data or catalogue retires old poses and callbacks. + +`GameVersion.layout(id)` also reports the registered GBA family (`frlg` or +`rse`) so an unchanged field companion probe can recognize the native host. +It supports current-version and colon queries without changing cache/save +selection; an explicit registered layout takes precedence. + +## Geometry and screen effects + +`anim_coords` exposes authored anchors and ordering for singles and doubles. +Current imported cartridge coordinates take precedence over the standard +240x160 anchors. Temporary side bindings restore on success and failure and +preserve trailing nil arguments/results. + +Supported screen effects are inversion, fade to white/black, darkening, +grayscale and blending to a custom colour. Repeated begin calls reapply within +one guarded render seam; nested seams restore their caller's shader. The native +graphics guard restores render targets, transforms, shaders and leaked pushes +after an error. Synthetic asymmetric sprites test orientation as well as colour. + +## Current boundary + +This bridge implements native move launch/observation, source pose tweens, +display ratios, sequence observations, screen effects and coordinate bindings. +It deliberately does not advertise a complete presentation API version. +Authored animation commands, mutable VM/sprite/task/palette callbacks, +particle bands, trainer/intro/switch/catch stage control, source HP/EXP tween +commands and complete custom UI captures still require integration. A module +being importable is not evidence of a complete mod boot or playable campaign. + +## Validation + +- `tests/engine/native_battle_animation.lua`: all five editions; shared clocks, + owned clip completion, held command queues, stale poses/callbacks and 180-frame + native trajectory comparisons per edition with and without the bridge. +- `tools/battle-animation-runtime/lifecycle-check.lua`: SDK entry failure, + teardown and native peer callback identities. +- `tools/battle-animation-runtime/coords-check.lua`: optional local contract + oracle supplied through `BATTLE_COORDS_ORACLE`; no external engine is bundled. +- `tools/battle-animation-runtime/host`: actual Metal/OpenGL sprite and screen + effect rendering, pixel comparisons and graphics error restoration. + +Local qualification runs use the repository's QA storage manager, frozen source +copies and dedicated LOVE identities. They use synthetic imported data and art; +they do not open player profiles, save files or ROM originals. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index c90feb417..5299618d9 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -2595,11 +2595,9 @@ function BattleState:beginMsgLine() self.shown[#self.shown + 1] = {} end -function BattleState:updateQueue() - if self.waitingUI then - if self.game.stack:top() ~= self then return true end - self.waitingUI = nil - end +-- Only display controllers live here. Command holds, HP awards, turn rows +-- and completion remain in updateQueue, even while a mod holds presentation. +function BattleState:updateMovePresentation() -- HOENN'S PARTICLES TICK BEFORE THE QUEUE'S OWN HOLD, and the whole -- reason the animations looked broken is that they used to tick after it. -- @@ -2650,11 +2648,16 @@ function BattleState:updateQueue() if not anim.caught then self.gen3Ball = nil end end end +end + +-- Display-only waits and bar completion. Returning idle grants no command; +-- only updateQueue may consume a battle row after this gate. +function BattleState:updateQueuePresentation() + self:updateMovePresentation() + local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle.anim'] + if source and source._holdsNativeQueue(self) then return true end -- a queued hold (faint slide, hit blink) counts down before the next row - if self.waitFrames and self.waitFrames > 0 then - self.waitFrames = self.waitFrames - 1 - return true - end + if self:stepPresentationWait() then return true end -- WaitForSoundToFinish (home/delay.asm:15-20) blocks until the sfx has -- actually stopped sounding, which is how the original gives a sound its -- own clear window instead of letting the next beat play over it @@ -2684,6 +2687,15 @@ function BattleState:updateQueue() if self:stepExpBar() then return true end self.expFilling = nil end + return false +end + +function BattleState:updateQueue() + if self.waitingUI then + if self.game.stack:top() ~= self then return true end + self.waitingUI = nil + end + if self:updateQueuePresentation() then return true end if self.animPlaying then self.animPlayer:update() if self.animPlayer.pollEffects and self.applyAnimEffect then @@ -4272,6 +4284,28 @@ function BattleState:touchpressed(_, px, py) end return true end +function BattleState:updateBattlerPresentation(dt) + for _, b in activeBattlers(self) do + if b and b.picAnim then b.picAnim:update(dt) end + if b and b.monAnim then b.monAnim:update(dt) end + end +end + +function BattleState:stepPresentationWait() + if self.waitFrames and self.waitFrames > 0 then + self.waitFrames = self.waitFrames - 1 + return true + end + return false +end + +-- Advance an ending/pose hold without executing battle commands or input. +function BattleState:updatePresentation(dt) + self:tickFx() + self:updateBattlerPresentation(dt) + self:updateQueuePresentation() +end + function BattleState:update(dt) self:tickFx() -- one sample a second into the fight, while it is still on screen @@ -4279,10 +4313,7 @@ function BattleState:update(dt) if self._stadiumReportTicks == 60 then self:reportStadiumBattle("mid") end -- Crystal pic animations run off the wall clock like every other battle -- pic effect; a Gold/Silver battler has no picAnim and this is a no-op. - for _, b in activeBattlers(self) do - if b and b.picAnim then b.picAnim:update(dt) end - if b and b.monAnim then b.monAnim:update(dt) end - end + self:updateBattlerPresentation(dt) local input = self.game.input -- L PUTS THE BOTTOM SCREEN AWAY, AND BRINGS IT BACK. @@ -12862,7 +12893,21 @@ end do local update, draw, field, exit, enter = BattleState.update, BattleState.draw, BattleState.drawBattleField, BattleState.exit, BattleState.enter + local Clock = require('src.battle.PresentationClock') + for _, name in ipairs{'tickFx','updateBattlerPresentation','updateMovePresentation', + 'stepHPDrain','stepExpBar','stepPresentationWait','updateQueuePresentation'} do + local body = BattleState[name] + BattleState[name] = function(self, ...) + return Clock.once(self, name, body, ...) + end + end BattleState._drawNative = draw + local battlerDraw = BattleState.drawBattlerPic + function BattleState:drawBattlerPic(battler, ...) + local anim = GameVersion.isGen3() and package.loaded['src.core.game3.battle.anim'] + if anim then return anim._drawBattler(self, battler, battlerDraw, ...) end + return battlerDraw(self, battler, ...) + end function BattleState:enter() enter(self) local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle'] @@ -12870,6 +12915,12 @@ do end function BattleState:update(dt) local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle'] + local anim = source and package.loaded['src.core.game3.battle.anim'] + if anim then + return Clock.run(self, dt, function() + return source._nativeUpdate(self, dt, update) + end) + end if source then return source._nativeUpdate(self, dt, update) end return update(self, dt) end diff --git a/src/battle/Gen3MoveAnim.lua b/src/battle/Gen3MoveAnim.lua index f6a3a09ea..d2f590fb4 100644 --- a/src/battle/Gen3MoveAnim.lua +++ b/src/battle/Gen3MoveAnim.lua @@ -754,6 +754,7 @@ function Gen3MoveAnim:start(moveId, attackerIsPlayer, attackerPosition, targetPo branchCtx, battle) local anim = self:recordFor(moveId, branchCtx) if not hasVisual(anim) then return false end + self.presentationSerial = (self.presentationSerial or 0) + 1 self.events = anim.events or {} self.shakes = anim.shakes self.blends = anim.blends diff --git a/src/battle/PresentationClock.lua b/src/battle/PresentationClock.lua new file mode 100644 index 000000000..00d8d13a1 --- /dev/null +++ b/src/battle/PresentationClock.lua @@ -0,0 +1,34 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Presentation holds borrow a native update; they never drain its command +-- queue. Results (including failures and nils) are shared within that update. +local M={} +local phases=setmetatable({},{__mode='k'}) +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +function M.active(screen)return phases[screen]~=nil end +function M.delta(screen)return phases[screen]and phases[screen].delta end +function M.once(screen,key,body,...) + local phase=phases[screen] + if not phase then return body(screen,...)end + local entry=phase.steps[key] + if not entry then + entry={};phase.steps[key]=entry + entry.result=pack(pcall(body,screen,...)) + end + local r=entry.result + if not r then return nil,'presentation step is already executing'end + if not r[1]then error(r[2],0)end + return unpack(r,2,r.n) +end +function M.run(screen,dt,body,...) + if phases[screen]then return body(...)end + local phase={steps={},delta=dt};phases[screen]=phase + local result=pack(pcall(body,...));phases[screen]=nil + -- A wrapper cannot swallow a failed display step and retry that step. + for _,entry in pairs(phase.steps)do + if entry.result and not entry.result[1]then error(entry.result[2],0)end + end + if not result[1]then error(result[2],0)end + return unpack(result,2,result.n) +end +return M diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index 330f4f3e9..8d0464c60 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -1117,6 +1117,14 @@ end -- ways no amount of widening an `isGen2()` reaches -- a filesystem instead of -- a flat ROM, two screens instead of one, and a 3D field. This exists so the -- Gen 4 work has a gate to hang itself on without touching the branches the +-- Registered GBA family for source mod probes; cache/save ownership stays native. +local gbaLayouts={firered='frlg',leafgreen='frlg',ruby='rse',sapphire='rse',emerald='rse'} +function GameVersion.layout(id,versionId) + if id==GameVersion then id=versionId end + local info=GameVersion.info(id) + return info and(info.layout or gbaLayouts[info.id])or nil +end + -- other three generations take. function GameVersion.isGen4(id) return GameVersion.generation(id) == 4 diff --git a/src/core/game3/battle.lua b/src/core/game3/battle.lua index 7eaf7be14..dd02e0fa5 100644 --- a/src/core/game3/battle.lua +++ b/src/core/game3/battle.lua @@ -118,6 +118,9 @@ function M._nativeUpdate(screen,dt,body) if ui then return ui._nativeUpdate(screen,dt,native)end return native(screen,dt) end,function() + local anim=package.loaded['src.core.game3.battle.anim'] + if anim then anim._nativeStep(screen,dt)end + if not admitted(c)or c.stack:top()~=screen then return false,'native battle admission retired'end local result=call('update',dt,require('src.core.Game3').forNative(c.game)) if admitted(c)and c.stack:top()==screen then c.views:flush()end return result @@ -159,7 +162,12 @@ function M._nativeField(screen,body) return require('src.core.Game3')._withCallbackOwner(nil,body,owner) end) end -function M._nativeExit(screen)captures[screen]=nil;runtime:exit(screen)end +function M._nativeExit(screen) + captures[screen]=nil + local anim=package.loaded['src.core.game3.battle.anim'] + if anim then anim._nativeExit(screen)end + runtime:exit(screen) +end -- Private typed consumers can resolve a source observation to the same native -- admission; callers cannot submit another state or a stale completed view. function M._screen(view) diff --git a/src/core/game3/battle/anim.lua b/src/core/game3/battle/anim.lua new file mode 100644 index 000000000..5c3bb9c60 --- /dev/null +++ b/src/core/game3/battle/anim.lua @@ -0,0 +1,267 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- One admitted native presentation owner. This is not a second battle VM; +-- authored command/particle APIs are advertised only as they are integrated. +local Battle=require('src.core.game3.battle') +local Coords=require('src.core.game3.battle.anim_coords') +local Clock=require('src.battle.PresentationClock') +local Scope=require('src.render.GraphicsScope') +local Ids=require('src.compat.gen1.BattleIds') +local A={Coords=Coords,ENEMY_MON={x=176,y=40},PLAYER_MON={x=72,y=80}} +local current +local numeric={ox=true,oy=true,alpha=true,scale=true,sx=true,sy=true,rotation=true, + darken=true,flash=true,displayHp=true,displayMaxHp=true,displayLevel=true,displayExp=true} +local function retire() + if current then + local screen=current.screen[1] + if screen and current.clip and screen.gen3Anim==current.clip.player + and current.clip.serial==screen.gen3Anim.presentationSerial then + screen.gen3Anim:release();screen.gen3AnimPlaying=false + end + current.clip=nil + if current.effect then current.effect:release();current.effect=nil end + current.tasks={};current.onEnd=nil;current.users={};current.overrides={} + current.view=nil;current.screen[1]=nil + end + current=nil +end +local function context() + local screen=Battle._screen();local view=Battle.getState() + if not screen then retire();return end + if not current or current.screen[1]~=screen or current.view~=view then + retire() + current={screen=setmetatable({screen},{__mode='v'}),view=view,present={},overrides={}, + tasks={},nextTask=0,users={},ids=Ids.new(screen.data)} + end + local facade=require('src.core.Game3');local owner=facade._callbackOwner() + local host=package.loaded['src.mods.Runtime'];owner=host and host.currentMod or owner + if owner then current.users[owner]=true end + return current,screen +end +local function battler(screen,id) + if id==0 then return screen.player elseif id==1 then return screen.enemy end + return screen:battlerAt(id) +end +local function number(v)return type(v)=='number'and v==v and v~=math.huge and v~=-math.huge end +local function callback(fn,...) + if fn then return require('src.core.Game3')._withCallbackOwner(fn.owner,fn.call,...)end +end +local function owned(fn) + if fn==nil then return end + assert(type(fn)=='function','presentation callback must be a function') + local host=package.loaded['src.mods.Runtime'] + return{call=fn,owner=host and host.currentMod or require('src.core.Game3')._callbackOwner()} +end +local function read(d,id,key) + local active,screen=context();if active~=d then return nil end + local b=battler(screen,id);if not b then return nil end + local p=d.overrides[id] + if p[key]~=nil then return p[key]end + if key=='id'then return id end + if key=='side'then return Coords.sideOf(id)end + if key=='visible'then return not b.fainted and not screen:fxHidden(b)and not screen:gen3AnimMonBgHidden(b)end + if key=='invisible'or key=='battlerInvisible'then return screen:gen3AnimMonBgHidden(b)end + if key=='ox'or key=='oy'then + local x,y=screen:gen3AnimShake(b);local fx=screen.picFx and screen.picFx[b] + return(key=='ox'and x+(fx and fx.ox or 0)or y+(fx and fx.oy or 0)) + end + if key=='scale'then return screen:gen3AnimScale(b)or 1 end + if key=='sx'or key=='sy'then + local x,y=screen:gen3AnimSquash(b);return(key=='sx'and x or y)or 1 + end + if key=='rotation'then return screen:gen3AnimRotate(b)or 0 end + if key=='alpha'then local fx=screen:gen3AnimSpecialMonState(b);return fx and fx.alpha or 1 end + if key=='hFlip'then return false end + if key=='darken'or key=='flash'then return 0 end + if key=='displayHp'then return b.shownHP or b.mon.hp end + if key=='displayMaxHp'then return b.mon.stats.hp end + if key=='displayLevel'then return b.mon.level end + if key=='displayStatus'then local observed=Coords.battler(d.view,id);return observed and observed.status end + if key=='displayExp'then return screen:expFraction(b.mon)end +end +function A.idOf(key) + if key=='attacker_side'then + local _,screen=context();local anim=screen and screen.gen3Anim + return anim and anim.attackerPosition or 0 + end + return Coords.idOf(key) +end +A.sideOf,A.isDouble,A.setDouble=Coords.sideOf,Coords.isDouble,Coords.setDouble +A.coords,A.subpriority,A.bgPriorityRank=Coords.coords,Coords.subpriority,Coords.bgPriorityRank +A.monDrawOrder,A.particleBand,A.battlerIds=Coords.monDrawOrder,Coords.particleBand,Coords.ids +function A.x(value) + local _,screen=context();local anim=screen and screen.gen3Anim + value=tonumber(value)or 0 + return anim and anim.attackerIsPlayer==false and -value or value +end +function A.present(key) + local d,screen=context();local id=A.idOf(key) + if not d or id==nil or id>=2 and not Coords.isDouble(d.view)or not battler(screen,id)then return nil end + if not d.present[id]then + d.overrides[id]={} + d.present[id]=setmetatable({},{__index=function(_,k)return read(d,id,k)end, + __newindex=function(_,k,v) + assert(context()==d,'native battle presentation retired') + assert(k~='id'and k~='side','native battler identity is read-only') + if v~=nil and(numeric[k]or type(v)=='number')then assert(number(v),'invalid presentation number')end + d.overrides[id][k]=v + end}) + end + return d.present[id] +end +function A.battlerCenter(key) + local p=A.present(key);local base=Coords.coords(nil,A.idOf(key)) + if not p or not base then return nil end + return base.x+(p.ox or 0),base.y+(p.oy or 0) +end +function A.shownBattler(key,fallback)local p=A.present(key);return p and p.shown or fallback end +function A.setShown(key,value)local p=A.present(key);if p then p.shown=value;return true end;return false end +function A.ballIdOf(key) + local d,screen=context();local id=A.idOf(key);local b=d and id and battler(screen,id) + if not b then return nil end + return require('src.battle.Gen3BallAnim').ballIndex(screen.data,b.mon.ball or'POKE_BALL',screen.data.items) +end +function A.displayHpRatio(key,fallback) + local p=A.present(key);local mon=fallback and fallback.mon + local hp=p and p.displayHp or mon and mon.hp or 0 + local maximum=math.max(1,p and p.displayMaxHp or mon and mon.maxHp or 1) + return math.max(0,math.min(1,hp/maximum)),hp,maximum +end +function A.displayExpRatio(key,fallback) + local p=A.present(key or'player') + return p and p.displayExp or 0,p and p.displayLevel or fallback and fallback.mon and fallback.mon.level or 1 +end +function A.hpTweening() + local _,screen=context();if not screen then return false end + for _,id in ipairs(Coords.ids())do + local b=battler(screen,id) + if b and b.shownHP and(b.shownHP~=b.mon.hp or b.draining or(b.drainHold or 0)>0)then return true end + end + return false +end +local function effect() + local d=context();if not d then return end + if not d.effect then d.effect=require('src.render.BattleScreenEffect').new()end + return d.effect +end +function A.setScreenEffect(opts)local e=effect();return e and e:set(opts)or false end +function A.screenEffect()local e=effect();return e and e.value end +function A.beginScreenEffect()local e=effect();return e and e:begin()or false end +function A.endScreenEffect()local e=effect();return e and e:finish()or false end +function A.busy() + local d,screen=context() + return d~=nil and(screen.gen3AnimPlaying==true or screen.gen3BallPlaying==true + or A.hpTweening()or screen.expFilling==true or next(d.tasks)~=nil)or false +end +function A.setSeqBusy(value)local d=context();if d then d.seqBusy=value==true end end +function A.seqBusy()local d=context();return d and d.seqBusy==true or false end +function A.tweenStage(frames,onStep,onComplete) + local d=context();if not d then return nil,'no admitted native battle'end + assert(number(frames)and frames>=0,'invalid presentation duration') + d.nextTask=d.nextTask+1 + local task={id=d.nextTask,frames=0,total=math.max(1,math.ceil(frames)),step=owned(onStep),done=owned(onComplete)} + d.tasks[task.id]=task + return task +end +function A._nativeStep(screen,dt) + if not Clock.active(screen)then return false,'animation update belongs to its native battle phase'end + local d,actual=context();if actual~=screen or screen.game.stack:top()~=screen then return false end + return Clock.once(screen,'source.presentation',function() + local clip=d.clip + if clip then + if clip.player~=screen.gen3Anim or clip.serial~=screen.gen3Anim.presentationSerial then + d.clip,d.onEnd=nil,nil + elseif screen.gen3AnimPlaying then + require('src.core.Game3')._withCallbackOwner(nil,screen.updateMovePresentation,screen) + end + end + -- Snapshot the IDs: a callback may schedule another tween, which starts + -- next frame, or retire the admission, which cancels the remaining work. + local ids={};for id in pairs(d.tasks)do ids[#ids+1]=id end;table.sort(ids) + for _,id in ipairs(ids)do + if context()~=d or screen.game.stack:top()~=screen then return false end + local task=d.tasks[id] + if task then + task.frames=task.frames+1 + callback(task.step,math.min(1,task.frames/task.total),task) + if context()~=d then return false end + if task.frames>=task.total then d.tasks[id]=nil;callback(task.done)end + end + end + if context()==d and screen.game.stack:top()==screen and d.onEnd and not screen.gen3AnimPlaying then + local done=d.onEnd;d.onEnd,d.clip=nil,nil;callback(done) + elseif context()==d and not screen.gen3AnimPlaying then d.clip=nil + end + return true + end) +end +function A._holdsNativeQueue(screen) + local d,actual=context();local clip=d and d.clip + return actual==screen and clip~=nil and clip.player==screen.gen3Anim + and clip.serial==screen.gen3Anim.presentationSerial and screen.gen3AnimPlaying==true +end +function A.update(dt) + local _,screen=context() + if not screen or not Clock.active(screen)then return false,'animation update belongs to its native battle phase'end + if screen.game.stack:top()~=screen then return false,'native battle is covered by another state'end + A._nativeStep(screen,dt) + if Battle._screen()~=screen or screen.game.stack:top()~=screen then return false,'native battle admission retired'end + -- Use the owner's dt, not a second caller-provided clock. The original + -- native update and this hold consume the same once-only display steps. + require('src.core.Game3')._withCallbackOwner(nil,screen.updatePresentation,screen,Clock.delta(screen)) + return true +end +function A.launchMove(moveId,opts) + opts=opts or{};local d,screen=context() + if not d then return false,'no admitted native battle'end + if screen.game.stack:top()~=screen then return false,'native battle is covered by another state'end + if screen.gen3AnimPlaying then return false,'native animation is already active'end + local native=d.ids:native('moves',moveId);local anim=screen.gen3Anim + if not native or not anim or not anim:has(native,opts)then return false,'no native animation record'end + if opts.miss then return false,'native miss presentation requires its authored command'end + local attacker=A.idOf(opts.attackerId or opts.attackerSide or'player') + local target=A.idOf(opts.targetId or opts.targetSide or'enemy') + if attacker==nil or target==nil or not battler(screen,attacker)or not battler(screen,target)then + return false,'animation requires admitted native battlers' + end + local done=owned(opts.onEnd) + if not require('src.core.Game3')._withCallbackOwner(nil,anim.start,anim,native,attacker%2==0,attacker,target,opts,screen)then return false end + screen.gen3AnimPlaying=true;d.onEnd=done;d.clip={player=anim,serial=anim.presentationSerial} + return true +end +function A.reset() + retire();Coords._service.release();return true +end +function A._nativeExit(screen) + if current and current.screen[1]==screen then A.reset()end +end +-- Applied around the existing native sprite draw, preserving its scanlines, +-- tints, substitutes and affine effects. Only source overrides compose here; +-- querying present() never feeds native effects back into themselves. +function A._drawBattler(screen,b,body,...) + local d,actual=context();local id=b.position or(b.isPlayer and 0 or 1) + local p=d and actual==screen and d.overrides[id] + if not p or next(p)==nil then return body(screen,b,...)end + if p.visible==false or p.invisible or p.battlerInvisible or p.blinkHidden then return end + if not(p.hFlip or(p.ox or 0)~=0 or(p.oy or 0)~=0 or(p.rotation or 0)~=0 + or(p.scale or 1)~=1 or(p.sx or 1)~=1 or(p.sy or 1)~=1 or(p.alpha or 1)~=1)then + return body(screen,b,...) + end + local args={...};local x,y,scale=args[1],args[2],args[3]or 1 + local img=screen:battlerPic(b);local w,h=img:getDimensions() + local cx,cy=x+w*scale/2,y+h*scale/2 + local ok,result=Scope.call(function() + local g=love.graphics + g.translate(cx+(p.ox or 0),cy+(p.oy or 0));g.rotate(p.rotation or 0) + g.scale((p.scale or 1)*(p.sx or 1)*(p.hFlip and -1 or 1),(p.scale or 1)*(p.sy or 1)) + g.translate(-cx,-cy) + local r,green,blue,a=g.getColor();g.setColor(r,green,blue,a*(p.alpha or 1)) + return body(screen,b,x,y,scale) + end) + if not ok then error(result,0)end + return result +end +A._service={release=A.reset,poll=function()context()end,releaseOwner=function(_,owner) + if current and current.users[owner]then A.reset()end +end} +require('src.core.game3.asset_stream').register(A._service) +return A diff --git a/src/core/game3/battle/anim_coords.lua b/src/core/game3/battle/anim_coords.lua new file mode 100644 index 000000000..6f8611cf7 --- /dev/null +++ b/src/core/game3/battle/anim_coords.lua @@ -0,0 +1,131 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Authored 240x160 sprite anchors, separate from opaque image bounds. Imported +-- cartridge coordinates override the standard anchors on the native owner. +local Battle=require('src.core.game3.battle') +local C={SINGLES={},DOUBLES={}} +C.SUBPRIORITY={[0]=30,40,20,50} +C.BG_PRIORITY_RANK={[0]=2,1,1,2} +C.DRAW_ORDER_SINGLES={1,0} +C.DRAW_ORDER_DOUBLES={3,1,0,2} +C.battleState=Battle.getState +local function point(x,y)return{x=x,y=y,[1]=x,[2]=y}end +for id,p in pairs{[0]={72,80},{176,40},{48,40},{112,80}}do C.SINGLES[id]=point(p[1],p[2])end +for id,p in pairs{[0]={32,80},{200,40},{90,88},{152,32}}do C.DOUBLES[id]=point(p[1],p[2])end +local double,overrides,binding +local users={} +local function recordOwner() + local host=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + local owner=host and host.currentMod or facade and facade._callbackOwner() + if owner then users[owner]=true end +end +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +function C.sideOf(id)return type(id)=='number'and(id%2==0 and'player'or'enemy')or nil end +function C.partner(id)return type(id)=='number'and(id+2)%4 or nil end +function C.fixedId(key) + if type(key)=='number'and key==key and key>=0 and key<=3 then return math.floor(key)end + return({player=0,enemy=1})[key] +end +function C.idOf(key) + if type(key)=='table'then key=key.id or key.side end + if binding and binding[key]~=nil then return binding[key]end + return C.fixedId(key) +end +function C.bind(attacker,target) + recordOwner() + if attacker==nil then binding=nil;return end + attacker=C.fixedId(attacker);target=C.fixedId(target) + binding={} + if target then binding[C.sideOf(target)]=target end + if attacker then binding[C.sideOf(attacker)]=attacker end +end +function C.withBattler(key,fn,...) + local previous=binding;local id=C.idOf(key) + binding={player=previous and previous.player or 0,enemy=previous and previous.enemy or 1} + if id then binding[C.sideOf(id)]=id end + local result=pack(pcall(fn,C.sideOf(id),...));binding=previous + if not result[1]then error(result[2],0)end + return unpack(result,2,result.n) +end +function C.sideArg(fn,index) + index=index or 1 + return function(...) + local args=pack(...);local id=C.idOf(args[index]) + if type(args[index])~='number'or not id then return fn(...)end + return C.withBattler(id,function(side) + args[index]=side;return fn(unpack(args,1,args.n)) + end) + end +end +function C.setDouble(value)recordOwner();if value==nil then double=nil else double=not not value end end +function C.isDouble(st) + if double~=nil then return double end + st=st or Battle.getState();return st and st.double==true or false +end +function C.setCoordinateOverrides(value)recordOwner();local previous=overrides;overrides=value;return previous end +function C.coords(st,key) + local id=C.idOf(key);if id==nil then return nil end + local p=overrides and overrides[id] + if p then return p end + local screen=Battle._screen(st) + local imported=screen and screen.data.constants.gen3BattlerCoords + local row=imported and(C.isDouble(st)and imported.doubles or imported.singles) + p=row and row[id+1]or(C.isDouble(st)and C.DOUBLES or C.SINGLES)[id] + return point(p.x or p[1],p.y or p[2]) +end +function C.ids(st)return C.isDouble(st)and{0,1,2,3}or{0,1}end +function C.battler(st,key) + st=st or Battle.getState();local id=C.idOf(key) + if not st or id==nil then return nil end + return st.battlers and st.battlers[id]or(id==0 and st.player or id==1 and st.enemy or nil) +end +function C.spritePresent(st,key) + local id=C.idOf(key);if id==nil or id>=2 and not C.isDouble(st)then return false end + local b=C.battler(st,id) + if not st then st=Battle.getState()end + if st and st.absent and st.absent[id]then return false end + return b==nil and id<2 or b~=nil and not b.absent and not b.fainted and(b.mon==nil or(b.mon.hp or 0)>0) +end +function C.subpriority(key)return C.SUBPRIORITY[C.idOf(key)]or 40 end +function C.bgPriorityRank(key)return C.BG_PRIORITY_RANK[C.idOf(key)]or 1 end +function C.monDrawOrder(st)return C.isDouble(st)and{3,1,0,2}or{1,0}end +function C.monBehindZ(key,st) + local id=C.idOf(key) + for i,slot in ipairs(C.monDrawOrder(st))do if slot==id then return(i-1)*100+95 end end + return 95 +end +function C.particleBand(k,st) + k=tonumber(k)or 0 + if k<=0 then return 0,99 end + return k*100+1,k>=#C.monDrawOrder(st)and 999 or k*100+99 +end +function C.layerZ(sub,monbg,bgPriority,st) + sub=tonumber(sub)or 0 + local order=C.monDrawOrder(st);local band=0 + for i=#order,1,-1 do + local id=order[i] + local front + if monbg and rawget(monbg,id)then front=((bgPriority and bgPriority[C.bgPriorityRank(id)])or 2)>=2 + else front=sub=3 then return math.max(1,math.min(98,98-sub))end + return C.layerZ(sub,vm and vm._monbg,vm and vm._bgPrio,st) +end +function C.idTable(values) + local result=setmetatable({},{__index=function(t,k)local id=C.idOf(k);if id~=nil then return rawget(t,id)end end, + __newindex=function(t,k,v)local id=C.idOf(k);rawset(t,id~=nil and id or k,v)end}) + for k,v in pairs(values or{})do result[k]=v end + return result +end +C._service={release=function()double,overrides,binding=nil,nil,nil;users={}end} +C._service.poll=function()if not Battle.isActive()then C._service.release()end end +C._service.releaseOwner=function(_,owner)if users[owner]then C._service.release()end end +require('src.core.game3.asset_stream').register(C._service) +return C diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 14c98eea7..0809268ee 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -36,7 +36,7 @@ Loader.__index = Loader -- stream service is available but not yet driven by loader retirement. local function retireBattlePresentation() local first - for _,name in ipairs({'src.core.game3.battle','src.core.game3.pokemon','src.core.game3.mon_anim'})do + for _,name in ipairs({'src.core.game3.battle','src.core.game3.battle.anim','src.core.game3.battle.anim_coords','src.core.game3.pokemon','src.core.game3.mon_anim'})do local module=package.loaded[name];local service=type(module)=='table'and module._service if type(service)=='table'and type(service.release)=='function'then local ok,why=pcall(service.release,service) diff --git a/src/mods/ModSeams.lua b/src/mods/ModSeams.lua index 564eb179c..a6b0c086b 100644 --- a/src/mods/ModSeams.lua +++ b/src/mods/ModSeams.lua @@ -116,7 +116,7 @@ function ModSeams.open(owner) local function require2(name, ...) local had = package.loaded[name] local result = real(name, ...) - if name=='src.core.game3.battle' then + if name=='src.core.game3.battle' or type(name)=='string' and name:match('^src%.core%.game3%.battle%.') then local facade=package.loaded['src.core.Game3'] or real('src.core.Game3') if facade and facade._observeMod then facade._observeMod(owner)end local watched=false diff --git a/src/render/BattleScreenEffect.lua b/src/render/BattleScreenEffect.lua new file mode 100644 index 000000000..ff45819cf --- /dev/null +++ b/src/render/BattleScreenEffect.lua @@ -0,0 +1,62 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Repeated begin calls reapply an effect within one render seam. Nested seams +-- each restore their caller's shader, including error paths guarded by Scope. +local Scope=require('src.render.GraphicsScope') +local M={};M.__index=M +local codes={invert=1,fade_white=2,fade_black=3,darken=3,grayscale=4,custom_blend=5} +local source=[[ +extern number mode; +extern number amount; +extern vec3 blendColour; +vec4 effect(vec4 colour, Image image, vec2 uv, vec2 screen) { + vec4 pixel=Texel(image,uv)*colour; + vec3 target=pixel.rgb; + if(mode<1.5) target=vec3(1.0)-pixel.rgb; + else if(mode<2.5) target=vec3(1.0); + else if(mode<3.5) target=vec3(0.0); + else if(mode<4.5) target=vec3(dot(pixel.rgb,vec3(0.299,0.587,0.114))); + else target=blendColour; + pixel.rgb=mode<1.5 ? target : mix(pixel.rgb,target,amount); + return pixel; +} +]] +function M.new() + return setmetatable({value={type='none',coeff=0,targetColor={1,1,1}}, + bindings=setmetatable({},{__mode='k'}),outside={}},M) +end +function M:set(opts) + if not opts or opts.type==false or opts.type=='none'then + self.value={type='none',coeff=0,targetColor={1,1,1}};return true + end + local kind=opts.type or'invert' + assert(codes[kind],'unsupported battle screen effect') + local amount=opts.coeff==nil and 1 or opts.coeff + assert(type(amount)=='number'and amount==amount and amount>=0 and amount<=1,'invalid battle effect strength') + local colour=opts.targetColor or{1,1,1};local copy={} + for i=1,3 do local v=colour[i];assert(type(v)=='number'and v==v and v>=0 and v<=1,'invalid battle effect colour');copy[i]=v end + self.value={type=kind,coeff=amount,targetColor=copy};return true +end +function M:begin() + local value=self.value;local code=codes[value.type] + if not code then return false end + local g=love.graphics + if not self.shader then self.shader=g.newShader(source)end + local token=Scope.token()or self.outside + local previous=self.bindings[token] + if not previous then previous={shader=g.getShader()};self.bindings[token]=previous end + self.shader:send('mode',code);self.shader:send('amount',value.coeff) + self.shader:send('blendColour',value.targetColor) + g.setShader(self.shader);return true +end +function M:finish() + local token=Scope.token()or self.outside;local previous=self.bindings[token] + if not previous then return false end + self.bindings[token]=nil;love.graphics.setShader(previous.shader);return true +end +function M:release() + -- A surrounding render guard restores a shader still in use on an error + -- path. Do not destroy a borrowed binding during that unwind. + if self.shader and love.graphics.getShader()~=self.shader then self.shader:release();self.shader=nil end + self.bindings=setmetatable({},{__mode='k'}) +end +return M diff --git a/src/render/GraphicsScope.lua b/src/render/GraphicsScope.lua new file mode 100644 index 000000000..396978870 --- /dev/null +++ b/src/render/GraphicsScope.lua @@ -0,0 +1,56 @@ +-- Fence a native render seam, including leaked pushes and canvas changes. +local S = {} +local current +-- Opaque lifetime for paired helpers inside a guarded render callback. +function S.token() return current end +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +function S.call(fn, ...) + local G = love.graphics + local canvas = pack(G.getCanvas()) + local realPush, realPop = G.push, G.pop + local before = G.getStackDepth and G.getStackDepth() + local pushed, why = pcall(realPush, "all") + if not pushed then + return false, why + end + local depth = 0 + G.push = function(...) + local result = pack(realPush(...)) + depth = depth + 1 + return unpack(result, 1, result.n) + end + G.pop = function(...) + if depth <= 0 then + error("render callback cannot pop caller graphics state", 0) + end + local result = pack(realPop(...)) + depth = depth - 1 + return unpack(result, 1, result.n) + end + local parent = current + current = {} + local out = pack(pcall(fn, ...)) + current = parent + G.push, G.pop = realPush, realPop + if before and G.getStackDepth then + local current = G.getStackDepth() + if current < before + 1 then + G.setCanvas(unpack(canvas, 1, canvas.n)) + return false, "render callback removed its graphics guard" + end + depth = current - before - 1 + end + for _ = 1, depth do + pcall(realPop) + end + local restored, err = pcall(realPop) + G.setCanvas(unpack(canvas, 1, canvas.n)) + if not restored then + return false, err + end + return unpack(out, 1, out.n) +end +return S diff --git a/tests/engine/native_battle_animation.lua b/tests/engine/native_battle_animation.lua new file mode 100644 index 000000000..f7d3c2172 --- /dev/null +++ b/tests/engine/native_battle_animation.lua @@ -0,0 +1,137 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Battle=require('src.core.game3.battle') +local A=require('src.core.game3.battle.anim') +local C=A.Coords +local Clock=require('src.battle.PresentationClock') +local base=Battle.update +T.eq(A.update(1/60),false,'standalone callers cannot start an animation clock') +T.eq(A.launchMove(33),false);T.eq(A.present(0),nil) +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + A.reset();Battle._service.release() + local f=require('tools.battle-source-runtime.fixture').new(edition) + local game=f.game;package.loaded['src.core.Game']=game + game.data.constants.gen3BattleAnimGfx={} + game.data.moves.MOVE_033.anim={duration=4,shakes={{at=0,duration=4,x=2,y=1}}} + local b=f.fresh('TACKLE').battle;b.enter=false;game.stack:push(b);b.phase='QA_IDLE' + local st=Battle.getState();local p=A.present(0) + T.eq(A.present('player'),p);T.eq(p.displayHp,400);T.eq(p.displayMaxHp,400) + T.eq(p.id,0);T.eq(p.side,'player');T.eq(A.present(2),nil) + T.raises(function()p.id=1 end,'read-only') + T.raises(function()p.alpha=0/0 end,'invalid presentation') + C.setDouble(true);T.eq(C.isDouble(),true);C.setDouble(nil);T.eq(C.isDouble(),false) + C.bind(2,3);T.eq(C.idOf('player'),2);T.eq(C.idOf('enemy'),3) + local got=pack(C.withBattler(0,function(side,a,z)T.eq(side,'player');T.eq(C.idOf(side),0);return a,z,nil end,7,8)) + T.eq(got.n,3);T.eq(got[1],7);T.eq(got[2],8) + T.eq(C.idOf('player'),2,'nested binding is restored including trailing nils') + T.raises(function()C.withBattler(0,function()error('binding failure')end)end,'binding failure') + T.eq(C.idOf('player'),2) + local arg=C.sideArg(function(a,side,z)T.eq(a,4);T.eq(side,'enemy');T.eq(C.idOf(side),3);T.eq(z,nil);return true,nil end,2) + T.eq(pack(arg(4,3,nil)).n,2);C.bind(nil) + T.same(C.monDrawOrder(st),{1,0});T.eq(C.subpriority(0),30) + T.eq(C.bgPriorityRank(3),2);T.eq(C.monBehindZ(0),195) + T.same(pack(C.particleBand(2)),{n=2,201,999}) + game.data.constants.gen3BattlerCoords={singles={{x=73,y=81},{x=177,y=41}}} + local x,y=A.battlerCenter(0);T.eq(x,73);T.eq(y,81,'authored anchors use the admitted cartridge table') + local override={[0]={x=25,y=27}} + T.eq(C.setCoordinateOverrides(override),nil);T.eq(C.coords(nil,0),override[0]);C.setCoordinateOverrides(nil) + local poseFrames,poseDt=0 + b.player.picAnim={update=function(_,dt)poseFrames=poseFrames+1;poseDt=dt end} + local done=0 + T.eq(A.launchMove(33,{attackerId=0,targetId=1,onEnd=function()done=done+1 end}),true) + T.eq(A.launchMove(33),false,'an active native clip cannot be overwritten') + T.eq(b.gen3Anim.attackerPosition,0);T.eq(b.gen3Anim.targetPosition,1) + local ball=require('src.battle.Gen3BallAnim').new(game.data,{sendOut='opponent',battler=b.enemy}) + b.gen3Ball,b.gen3BallPlaying=ball,true + b.waitFrames=3;b.queue={{fn=function()error('held battle command executed')end}} + local frames=b.frame + local stepped,completed=0,0 + A.tweenStage(2,function(u,t)stepped=stepped+1;p.ox=12*u;T.eq(t.frames,stepped)end,function()completed=completed+1 end) + Battle.update=function(dt) + T.eq(A.update(999),true);T.eq(A.update(-999),true) + base(dt);return base(dt) + end + b:update(1/60) + T.eq(b.frame,frames+1);T.eq(b.gen3Anim.frame,1);T.eq(ball.frame,1) + T.eq(poseFrames,1);T.eq(poseDt,1/60,'a source argument cannot replace the native dt') + T.eq(b.waitFrames,3,'a source clip owns its own hold');T.eq(#b.queue,1);T.eq(stepped,1);T.eq(completed,0) + T.eq(p.ox,6);T.eq(A.battlerCenter(0),79) + Battle.update=function()return A.update(0)end + b:update(1/60) + T.eq(b.frame,frames+2);T.eq(b.gen3Anim.frame,2);T.eq(b.waitFrames,3) + T.eq(stepped,2);T.eq(completed,1);T.eq(#b.queue,1,'a source ending hold advances display without combat') + for i=1,300 do if not b.gen3AnimPlaying then break end;b:update(1/60)end + T.eq(b.gen3AnimPlaying,false);T.eq(done,1,'source completion follows the real native clip') + for i=1,3 do b:update(1/60)end;T.eq(b.waitFrames,0) + b.player.mon.hp=380;b.player.shownHP=400;b.draining=true + b:update(1/60) + local shown=b.player.shownHP;T.check(shown<400);T.eq(b.player.mon.hp,380,'display never applies damage') + T.eq(A.displayHpRatio(0),shown/400);T.eq(A.hpTweening(),true) + local heldFrame=b.frame;local overlay={};game.stack:push(overlay) + T.eq(A.update(1/60),false);T.eq(b:update(1/60),false) + T.eq(b.frame,heldFrame);T.eq(b.player.shownHP,shown);game.stack:pop() + -- Retire through a real session replacement; neither old poses nor queued + -- callbacks may write to the new admission, even if a save is restored. + local stale=p;local cancelled=0 + A.tweenStage(1,function()cancelled=cancelled+1 end) + local save=game.save;game.save={};T.neq(A.present(0),stale,'a replacement save creates a new presentation identity');game.save=save + T.raises(function()stale.ox=10 end,'retired') + b:update(1/60);T.eq(cancelled,0);T.neq(A.present(0),stale) + b.player.picAnim=nil;Battle.update=base + game.stack.states={};f.close();A._service.poll() + local function nativeTrace(enabled) + package.loaded['src.core.game3.battle.anim']=enabled and A or nil + local fixture=require('tools.battle-source-runtime.fixture').new(edition) + local g=fixture.game;package.loaded['src.core.Game']=g + g.data.constants.gen3BattleAnimGfx={} + g.data.moves.MOVE_033.anim={duration=4,shakes={{at=0,duration=4,x=2,y=1}}} + local state=fixture.fresh('TACKLE').battle;state.enter=false;g.stack:push(state) + state.player.shownHP,state.enemy.shownHP=400,400 + state.phase='messages';state.current=nil + state.queue={{fn=function() + local s=state + s.gen3Anim:start('MOVE_033',true,0,1,nil,s);s.gen3AnimPlaying=true + s.waitFrames=s.gen3Anim.total + end},{fn=function() + local s=state + s.enemy.mon.hp=370;s.enemy.shownHP=400;s.draining=true + end},{fn=function() + local s=state + s.gen3Ball=require('src.battle.Gen3BallAnim').new(g.data,{sendOut='opponent',battler=s.enemy}) + s.gen3BallPlaying=true;s.waitFrames=80 + end}} + local trace={} + for frame=1,180 do + state:update(1/60) + trace[frame]={frame=state.frame,phase=state.phase,queue=#state.queue,wait=state.waitFrames, + hp=state.enemy.mon.hp,shown=state.enemy.shownHP,draining=state.draining, + anim=state.gen3Anim.frame,playing=state.gen3AnimPlaying, + ball=state.gen3Ball and state.gen3Ball.frame,ballPlaying=state.gen3BallPlaying} + end + g.stack.states={};fixture.close();Battle._service.release();A.reset() + return trace + end + local baseline=nativeTrace(false);local adapted=nativeTrace(true) + for frame=1,#baseline do + local different=false + for key,value in pairs(baseline[frame])do if adapted[frame][key]~=value then different=true end end + for key,value in pairs(adapted[frame])do if baseline[frame][key]~=value then different=true end end + if different then print('FIRST_NATIVE_TRACE_DIFFERENCE',edition,frame, + require('src.link.Json').encode(baseline[frame]),require('src.link.Json').encode(adapted[frame]));break end + end + T.same(adapted,baseline,'an imported animation namespace preserves native queue timing '..edition) +end +Battle.update=base;A.reset();Battle._service.release() +-- Failed native work remains failed throughout its phase; a source wrapper +-- cannot swallow it and retry. The next real frame can recover normally. +local screen={};local attempts=0 +T.raises(function()Clock.run(screen,1/60,function() + local function fail()attempts=attempts+1;error('display failure')end + pcall(Clock.once,screen,'fx',fail);pcall(Clock.once,screen,'fx',fail) +end)end,'display failure') +T.eq(attempts,1);T.eq(Clock.active(screen),false) +T.eq(Clock.run(screen,1/60,function()return Clock.once(screen,'fx',function()return true,nil,4 end)end),true) +T.finish('native battle animation ownership') diff --git a/tests/engine/native_version_layout.lua b/tests/engine/native_version_layout.lua new file mode 100644 index 000000000..c2a5fcee9 --- /dev/null +++ b/tests/engine/native_version_layout.lua @@ -0,0 +1,19 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.harness') +local Version=require('src.core.GameVersion') +local layouts={firered='frlg',leafgreen='frlg',emerald='rse',ruby='rse',sapphire='rse'} +local original=Version.get() +for id,info in pairs(Version.VERSIONS)do + local prefix,suffix,manifest=info.cachePrefix,info.saveSuffix,info.manifest + T.eq(Version.layout(id),info.layout or layouts[id],id..' metadata layout') + T.eq(Version:layout(id),info.layout or layouts[id],id..' colon query') + Version.set(id);T.eq(Version.layout(),info.layout or layouts[id],id..' current query') + T.eq(Version.get(),id);T.eq(info.cachePrefix,prefix);T.eq(info.saveSuffix,suffix);T.eq(info.manifest,manifest) +end +Version.VERSIONS.QA_LAYOUT={id='QA_LAYOUT',generation=3,layout='custom'} +T.eq(Version.layout('QA_LAYOUT'),'custom','an explicit registered layout takes precedence') +Version.VERSIONS.QA_LAYOUT=nil +T.eq(Version.layout('UNKNOWN'),nil,'unknown IDs cannot inherit the current cartridge layout') +Version.set(original) +T.finish('native registered version layout queries') diff --git a/tools/battle-animation-runtime/coords-check.lua b/tools/battle-animation-runtime/coords-check.lua new file mode 100644 index 000000000..9401a88d9 --- /dev/null +++ b/tools/battle-animation-runtime/coords-check.lua @@ -0,0 +1,42 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Optional local contract comparison. The oracle is supplied by the caller; +-- no external engine, private package or cartridge is distributed here. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local C=require('src.core.game3.battle.anim_coords') +local reference=assert(loadfile(assert(os.getenv('BATTLE_COORDS_ORACLE'))))() +for _,double in ipairs{false,true}do + local st={double=double,battlers={}} + C.setDouble(double);reference.setDouble(double) + T.same(C.monDrawOrder(st),reference.monDrawOrder(st)) + T.same(C.ids(st),reference.ids(st)) + for id=0,3 do + T.same(C.coords(st,id),reference.coords(st,id)) + T.eq(C.subpriority(id),reference.subpriority(id)) + T.eq(C.bgPriorityRank(id),reference.bgPriorityRank(id)) + T.eq(C.monBehindZ(id,st),reference.monBehindZ(id,st)) + T.eq(C.partner(id),reference.partner(id)) + for _,hp in ipairs{0,1}do + st.battlers[id]={mon={hp=hp}} + T.eq(C.spritePresent(st,id),reference.spritePresent(st,id)) + end + end + for k=0,5 do + local a,b=C.particleBand(k,st);local x,y=reference.particleBand(k,st) + T.eq(a,x);T.eq(b,y) + end + for sub=0,255 do + for pri=0,3 do + for _,vm in ipairs{{},{_monbg={[0]=true,[3]=true},_bgPrio={[1]=1,[2]=3}}}do + T.eq(C.zFor(pri,sub,vm,st),reference.zFor(pri,sub,vm,st)) + end + end + end +end +C.setDouble(nil);reference.setDouble(nil) +for attacker=0,3 do for target=0,3 do + C.bind(attacker,target);reference.bind(attacker,target) + for _,side in ipairs{'player','enemy'}do T.eq(C.idOf(side),reference.idOf(side))end +end end +C.bind(nil);reference.bind(nil) +T.finish('authored battler coordinate contract') diff --git a/tools/battle-animation-runtime/host/conf.lua b/tools/battle-animation-runtime/host/conf.lua new file mode 100644 index 000000000..7aea1d29a --- /dev/null +++ b/tools/battle-animation-runtime/host/conf.lua @@ -0,0 +1,6 @@ +function love.conf(t) + t.identity=assert(os.getenv('POKEPORT_IDENTITY')) + t.window.width,t.window.height=480,320;t.window.vsync=0;t.window.highdpi=false + t.modules.audio,t.modules.joystick,t.modules.physics=false,false,false + t.graphics=t.graphics or{};t.graphics.renderers={assert(os.getenv('POKEPORT_GRAPHICS_BACKEND'))} +end diff --git a/tools/battle-animation-runtime/host/main.lua b/tools/battle-animation-runtime/host/main.lua new file mode 100644 index 000000000..a6381f06b --- /dev/null +++ b/tools/battle-animation-runtime/host/main.lua @@ -0,0 +1,159 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +io.stdout:setvbuf('no') +local root=assert(os.getenv('BATTLE_SOURCE_ENGINE')) +package.path=root..'/?.lua;'..root..'/?/init.lua;./?/init.lua;'..package.path +function love.errorhandler(err)io.stderr:write(debug.traceback(tostring(err),2)..'\n');return function()return 1 end end +function love.load() + local T=require('tests.harness').suite('native battle animation GPU') + local A=require('src.core.game3.battle.anim') + local Battle=require('src.core.game3.battle') + local Scope=require('src.render.GraphicsScope') + local g=love.graphics + local nativeSource=debug.getinfo(require('src.battle.BattleState').update,'S').source + T.eq(nativeSource,'@'..root..'/src/battle/BattleState.lua') + local renderer,version,vendor,device=g.getRendererInfo() + local backend=assert(os.getenv('POKEPORT_GRAPHICS_BACKEND')) + T.check(renderer:lower():find(backend,1,true)~=nil,'actual requested GPU backend') + T.eq(love.filesystem.getIdentity(),os.getenv('POKEPORT_IDENTITY')) + love.filesystem.createDirectory('tests/fixture_data/assets') + local pixels=love.image.newImageData(64,64) + pixels:mapPixel(function(x,y) + if x<7 or y<5 or x>56 or y>57 then return 0,0,0,0 end + if y<20 and x<37 then return 1,0,91/255,1 end + if x>40 and y>35 then return 0,1,125/255,1 end + return .2,.3,.9,1 + end) + for _,side in ipairs{'back','front'}do pixels:encode('png','tests/fixture_data/assets/fixmon_a_'..side..'.png')end + pixels:release() + local comparisons,readbacks=0,0 + local function read(canvas) + local data=g.readbackTexture and g.readbackTexture(canvas)or canvas:newImageData() + local bytes=data:getString();data:release();readbacks=readbacks+1;return bytes + end + local function frame(canvas,draw) + local ok,why=Scope.call(function() + g.setCanvas(canvas);g.origin();g.setShader();g.setScissor();g.clear(0,0,0,0);g.setColor(1,1,1,1);draw() + end) + assert(ok,why);return read(canvas) + end + local function same(a,b,label,tolerance) + comparisons=comparisons+1 + if not tolerance then return T.eq(a,b,label)end + local error=0 + for i=1,#a do error=math.max(error,math.abs(a:byte(i)-b:byte(i)))end + if error>tolerance then + local count=0 + for i=1,#a,4 do + local bad=false;for c=0,3 do if math.abs(a:byte(i+c)-b:byte(i+c))>tolerance then bad=true end end + if bad then + print('PIXEL_ORACLE_DIFFERENCE',label,((i-1)/4)%160,math.floor((i-1)/4/160), + string.format('%d,%d,%d,%d',a:byte(i,i+3)),string.format('%d,%d,%d,%d',b:byte(i,i+3)));count=count+1;if count==4 then break end + end + end + print('MAX_PIXEL_ERROR',error) + end + T.check(#a==#b and error<=tolerance,label) + end + -- Independent pixel oracle: inverse-map output pixel centres into the + -- already rendered native image, without using graphics transforms. + local function remap(bytes,width,height,map,alpha) + local rows={} + for y=0,height-1 do + local row={} + for x=0,width-1 do + local sx,sy=map(x+.5,y+.5);sx,sy=math.floor(sx),math.floor(sy) + local value='\0\0\0\0' + if sx>=0 and sx=0 and sy