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
88 changes: 88 additions & 0 deletions LICENSE-ROXAS2712.md
Original file line number Diff line number Diff line change
@@ -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.
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
Loading