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/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/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 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..8e089ebe0 --- /dev/null +++ b/tools/field-effect-sheets/host/main.lua @@ -0,0 +1,95 @@ +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(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={} + 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)