Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/native-field-effect-sheets.md
Original file line number Diff line number Diff line change
@@ -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.
67 changes: 67 additions & 0 deletions src/compat/gen1/FieldEffectCache.lua
Original file line number Diff line number Diff line change
@@ -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)<math.huge,'nonfinite field metadata');return v end
if kind=='string'then assert(#v<=4096,'field metadata string limit');return v end
if kind=='boolean'or kind=='nil'then return v end
assert(kind=='table'and depth<24 and not state.seen[v],'invalid field metadata table')
assert(getmetatable(v)==nil,'field metadata metatable unavailable')
state.seen[v]=true;local out={}
for k,x in pairs(v)do
state.nodes=state.nodes+1;assert(state.nodes<=65536,'field metadata node limit')
assert(type(k)=='string'and#k<=256 or integer(k,-65536,65536),'invalid field metadata key')
out[k]=copy(x,state,depth+1)
end
state.seen[v]=nil;return out
end
local function decode(bytes)
if type(bytes)~='string'or#bytes>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
147 changes: 147 additions & 0 deletions src/compat/gen1/FieldEffectRuntime.lua
Original file line number Diff line number Diff line change
@@ -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.name<b.name end)
return manifest
end
function R:record(name)
if type(name)~='string'or#name>80 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
33 changes: 33 additions & 0 deletions src/core/game3/field_effects.lua
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions src/render/Gen3FieldEffectCatalog.lua
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions tests/engine/native_field_effect_sheets.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dofile('tools/field-effect-sheets/check.lua')
Loading