From 6bbc5a42bda85a94c4da4aa1e0bea43ea66b9105 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 03:06:57 +0100 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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