diff --git a/LICENSE-ROXAS2712.md b/LICENSE-ROXAS2712.md new file mode 100644 index 000000000..fbcffd47c --- /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, Gen1Recomp / BOIS CLUB GAMES +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-atlas-compatibility.md b/docs/native-atlas-compatibility.md new file mode 100644 index 000000000..0b4e753b3 --- /dev/null +++ b/docs/native-atlas-compatibility.md @@ -0,0 +1,70 @@ +# Native atlas resources for unchanged GBA mods + +`src.core.game3.tileset_native` projects imported native tileset banks into the +atlas resources expected by existing GBA mods. Mod manifests and assets do not +need rewriting. The projection owns its images, image data and quads; it never +lends a native renderer sheet to a consumer that can recolor or release it. + +The pixel model retains the source atlas's 16-column slot layout, transparent +upper plane, source BGR555 rounding, primary color-zero clearing and reserved +background palette rules. Imported original palette words are retained beside +the existing RGB data. Custom RGB remains exact. Palette changes only revisit +metatiles using the changed slots; tile changes only invalidate affected decodes. +Indexed pixel locations remain stable during recoloring. + +Each declared tile animation observes its own period without a combined-cycle +limit. Palette programs use their original words when available. The resource +view observes `TileRenderer.animFrame()` and does not advance another terrain +clock. Uploads maintain independent dirty histories for two or three image +buffers, including switches between those modes. Flush calls remain visible to +installed mod wrappers, and existing field batches can follow image rotation. + +`asset_stream` and `warm` provide prioritized cooperative preparation, +cancellation and teardown. Explicit `get` may finish a pending request, as the +consumer contract requires. Atlas leases preserve retired resources until the +last borrower releases them. Queue failures retain diagnostics and clean up +partial allocations; replacing public cache entries also retires unreferenced +owned resources. Loader cleanup runs after mod wrappers unwind. + +## Verification + +Integration prerequisites: terrain palette decoding/import (#259), semantic +pair identities (#250), early native session resolution (#254), and the map +fixture/read-view foundations from #251 with its dependencies. Five-edition +tests also require the separate cartridge registrations (#183, #186, #187). +Loader cleanup overlaps those integration branches and should retain both +map/player retirement and the resource retirement added here. + +From the repository root, with LuaJIT: + +``` +luajit tools/native-atlas/pixels-check.lua +luajit tools/native-atlas/palette-check.lua +luajit tools/native-atlas/resources-check.lua +luajit tools/native-atlas/warm-check.lua +luajit tests/run_engine.lua +luajit tests/run_modkit.lua +``` + +The synthetic pixel goldens come from the independent source compositor, +including its palette merge operation. The LÖVE host in +`tools/native-atlas/host` exercises real texture uploads and readbacks; it +requires a fresh QA identity, the requested graphics backend, and explicit +`NATIVE_ATLAS_ENGINE` / `NATIVE_ATLAS_OUT` paths. The cartridge comparison host +hashes normalized planes per metatile, without writing cartridge artwork. +Private cartridge inputs and player data are excluded from the public tests. + +## Remaining integration + +This resource layer does not establish complete game or renderer compatibility. +The distinct session facade, map writes, full renderer and gameplay services +still need integration. A replacement draw path must retain one native terrain +clock owner. Original tile callback phase semantics and palette profiles across +all five editions still need complete auditing. Old imports without original +palette words retain their RGB; automatic cache upgrading is a separate task. +The cooperative stream does not yet implement generic worker-task protocols. + +Final GPU and cartridge receipts must identify the exact tested source snapshot; +successful pixel comparisons alone do not prove unchanged-mod startup or play. +Native QA uses separate identities, preserves player saves and keeps at least +8 GiB free disk reserve. diff --git a/docs/qa/native-foundation/static-palette-projection.md b/docs/qa/native-foundation/static-palette-projection.md new file mode 100644 index 000000000..144e3c9e7 --- /dev/null +++ b/docs/qa/native-foundation/static-palette-projection.md @@ -0,0 +1,35 @@ +# Reuse validated static atlas palette projections + +Repeated native atlas observations rebuilt the same 256 RGBA entries, BGR +tables and concatenated palette rows. Reuse the projection after comparing +all consumed RGB inputs by value, the original palette-word strings and the +palette-bank boundaries. Keep caller-visible BGR tables detached from the +cache. Same-clock nested RGB writes, changed provenance, modified row metadata, +palette programs, explicit overrides and foreign table observers retain the +full projection path. No terrain clock or upload owner changes. + +Qualification on main cbb552a8 plus the existing shader corrections: + +- 13 focused CPU groups pass: cache 1360, independent pixel goldens 1748, + palette words/programs 602, atlas resource lifetime 526, static palette + reuse 2448, samples 1018, frame cache 622, renderer 3639, indexed drawing + 7771, Gen2 queries 19, drawing 247, byte register 15433 and clock 46. +- An alternating five-trial CPU comparison of 3000 updates per edition + measures median old/new milliseconds: FireRed 118.527/71.382, + LeafGreen 121.363/71.934, Emerald 127.920/71.836, + Ruby 126.816/72.111, Sapphire 132.229/74.963. Final lower/upper RGBA + bytes equal the earlier projection in each edition. These are isolated + CPU costs; they establish no whole-scene FPS improvement. +- The benchmark is reproducible with + `luajit tools/native-atlas/palette-reuse-benchmark.lua /path/to/earlier/AtlasPixels.lua`. + The comparison baseline is 9f8cc0fb, before this projection cache. +- The actual native atlas GPU fixture passes 526 checks with 215 texture + readbacks under each of Metal and OpenGL on the integrated fork source. +- The actually offered atlas component, composed only with its published + palette decoding prerequisite c08f8011 (#259), independently passes the + cache 1360, pixel 1748 and palette-word 602 checks. Its resource/clock + prerequisites remain those listed in the atlas offer (#260). + +Receipts: focused CPU 6413d6b825, benchmark b436937911, GPU a980111c01, +offered component e49fd412c0. +Contributions by Roxas2712 under LICENSE-ROXAS2712.md; existing rights remain. diff --git a/src/compat/gen1/AtlasPaletteCache.lua b/src/compat/gen1/AtlasPaletteCache.lua new file mode 100644 index 000000000..81df52cd7 --- /dev/null +++ b/src/compat/gen1/AtlasPaletteCache.lua @@ -0,0 +1,66 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Reuse a static native palette projection only after comparing every RGB +-- input by value. Caller-visible palette tables are never the cache's owner. +local C={};C.__index=C +local function plain(value)return type(value)=='table'and getmetatable(value)==nil end +local function copyRows(rows) + local out={} + for slot=0,15 do local row={};out[slot]=row;for color=0,15 do row[color]=rows[slot][color]end end + return out +end +local function inputs(self,expected,out) + local decoder,layout=self.decoder,self.layout + local state=decoder.paletteState + if not plain(state)or not state.complete or not plain(state.primary)or not plain(state.secondary)then return false end + for _,bank in ipairs{decoder.primary,decoder.secondary}do + if not plain(bank)or not plain(bank.paletteCycles)or next(bank.paletteCycles)~=nil then return false end + end + local at=0 + for slot=0,15 do + local reserved=slot>=(layout.palettesTotal or 13) + local list=reserved and decoder.primary.palettes or(slot=0 and v<=hi and v%1==0 end +local function bytes(values) + local out={};for i=1,#values do out[i]=string.char(values[i])end;return concat(out) +end +local function le16(n)return string.char(n%256,floor(n/256)%256)end +local function word(data,index) + if type(data)~='string'then return nil end + local lo,hi=data:byte(index*2+1,index*2+2) + if hi then return lo+hi*256 end +end +local function matches(value,rgb) + return value~=nil and rgb and floor((value%32)*255/31)==rgb[1] + and floor((floor(value/32)%32)*255/31)==rgb[2] + and floor((floor(value/1024)%32)*255/31)==rgb[3] +end +function M.colors(words) + local out={} + for i=1,16 do + local word=words[i]or 0;assert(integer(word,65535),'invalid palette word') + out[i]={floor((word%32)*255/31+.5),floor((floor(word/32)%32)*255/31+.5),floor((floor(word/1024)%32)*255/31+.5)} + end + return out +end +function M.new(pair,layout,checkpoint) + for _,bank in ipairs({pair.primary,pair.secondary})do + assert(type(bank)=='table'and integer(bank.metatileCount,1024) + and type(bank.metatiles)=='string'and#bank.metatiles>=bank.metatileCount*16 + and type(bank.attributes)=='string'and#bank.attributes>=bank.metatileCount*2 + and type(bank.tiles)=='string','native atlas bank data unavailable') + for slot=1,16 do + local palette=bank.palettes and bank.palettes[slot] + assert(type(palette)=='table','native atlas palette data unavailable') + for color=1,16 do + local rgb=palette[color];assert(type(rgb)=='table','native atlas palette data unavailable') + for channel=1,3 do assert(integer(rgb[channel],255),'invalid native atlas colour')end + end + end + end + local decoder=Tiles.new(pair,layout) + local paletteCache=PaletteCache.new(decoder,layout) + local self={decoder=decoder,mids={},midToSlot={},indexed={},under={},over={},paletteOverrides={},revision=0,checkpoint=checkpoint, + background='\0\0\0\255',paletteUsers={},slotPalettes={},indexRevision=0} + local usedTiles={} + local function add(mid) + local slot=#self.mids;self.mids[slot+1]=mid;self.midToSlot[mid]=slot + for _,entry in ipairs(decoder:entries(mid))do + local set=usedTiles[entry.tile]or{};usedTiles[entry.tile]=set;set[slot]=true + end + if checkpoint and slot%16==15 then checkpoint('index')end + end + local primaryCount=math.min(pair.primary.metatileCount or 0,decoder.metatilesInPrimary) + for mid=0,primaryCount-1 do add(mid)end + for mid=0,(pair.secondary and pair.secondary.metatileCount or 0)-1 do add(decoder.metatilesInPrimary+mid)end + assert(#self.mids>0 and #self.mids<=1024,'invalid atlas metatile count') + self.cols,self.rows=M.COLS,math.max(1,math.ceil(#self.mids/M.COLS)) + self.width,self.height=self.cols*16,self.rows*16 + local function activeCycle(slot,color) + local bank=decoder:paletteBank(slot) + local selected=decoder.paletteState and decoder.paletteState[slot=cycle.first and at=(layout.palettesTotal or 13)and not cycle then + slot=0;record=decoder:paletteBank(0);cycle,frame=nil,nil;reservedBase=true + end + local palette=reservedBase and record.palettes[1]or decoder:palette(slot)or{} + local rgb=palette[color+1]or{0,0,0} + local original=record and word(record.sourcePaletteBytes,slot*16+color) + if cycle then + local at=slot*16+color + if cycle.kind=='frames'then original=cycle.sourceFrames and word(cycle.sourceFrames[frame+1],at-cycle.first) + else + local from=cycle.first+(at-cycle.first-cycle.direction*frame)%cycle.count + rgb=record.palettes[floor(from/16)+1][from%16+1] + original=word(record.sourcePaletteBytes,from) + if from%16==0 and floor(from/16)0 then old._pendingRetirement=true + else Upload.release(old);owned[old]=nil end + end + api._pairs[job.key]=result;owned[result]=true;jobs[job.key]=nil;job.co,job.ts=nil,nil + api._gen=api._gen+1;touch(job.key) + runtime:sweepRetired() + return result + end + function runtime:step(budget) + if not session()then return 0 end + local clock=love and love.timer and love.timer.getTime or os.clock + local deadline=clock()+(budget or .002);local count=0 + repeat + local chosen + for _,job in pairs(jobs)do + if not chosen or job.priority=deadline + return count + end + function runtime:cancel(name) + local canonical=resolve(name);local job=canonical and jobs[canonical] + if job then releaseJob(job);jobs[canonical]=nil;return true end + return false + end + function runtime:cancelPending() + for _,job in pairs(jobs)do releaseJob(job)end;jobs={} + end + function runtime:apply(ts,under,over) + local model=ts._model;if not model then return false end + local anyUnder,anyOver=next(under)~=nil,next(over)~=nil + local fullUnder=model.backgroundChanged + for _,info in ipairs({{under,false,ts.imageData,model.under},{over,true,ts.overImageData,model.over}})do + local dirty,isOver,target,rgba=info[1],info[2],info[3],info[4] + if not isOver and fullUnder then + local surface=Upload.data(model.width,model.height,model:rgba(false)) + target:paste(surface,0,0,0,0,model.width,model.height);surface:release();ts._dirtyUnder=nil + else + for slot in pairs(dirty)do Upload.patch(target,ts.cols,slot,rgba[slot],api.PORTABLE_PIXEL_WRITES);api.markDirty(ts,isOver,slot)end + end + end + if ts._indexRevision~=model.indexRevision then ts.slotPix={};ts._indexRevision=model.indexRevision end + ts.bgr=model.bgr + if anyUnder or anyOver or fullUnder then api.flush(ts,anyUnder or fullUnder,anyOver);return true end + return false + end + function runtime:sync(ts) + if not(ts and ts.image and not ts._retired)then return nil end + if ts._model then + local renderer=package.loaded['src.render.TileRenderer'] + local ticks=renderer and renderer.animFrame and renderer.animFrame()or 0 + local under,over=ts._model:update(ticks);self:apply(ts,under,over) + end + return ts + end + function runtime:get(name) + if not session()then return nil,'native GBA tileset data unavailable'end + local cached=api._pairs[name] + if cached and cached.image and not cached._stamp then touch(name);return cached end + local canonical,pair,key=resolve(name);if not canonical then return nil,pair end + cached=api._pairs[canonical] + if cached and cached.image and same(cached._stamp,key)then touch(canonical);return self:sync(cached)end + self:prefetch(canonical,0) + local job=jobs[canonical];if not job then return nil,'native atlas build unavailable'end + local result,why + repeat result,why=advance(job)until result~=false + if not result then return nil,why end + return self:sync(result) + end + function runtime:update() + if not session()then return end + for _,ts in pairs(api._pairs)do self:sync(ts)end + end + function runtime:drop(name) + local ts=api._pairs[name];if not ts then return false end + for key,value in pairs(api._pairs)do if value==ts then api._pairs[key]=nil;api._use[key]=nil end end + Upload.release(ts);owned[ts]=nil;return true + end + return runtime +end +return M diff --git a/src/compat/gen1/AtlasUpload.lua b/src/compat/gen1/AtlasUpload.lua new file mode 100644 index 000000000..3cbf9a8da --- /dev/null +++ b/src/compat/gen1/AtlasUpload.lua @@ -0,0 +1,107 @@ +-- Owned compatibility textures. Each upload buffer remembers every change +-- since its last use, including when a third buffer joins an existing pair. +local M={} +local ffiOK,ffi=pcall(require,'ffi') +local function release(object)if object and object.release then pcall(object.release,object)end end +function M.data(w,h,bytes)return love.image.newImageData(w,h,'rgba8',bytes)end +function M.image(data) + local image=love.graphics.newImage(data);image:setFilter('nearest','nearest');return image +end +function M.patch(data,cols,slot,rgba,portable) + local x,y=(slot%cols)*16,math.floor(slot/cols)*16 + if not portable and ffiOK and type(rgba)=='string' and #rgba==1024 and data.getFFIPointer and data:getFormat()=='rgba8' then + local pointer=ffi.cast('uint8_t*',data:getFFIPointer()) + if pointer~=nil then + local width=data:getWidth() + -- rgba stays owned by this call. Read its immutable bytes directly; + -- slicing sixteen temporary strings did not change the copied pixels. + local source=ffi.cast('const uint8_t*',rgba) + for row=0,15 do ffi.copy(pointer+((y+row)*width+x)*4,source+row*64,64)end + return + end + end + local cell=M.data(16,16,rgba);data:paste(cell,x,y,0,0,16,16);release(cell) +end +local function retarget(old,new) + if old==new then return end + local view=package.loaded['src.core.game3.field_view'];if type(view)~='table'then return end + local seen={} + local function visit(value) + if type(value)~='table'and type(value)~='userdata'then return end + if value.getTexture and value.setTexture then + if value:getTexture()==old then value:setTexture(new)end + elseif type(value)=='table'and not seen[value]then + seen[value]=true;for _,child in pairs(value)do visit(child)end + end + end + for _,key in ipairs({'_nativeBatch','_nativeBatches','_nativeOverBatches','_nativeOverByRow','_voidFrom'})do visit(view[key])end +end +local function pool(ts,over,count) + local key=over and'overImage'or'image';local data=ts[over and'overImageData'or'imageData'] + if not(ts[key]and data)then return nil end + ts._uploadBuffers=ts._uploadBuffers or{} + local state=ts._uploadBuffers[key] + if not state then state={entries={{image=ts[key],slots={}}},cursor=1};ts._uploadBuffers[key]=state end + while #state.entries=deadline then break end + end +end +function S.cancelPending() + for _,service in pairs(services)do if service.cancelPending then service:cancelPending()end end +end +function S.frameComplete()S._uploadedThisFrame=false end +function S.shutdown() + for _,service in pairs(services)do if service.release then service:release()end end +end +S.release=S.shutdown +return S diff --git a/src/core/game3/tileset_anim.lua b/src/core/game3/tileset_anim.lua new file mode 100644 index 000000000..4d1e2d35c --- /dev/null +++ b/src/core/game3/tileset_anim.lua @@ -0,0 +1,25 @@ +-- Animation observes the existing native clock. It never advances the game, +-- source scripts, RNG or a second terrain timer. +local A={_visible={}} +local function native()return require('src.core.game3.tileset_native')end +function A.install()A.invalidate()end +function A.invalidate()A._visible={};A._current=nil end +function A.enterMap(pair)A._current=pair;A._visible[pair]=true;return true end +function A.bindPair(pair,atlas)if not(pair and atlas)then return false end;A._visible[pair]=true;return true end +function A.unbindPair(pair)A._visible[pair]=nil end +function A.setVisiblePairs(pairs)A._visible=pairs or{}end +function A.step()native().update()end +A.stepRse=A.step +A.release=A.invalidate +return setmetatable(A,{__index=function(_,key) + if key=='_pairs'then + -- Resident native animation owners, observed without loading an atlas or + -- advancing a decoder. Authored legacy RGBA banks are a separate format; + -- do not invent those when only native VRAM animation programs exist. + local out={};local rse=not require('src.core.GameVersion').isFRLG() + for pair,atlas in pairs(native().animationPairs())do + if atlas.image and not atlas._retired and atlas._model then out[pair]={native=true,rse=rse}end + end + return out + end +end}) diff --git a/src/core/game3/tileset_native.lua b/src/core/game3/tileset_native.lua new file mode 100644 index 000000000..f13b1da10 --- /dev/null +++ b/src/core/game3/tileset_native.lua @@ -0,0 +1,130 @@ +-- Source atlas contract backed directly by native imported banks. Projected +-- images are owned here, so palette/animation uploads cannot mutate a native +-- renderer's borrowed sheet or be evicted by its separate LRU. +local Upload=require('src.compat.gen1.AtlasUpload') +local Native={_pairs={},_use={},_tick=0,_gen=0,RESIDENT_MAX=4,MAX_UPLOAD_BUFFERS=3,COALESCED_ROW_UPLOADS=true} +local runtime=require('src.compat.gen1.AtlasRuntime').new(Native) +require('src.core.game3.asset_stream').register(runtime) +local function integer(n,hi)return type(n)=='number'and n>=0 and n<=hi and n%1==0 end +function Native.install(cache) + runtime:release();Native._cache=cache +end +local function resetAnimation() + local animation=package.loaded['src.core.game3.tileset_anim'] + if animation and animation.invalidate then animation.invalidate()end +end +function Native.invalidate()runtime:release();resetAnimation()end +function Native.release()runtime:release();Native._cache=nil;resetAnimation()end +function Native.ready(pair)return type(pair)=='string'and runtime:ready(pair)or false end +function Native.animationPairs()return runtime:observePairs()end +function Native.prefetch(pair,priority) + if type(pair)~='string'then return false end + return runtime:prefetch(pair,priority) +end +function Native.get(pair) + if type(pair)~='string'then return nil,'atlas pair must be a string'end + return runtime:get(pair) +end +local function atlas(pairOrTs) + if type(pairOrTs)=='table'then return pairOrTs.image and not pairOrTs._retired and pairOrTs or nil end + return Native.get(pairOrTs) +end +function Native.slotFor(pairOrTs,mid) + local ts=atlas(pairOrTs) + return ts and(ts.midToSlot[mid]or ts.midToSlot[0])or 0 +end +function Native.hasMid(pairOrTs,mid) + local ts=atlas(pairOrTs);return ts~=nil and ts.midToSlot[mid]~=nil +end +local function quad(pairOrTs,slot,over) + local ts=atlas(pairOrTs);if not ts then return nil end + slot=tonumber(slot)or 0 + if not integer(slot,(ts.cols*ts.rows)-1)then return nil end + local image=ts[over and'overImage'or'image'];if not image then return nil end + local list=ts[over and'overQuads'or'quads'] + if not list[slot]then list[slot]=love.graphics.newQuad(slot%ts.cols*16,math.floor(slot/ts.cols)*16,16,16,image:getDimensions())end + return list[slot] +end +function Native.quad(pairOrTs,slot)return quad(pairOrTs,slot,false)end +function Native.overQuad(pairOrTs,slot)return quad(pairOrTs,slot,true)end +function Native.markDirty(ts,over,slot) + if not integer(slot,ts.cols*ts.rows-1)then return false end + local key=over and'_dirtyOver'or'_dirtyUnder';ts[key]=ts[key]or{};ts[key][slot]=true;return true +end +function Native.flush(ts,under,over,count,coalesced) + if not(ts and ts.image and not ts._retired)then return false end + count=count==3 and 3 or 2 + if under then Upload.flushPlane(ts,false,count,coalesced~=false)end + if over then Upload.flushPlane(ts,true,count,coalesced~=false)end + if under or over then ts.revision=(ts.revision or 0)+1 end + return under or over or false +end +function Native.ensureAlt(ts)if ts and not ts._retired then Upload.ensureAlt(ts,2)end end +function Native.prepareSlot(ts,slot) + if not(ts and ts._model and integer(slot,15))then return {under={},over={}}end + local entry=ts.slotPix[slot] + if not entry then entry={under=ts._model:palettePixels(slot,false),over=ts._model:palettePixels(slot,true)};ts.slotPix[slot]=entry end + return entry +end +function Native.setSlotPalette(pairOrTs,slot,words) + local ts=atlas(pairOrTs) + if not(ts and ts._model and integer(slot,15)and type(words)=='table')then return false end + local under,over=ts._model:setPalette(slot,words) + ts.patchedSlots=ts.patchedSlots or{};ts.patchedSlots[slot]=true + runtime:apply(ts,under,over);return true +end +function Native.resetSlotPalette(pairOrTs,slot) + local ts=type(pairOrTs)=='table'and pairOrTs or Native._pairs[pairOrTs] + if not(ts and ts._model and ts.patchedSlots and ts.patchedSlots[slot])then return false end + local under,over=ts._model:setPalette(slot,nil);ts.patchedSlots[slot]=nil + runtime:apply(ts,under,over);return true +end +function Native.resident() + local seen,n={},0 + for _,ts in pairs(Native._pairs)do if ts.image and not seen[ts]then n=n+1;seen[ts]=true end end + return n +end +function Native.acquire(pair) + local ts,why=Native.get(pair);if not ts then return nil,why end + ts._leases=(ts._leases or 0)+1 + local released=false + return {atlas=ts,release=function() + if not released then ts._leases=math.max(0,(ts._leases or 1)-1);released=true;runtime:sweepRetired()end + end} +end +function Native.trim(keep,max,busy) + keep,max,busy=keep or{},max or Native.RESIDENT_MAX,busy or{} + local held,order={},{} + for key,ts in pairs(Native._pairs)do + if keep[key]or(ts._leases or 0)>0 then held[ts]=true end + for _,name in ipairs({'image','overImage','imageAlt','overImageAlt'})do if ts[name]and busy[ts[name]]then held[ts]=true end end + for _,state in pairs(ts._uploadBuffers or{})do + for _,entry in ipairs(state.entries)do if busy[entry.image]then held[ts]=true end end + end + order[#order+1]=key + end + table.sort(order,function(a,b)return(Native._use[a]or 0)<(Native._use[b]or 0)end) + local out={};local n=Native.resident() + for _,key in ipairs(order)do + if n<=max then break end + local ts=Native._pairs[key] + if ts and not held[ts]and runtime:drop(key)then out[#out+1]=key;n=n-1 end + end + return out +end +function Native.update()runtime:update()end +-- Preserve the public pending/prefetch/get/cancel/retain observer surface. +local stream={} +function stream:prefetch(pair,priority)return Native.prefetch(pair,priority)end +function stream:get(pair)return Native.get(pair)end +function stream:cancel()runtime:cancelPending()end +function stream:decoded(pair) + local job=runtime:pending()[pair];return job~=nil and job.ts~=nil +end +function stream:retain(wanted) + local keys={};for key in pairs(runtime:pending())do if not wanted[key]then keys[#keys+1]=key end end + for _,key in ipairs(keys)do runtime:cancel(key)end +end +setmetatable(stream,{__index=function(_,key)if key=='pending'then return runtime:pending()end end}) +Native._stream=stream +return Native diff --git a/src/core/game3/warm.lua b/src/core/game3/warm.lua new file mode 100644 index 000000000..4d3158868 --- /dev/null +++ b/src/core/game3/warm.lua @@ -0,0 +1,55 @@ +-- Cooperative source warm requests. Work retains its full result/geometry; +-- yielding spends another slice rather than lowering quality or dropping it. +local W={BUDGET=.002,errors={}} +local jobs,serial,active={},0,nil +local function clock()return love and love.timer and love.timer.getTime and love.timer.getTime()or os.clock()end +local function nextJob() + local selected + for _,job in pairs(jobs)do + if not selected or job.priority=deadline +end +function W.flush(key) + local job=jobs[key] + if not job or job.co==coroutine.running()then return false end + job.flushing=true + while jobs[key]==job do advance(job)end + return true +end +function W.yield() + if active and not active.flushing and coroutine.running()==active.co then coroutine.yield()end +end +function W.require(names,priority) + for _,name in ipairs(names)do + if not package.loaded[name]then W.add('mod:'..name,function()require(name)end,priority or 8)end + end +end +function W.reset() + local retired=jobs;jobs={};active=nil;W.errors={} + for _,job in pairs(retired)do if job.cancel then pcall(job.cancel)end end +end +W.release=W.reset +return W diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..d8ff4dc0b 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1309,12 +1309,23 @@ local function stampAudioOwners(data, name, registry) owners[key] = map end +-- Release only already-loaded compatibility resources, after mod wrappers +-- unwind. These scopes own their buffers and never borrow native sheets. +local function retireCompatibilityAtlases() + for _, name in ipairs({"src.core.game3.warm", "src.core.game3.asset_stream", + "src.core.game3.tileset_native", "src.core.game3.tileset_anim"}) do + local module = package.loaded[name] + if type(module) == "table" and type(module.release) == "function" then module.release() end + end +end + function Loader:load(data) -- A SECOND LOAD IN ONE PROCESS STARTS FROM THE ENGINE, NOT FROM THE LAST -- LOAD'S MODS. EXIT GAME -> launcher -> Play runs every mod's main.lua -- again (and the launcher backdrop boots the plain game in between); the -- wraps and flags the previous load put on engine tables come off first. ModSeams.unwindAll() + retireCompatibilityAtlases() 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 +1665,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()) + retireCompatibilityAtlases() pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) Runtime.currentMod = nil diff --git a/src/render/Gen3AnimationFrameSlices.lua b/src/render/Gen3AnimationFrameSlices.lua new file mode 100644 index 000000000..bff1c9b58 --- /dev/null +++ b/src/render/Gen3AnimationFrameSlices.lua @@ -0,0 +1,28 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Retain only the currently selected immutable frame for each animation. +-- Placement, cadence, ordering and dirty-tile detection stay with the decoder. +local M = {} + +function M.get(cache, index, frame, count) + local prior = cache[index] + if prior and prior.frame == frame and prior.count == count then + return prior.pieces, true + end + local pieces = {} + for tile = 0, count - 1 do + local offset = tile * 32 + if offset + 32 <= #frame then + pieces[tile + 1] = frame:sub(offset + 1, offset + 32) + end + end + cache[index] = { frame = frame, count = count, pieces = pieces } + return pieces, false +end + +function M.trim(cache, count) + for index in pairs(cache) do + if index > count then cache[index] = nil end + end +end + +return M diff --git a/src/render/Gen3Tiles.lua b/src/render/Gen3Tiles.lua index 1a59d1898..1f6e303d4 100644 --- a/src/render/Gen3Tiles.lua +++ b/src/render/Gen3Tiles.lua @@ -35,6 +35,7 @@ local Gen3Tiles = {} Gen3Tiles.__index = Gen3Tiles +local AnimationFrameSlices = require('src.render.Gen3AnimationFrameSlices') -- Defaults matching Emerald, overridden from the manifest's layout block. local DEFAULTS = { @@ -289,6 +290,48 @@ function Gen3Tiles:setAnimFrame(f) self.tileCache = {} end +-- Observe independent VRAM animation clocks without pre-baking a combined +-- cycle. A projection can keep fixed atlas slots even when animation periods +-- have a large LCM. Only changed tile decodes are invalidated; native shared +-- bank bytes and the renderer's legacy frame-selection API stay untouched. +function Gen3Tiles:setAnimationTicks(ticks) + assert(type(ticks) == "number" and ticks >= 0 and ticks % 1 == 0 + and ticks <= 9007199254740991, "invalid terrain animation ticks") + local nextOverrides = {} + -- Immutable frame slices are owned by this decoder. Every call still + -- selects live metadata and compares overrides, including external writes. + local cached = self.reuseAnimationSlices ~= false + local cache = cached and (self._animationFrameSlices or {}) + self._animationFrameSlices = cache or nil + local animations = self:animations() + for index, a in ipairs(animations) do + local frame = a.frames[(math.floor(ticks / math.max(1, a.step)) % #a.frames) + 1] + if type(frame) == "string" then + if cached then + local pieces = AnimationFrameSlices.get(cache, index, frame, a.count) + for tile, bytes in ipairs(pieces) do nextOverrides[a.tile + (tile - 1)] = bytes end + else + for k = 0, a.count - 1 do + local at = k * 32 + if at + 32 <= #frame then nextOverrides[a.tile + k] = frame:sub(at + 1, at + 32) end + end + end + elseif cache then + cache[index] = nil + end + end + if cache then AnimationFrameSlices.trim(cache, #animations) end + local previous, dirty = self.tileOverride or {}, {} + for tile, bytes in pairs(nextOverrides) do + if previous[tile] ~= bytes then dirty[tile] = true; self.tileCache[tile] = nil end + end + for tile in pairs(previous) do + if nextOverrides[tile] == nil then dirty[tile] = true; self.tileCache[tile] = nil end + end + self.tileOverride, self.animationTicks = nextOverrides, ticks + return dirty +end + -- Which metatiles change when the frame does -- the only ones a sheet has to -- hold more than one picture of. function Gen3Tiles:animatedMetatiles() diff --git a/tests/engine/native_atlas_palette_cache.lua b/tests/engine/native_atlas_palette_cache.lua new file mode 100644 index 000000000..bc9826790 --- /dev/null +++ b/tests/engine/native_atlas_palette_cache.lua @@ -0,0 +1,51 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +love=require('tests.love_stub') +local T=require('tests.harness') +local Cache=require('src.compat.gen1.AtlasPaletteCache') +local Pixels=require('src.compat.gen1.AtlasPixels') +local Fixture=require('tools.native-atlas.fixture') +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + local f=Fixture.new(edition);local model=Pixels.new(f.pair,f.layout) + local d=model.decoder;local cache=Cache.new(d,f.layout) + local lut={};for i=0,255 do lut[i]=model.paletteKey:sub(i*4+1,i*4+4)end + local function seed()cache:remember({},model.paletteRows,lut,model.bgr,model.paletteKey)end + seed() + local out,bgr,key,changed=cache:read({},model.paletteRows) + T.check(out~=nil,'validated static palettes can reuse their exact projection') + T.eq(key,model.paletteKey);T.eq(next(changed),nil) + for i=0,255 do T.eq(out[i],lut[i],'all projected RGBA bytes remain exact')end + local held=bgr[0][1];bgr[0][1]=-1 + local _,fresh=cache:read({},model.paletteRows) + T.eq(fresh[0][1],held,'consumer mutation cannot poison the retained BGR projection') + local before=model.bgr[0][1];model.bgr[0][1]=-2 + local _,fresh=cache:read({},model.paletteRows) + T.eq(fresh[0][1],held,'mutating a previously published BGR table cannot poison the cache') + model.bgr[0][1]=before + local rgb=d:palette(0)[2];local value=rgb[1];local oldKey=d.paletteState.contentKey + rgb[1]=(value+1)%256 + T.eq(cache:read({},model.paletteRows),nil,'same-clock nested RGB writes invalidate reuse') + T.eq(d.paletteState.contentKey,oldKey,'mutation test retains the previous decoder content key') + rgb[1]=value;T.check(cache:read({},model.paletteRows)~=nil,'restored bytes permit exact reuse') + local bytes=f.pair.primary.sourcePaletteBytes + f.pair.primary.sourcePaletteBytes=bytes:sub(1,3)..string.char((bytes:byte(4)+128)%256)..bytes:sub(5) + T.eq(cache:read({},model.paletteRows),nil,'raw word provenance invalidates even when RGB is identical') + f.pair.primary.sourcePaletteBytes=bytes + T.eq(cache:read({[0]={{255,0,0}}},model.paletteRows),nil,'explicit palette ownership retains the full projection path') + local total=f.layout.palettesTotal;f.layout.palettesTotal=12 + T.eq(cache:read({},model.paletteRows),nil,'reserved bank boundary changes invalidate reuse') + f.layout.palettesTotal=total + local row=model.paletteRows[0];model.paletteRows[0]='changed' + T.eq(cache:read({},model.paletteRows),nil,'changed public row metadata is reconciled through the full path') + model.paletteRows[0]=row + local prior=f.pair.secondary.paletteCycles + f.pair.secondary.paletteCycles={{kind='rotate',first=128,count=2,step=1,direction=1}} + T.eq(cache:read({},model.paletteRows),nil,'animated banks keep their independent palette cadence') + f.pair.secondary.paletteCycles=prior + setmetatable(rgb,{}) + T.eq(cache:read({},model.paletteRows),nil,'foreign palette observers retain the conservative path') + setmetatable(rgb,nil) + T.check(cache:read({},model.paletteRows)~=nil) + T.eq(d.paletteState.ticks,0,'projection observation does not advance native time') +end +T.finish('static native palette projection, mutable RGB/provenance and independent palette owners') diff --git a/tests/engine/native_atlas_palettes.lua b/tests/engine/native_atlas_palettes.lua new file mode 100644 index 000000000..75d1cb719 --- /dev/null +++ b/tests/engine/native_atlas_palettes.lua @@ -0,0 +1 @@ +dofile('tools/native-atlas/palette-check.lua') diff --git a/tests/engine/native_atlas_pixels.lua b/tests/engine/native_atlas_pixels.lua new file mode 100644 index 000000000..1c12822e4 --- /dev/null +++ b/tests/engine/native_atlas_pixels.lua @@ -0,0 +1 @@ +dofile('tools/native-atlas/pixels-check.lua') diff --git a/tests/engine/native_atlas_resources.lua b/tests/engine/native_atlas_resources.lua new file mode 100644 index 000000000..1396310a6 --- /dev/null +++ b/tests/engine/native_atlas_resources.lua @@ -0,0 +1 @@ +dofile('tools/native-atlas/resources-check.lua') diff --git a/tests/engine/native_atlas_row_copy.lua b/tests/engine/native_atlas_row_copy.lua new file mode 100644 index 000000000..1b942dbcd --- /dev/null +++ b/tests/engine/native_atlas_row_copy.lua @@ -0,0 +1,57 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local ffi=require('ffi') +local Upload=require('src.compat.gen1.AtlasUpload') +local checks=0 +local function check(value,label)checks=checks+1;assert(value,label)end +local function pixels(seed) + local bytes={};for i=1,1024 do bytes[i]=string.char((i*37+seed*53)%256)end + return table.concat(bytes) +end +local n=0 +for _,cols in ipairs{1,2,5,16}do for _,rows in ipairs{1,3}do + local width,height=cols*16,rows*16;local total=width*height*4 + for slot=0,cols*rows-1 do + n=n+1 + local memory=ffi.new('uint8_t[?]',total+64);ffi.fill(memory,total+64,193) + local data={getWidth=function()return width end,getFormat=function()return'rgba8'end, + getFFIPointer=function()return memory+32 end} + local rgba=pixels(n) + Upload.patch(data,cols,slot,rgba,false) + -- Construct independent complete image rows, including both guard regions. + local expected={string.rep(string.char(193),32)} + local x,y=slot%cols*16,math.floor(slot/cols)*16 + for row=0,height-1 do + if row>=y and row=math.floor(a.count)then break end + out[a.tile+tile]=chunk;tile=tile+1 + end + end + end + return out +end +local function equal(a,b,label) + for k,v in pairs(a)do T.eq(b[k],v,label)end + for k,v in pairs(b)do T.eq(a[k],v,label)end +end +local fast,slow=decoder(true),decoder(false) +for iteration=1,1500 do + local tick=iteration%17==0 and 9007199254740991 or(iteration*97)%4096 + if iteration%13==0 then rows[1].tile=iteration%9-3 end + if iteration%19==0 then rows[1].count=iteration%7 end + if iteration%23==0 then rows[1].step=iteration%21-2 end + if iteration%29==0 then rows[1].frames[1]=frame(iteration%256,iteration%6,iteration%32)end + if iteration%31==0 then rows[2].frames[2]=false end + if iteration%37==0 then rows[2].frames[2]=frame((iteration+11)%256,3,17)end + if iteration%41==0 then rows[1],rows[2]=rows[2],rows[1]end + if iteration%43==0 then rows[3]={tile=iteration%8,count=2,step=3,frames={frame(6,2),frame(7,2)}}end + if iteration%47==0 then rows[3]=nil end + if iteration%11==0 then + -- Publicly visible override tables may be edited between observations. + fast.tileOverride[999]='foreign';slow.tileOverride[999]='foreign' + fast.tileCache[999]={};slow.tileCache[999]={} + end + local old={};for k,v in pairs(fast.tileOverride)do old[k]=v end + local published=fast.tileOverride + local want=expected(tick);local dirty={} + for k,v in pairs(want)do if old[k]~=v then dirty[k]=true end end + for k in pairs(old)do if want[k]==nil then dirty[k]=true end end + local marker={} + for k in pairs(want)do fast.tileCache[k]=marker end + for k in pairs(old)do fast.tileCache[k]=marker end + local actual=fast:setAnimationTicks(tick);local reference=slow:setAnimationTicks(tick) + equal(fast.tileOverride,want,'selected bytes, overlap order and partial frame') + equal(actual,dirty,'dirty set after source or external override changes') + equal(actual,reference,'reference switch retains the old invalidation path') + equal(fast.tileOverride,slow.tileOverride,'cached and uncached decoder values') + equal(published,old,'published overrides stay owned by their original observation') + for k in pairs(want)do + local expectedCache=marker;if dirty[k]then expectedCache=nil end + T.eq(fast.tileCache[k],expectedCache,'decode invalidation') + end + T.eq(fast.animationTicks,tick,'native clock observation stays exact') + for index in pairs(fast._animationFrameSlices)do T.check(index<=#rows,'removed animations retain no cache row')end +end +fast.reuseAnimationSlices=false;fast:setAnimationTicks(100) +T.eq(fast._animationFrameSlices,nil,'reference switch releases retained frame slices') +local Slice=require('src.render.Gen3AnimationFrameSlices');local cache={} +local bytes=frame(0,2,31);local a,hit=Slice.get(cache,1,bytes,3) +T.check(not hit and #a==2,'only complete native tiles are retained') +local b,reused=Slice.get(cache,1,bytes,3);T.check(reused and a==b,'same immutable selection reuses its slices') +local c,changed=Slice.get(cache,1,bytes,1);T.check(not changed and #c==1,'live count changes invalidate the selection') +Slice.trim(cache,0);T.eq(next(cache),nil,'removed owners release frame references') +T.finish('native animation slices, independent bytes, live metadata, dirty sets and reference ownership') diff --git a/tools/native-atlas/animation-slices-benchmark.lua b/tools/native-atlas/animation-slices-benchmark.lua new file mode 100644 index 000000000..2ba561393 --- /dev/null +++ b/tools/native-atlas/animation-slices-benchmark.lua @@ -0,0 +1,31 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local Tiles=require('src.render.Gen3Tiles') +local rows={} +for i=1,2 do + local frames={} + for f=1,4 do frames[f]=string.rep(string.char(i*16+f),32*32)end + rows[i]={tile=(i-1)*32,count=32,step=16,frames=frames} +end +local function decoder(on) + return setmetatable({tileOverride={},tileCache={},reuseAnimationSlices=on, + animations=function()return rows end},{__index=Tiles}) +end +for _,on in ipairs{false,true}do + local d=decoder(on);for tick=1,3000 do d:setAnimationTicks(tick)end +end +local times,checks={},{ }; +for index,on in ipairs{false,true,true,false}do + local d=decoder(on);for tick=1,800 do d:setAnimationTicks(tick)end + collectgarbage('collect') + local before=os.clock();local checksum=0 + for tick=1,24000 do + local dirty=d:setAnimationTicks(tick);local n=0;for _ in pairs(dirty)do n=n+1 end + checksum=(checksum*33+d.tileOverride[0]:byte(1)+n)%65521 + end + times[index]=os.clock()-before;checks[index]=checksum + print('ANIMATION_SLICES_CPU_LEG',index,on,times[index],checksum) +end +assert(checks[1]==checks[2]and checks[2]==checks[3]and checks[3]==checks[4],'native outputs differ') +print('ANIMATION_SLICES_CPU_RATIO',((times[1]+times[4])/2)/((times[2]+times[3])/2)) +print('MICROBENCHMARK_ONLY_NOT_GAME_FPS') diff --git a/tools/native-atlas/cpu-gpu.lua b/tools/native-atlas/cpu-gpu.lua new file mode 100644 index 000000000..3b82bdb1b --- /dev/null +++ b/tools/native-atlas/cpu-gpu.lua @@ -0,0 +1,45 @@ +-- Memory-backed objects for lifecycle/queue tests. Actual GPU texture bytes +-- are checked separately with LÖVE readback on each graphics backend. +local M={} +function M.install(love) + local Data,Image={},{};Data.__index=Data;Image.__index=Image + local created={} + local function alive(self)assert(not self.dead,'released graphics object')end + function Data:getWidth()alive(self);return self.w end + function Data:getHeight()alive(self);return self.h end + function Data:getDimensions()return self:getWidth(),self:getHeight()end + function Data:getFormat()return'rgba8'end + function Data:getString()alive(self);return self.bytes end + function Data:release()alive(self);self.dead=true end + function Data:paste(other,dx,dy,sx,sy,w,h) + alive(self);alive(other) + for y=0,h-1 do + local target=((dy+y)*self.w+dx)*4 + local start=((sy+y)*other.w+sx)*4 + self.bytes=self.bytes:sub(1,target)..other.bytes:sub(start+1,start+w*4)..self.bytes:sub(target+w*4+1) + end + end + local function data(w,h,format,raw) + local object=setmetatable({w=w,h=h,bytes=raw or string.rep('\0',w*h*4)},Data) + assert(#object.bytes==w*h*4);created[#created+1]=object;return object + end + function Image:getDimensions()alive(self);return self.data:getDimensions()end + function Image:getWidth()alive(self);return self.data:getWidth()end + function Image:getHeight()alive(self);return self.data:getHeight()end + function Image:setFilter()alive(self)end + function Image:release()alive(self);self.dead=true;self.data:release()end + function Image:replacePixels(source,_,_,x,y) + alive(self) + if not x then self.data.bytes=source:getString() + else self.data:paste(source,x,y,0,0,source:getDimensions())end + end + local original=love.graphics.newImage + love.image={newImageData=data} + love.graphics.newImage=function(source) + if type(source)~='table'or not source.getString then return original(source)end + local copy=data(source:getWidth(),source:getHeight(),'rgba8',source:getString()) + local object=setmetatable({data=copy},Image);created[#created+1]=object;return object + end + return function(image)return image.data:getString()end,created +end +return M diff --git a/tools/native-atlas/fixture.lua b/tools/native-atlas/fixture.lua new file mode 100644 index 000000000..63cd25f6c --- /dev/null +++ b/tools/native-atlas/fixture.lua @@ -0,0 +1,65 @@ +-- Synthetic banks shared with an independent source-format pixel oracle. +local F={} +local floor=math.floor +local function le(n,bytes)local out={}for i=1,bytes do out[i]=string.char(n%256);n=floor(n/256)end;return table.concat(out)end +function F.new(edition) + local fr=edition=='firered'or edition=='leafgreen' + local seed=({firered=1,leafgreen=2,emerald=3,ruby=4,sapphire=5})[edition] + local layout={tilesInPrimary=fr and 640 or 512,metatilesInPrimary=fr and 640 or 512,palettesInPrimary=fr and 7 or 6,metatileLayers=2} + local function bank(secondary,count) + local tiles,metatiles,attributes,source,paletteBytes,palettes={},{},{},{},{},{} + for tile=0,31 do for y=0,7 do for x=0,7,2 do + local a=(tile*3+x+y*5+seed)%16;local b=(tile*3+x+1+y*5+seed)%16 + tiles[#tiles+1]=string.char(a+b*16) + end end end + for mid=0,count-1 do + for q=0,7 do + local tile=(mid+q*7)%32+(q%2==1 and layout.tilesInPrimary or 0) + local flip=(mid+q)%4*1024 + local palette=(mid*3+q+seed)%16 + metatiles[#metatiles+1]=le(tile+flip+palette*4096,2) + end + local layer=mid%4;attributes[#attributes+1]=le(layer*4096,2) + source[#source+1]=le(fr and layer*0x20000000 or layer*4096,fr and 4 or 2) + end + for slot=0,15 do + local row={} + for c=0,15 do + local offset=secondary and 7 or 0 + local r,g,b=(slot+c+seed+offset)%32,(slot*3+c*5+seed)%32,(slot*7+c+offset)%32 + paletteBytes[#paletteBytes+1]=le(r+g*32+b*1024,2) + row[c+1]={floor(r*255/31),floor(g*255/31),floor(b*255/31)} + end + palettes[slot+1]=row + end + return {tiles=table.concat(tiles),tileCount=32,metatiles=table.concat(metatiles),metatileCount=count, + attributes=table.concat(attributes),sourceAttributes=table.concat(source),sourceAttributeBytes=fr and 4 or 2, + palettes=palettes,sourcePaletteBytes=table.concat(paletteBytes),paletteCycles={},animations={},secondary=secondary} + end + local pair={primary=bank(false,19),secondary=bank(true,12)} + local function part(record,kind) + if kind=='tiles'then return {raw=record.tiles,count=record.tileCount}end + return {data=kind=='mt'and record.metatiles or record.sourceAttributes,count=record.metatileCount} + end + local bundle={primaryTiles=part(pair.primary,'tiles'),secondaryTiles=part(pair.secondary,'tiles'), + primaryMt=part(pair.primary,'mt'),secondaryMt=part(pair.secondary,'mt'), + primaryAttr=part(pair.primary,'attr'),secondaryAttr=part(pair.secondary,'attr')} + local mids,palettes={},{} + local bankPalettes={primary={},secondary={}} + for name,record in pairs(pair)do + for slot=0,15 do + local row={} + for c=0,15 do local at=(slot*16+c)*2+1;row[c]=record.sourcePaletteBytes:byte(at)+record.sourcePaletteBytes:byte(at+1)*256 end + bankPalettes[name][slot]=row + end + end + for mid=0,18 do mids[#mids+1]=mid end + for mid=0,11 do mids[#mids+1]=layout.metatilesInPrimary+mid end + for slot=0,15 do + local record=slotrevision) + local repeatRevision=p.revision;p:update(15);T.eq(p.revision,repeatRevision) +end +T.finish('native atlas pixel projection') diff --git a/tools/native-atlas/resources-check.lua b/tools/native-atlas/resources-check.lua new file mode 100644 index 000000000..4cb0e7151 --- /dev/null +++ b/tools/native-atlas/resources-check.lua @@ -0,0 +1,6 @@ +package.path='./?.lua;./?/init.lua;'..package.path +love=require('tests.love_stub') +local read=require('tools.native-atlas.cpu-gpu').install(love) +local T=require('tests.harness') +require('tools.native-atlas.resources').run(T,read) +T.finish('native atlas resource lifecycle') diff --git a/tools/native-atlas/resources.lua b/tools/native-atlas/resources.lua new file mode 100644 index 000000000..362e0a5e0 --- /dev/null +++ b/tools/native-atlas/resources.lua @@ -0,0 +1,154 @@ +local C={} +function C.run(T,readImage) + local Native=require('src.core.game3.tileset_native') + local Stream=require('src.core.game3.asset_stream') + local Upload=require('src.compat.gen1.AtlasUpload') + local Pixels=require('src.compat.gen1.AtlasPixels') + local F=require('tools.native-atlas.session-fixture') + local renderer=require('src.render.TileRenderer') + local previousGame=package.loaded['src.core.Game'] + local previousView=package.loaded['src.core.game3.field_view'] + local function equalImage(ts,over,message) + T.eq(readImage(ts[over and'overImage'or'image']),ts[over and'overImageData'or'imageData']:getString(),message) + end + local function dead(object) + return not pcall(function()return object:getWidth()end) + end + for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'})do + Native.release() + local f=F.new(edition) + T.eq(Native.ready(f.pair),true,'native atlas can warm before an avatar is spawned') + T.eq(f.world.player,nil) + T.eq(Native.prefetch(f.pair,4),true);T.eq(Native.resident(),0) + local steps=0 + while Native._stream.pending[f.pair]do Stream.update();steps=steps+1;assert(steps<100,'atlas warming did not finish')end + T.check(steps>1,'CPU and GPU preparation yield between resource stages') + local ts=assert(Native.get(f.pair));T.eq(Native.get(f.pair),ts) + local animation=require('src.core.game3.tileset_anim') + local clock=renderer.animFrame() + T.eq(animation._pairs[f.pair].native,true,'animation census observes the actual resident native atlas') + T.eq(animation._pairs[f.pair].rse,edition~='firered'and edition~='leafgreen') + T.eq(renderer.animFrame(),clock,'reading animation ownership never advances the native clock') + local observed=animation._pairs;observed[f.pair].rse='mutated' + T.eq(animation._pairs[f.pair].rse,edition~='firered'and edition~='leafgreen','source observation is detached') + T.eq(ts.imageData:getWidth(),256);T.eq(ts.imageData:getHeight(),32) + local expected=Pixels.new(f.picture.pair,f.picture.layout);expected:update(renderer.animFrame()) + T.eq(ts.imageData:getString(),expected:rgba(false));T.eq(ts.overImageData:getString(),expected:rgba(true)) + equalImage(ts,false,'initial native under texture matches CPU bytes') + equalImage(ts,true,'initial native over texture matches CPU bytes') + T.eq(Native.hasMid(ts,19),false);T.eq(Native.slotFor(ts,19),0) + T.eq(Native.slotFor(ts,f.picture.layout.metatilesInPrimary),19) + T.check(Native.quad(ts,19)~=nil);T.check(Native.overQuad(ts,19)~=nil) + T.eq(Native.quad(ts,-1),nil) + local lease=assert(Native.acquire(f.pair)) + T.eq(#Native.trim({},0),0,'explicit leases prevent LRU retirement') + lease.release();lease.release();T.eq(ts._leases,0,'lease release is idempotent') + -- Cancel after CPU allocation but before publication. Its partial data + -- must be released, with the already resident pair left untouched. + Native.prefetch(f.neighborPair,1) + local job=Native._stream.pending[f.neighborPair] + while job and not(job.ts and job.ts.imageData)do Stream.update();job=Native._stream.pending[f.neighborPair]end + local partial=assert(job and job.ts and job.ts.imageData) + Native._stream:retain({[f.pair]=true}) + T.eq(Native._stream.pending[f.neighborPair],nil);T.eq(dead(partial),true) + T.eq(Native.resident(),1) + Native.ensureAlt(ts) + -- The actual public flush is replaceable: animation/palette updates + -- must pass through a source wrapper, so its dirty revisions survive. + local original=Native.flush;local observed=0 + local ok=require('src.mods.ModSeams').run('atlas_flush_probe',function() + Native.flush=function(value,under,over) + observed=observed+1 + return original(value,under,over,3,true) + end + end) + T.eq(ok,true) + local batch={texture=ts.image} + function batch:getTexture()return self.texture end + function batch:setTexture(texture)self.texture=texture end + package.loaded['src.core.game3.field_view']={_nativeBatch=batch} + for palette=0,3 do + local words={};for i=1,16 do words[i]=palette<=1 and 31 or(palette==2 and 31*32 or 31*1024)end + T.eq(Native.setSlotPalette(ts,palette,words),true) + T.eq(batch.texture,ts.image,'retained source batches follow buffer switches') + equalImage(ts,false,'three-buffer partial palette history is complete') + equalImage(ts,true,'overhead palette history is complete') + end + T.check(observed>=3,'native updates invoke the currently installed source flush wrapper') + -- Dirty cells in different rows must accumulate separately for every + -- backing image, including after switching between two and three buffers. + for pass=1,9 do + local slot=({0,17,30})[(pass-1)%3+1] + local pixel=string.char(pass*17,255-pass*19,pass*11,255) + Upload.patch(ts.imageData,ts.cols,slot,string.rep(pixel,256),pass%2==0) + Native.markDirty(ts,false,slot) + original(ts,true,false,pass%2==0 and 2 or 3,pass%3~=0) + equalImage(ts,false,'mixed full/row/cell buffer history preserves every changed cell') + end + local third=ts._uploadBuffers.image.entries[3].image + local other=assert(Native.get(f.neighborPair));local otherImage=other.image + Native.trim({},0,{[third]=true}) + T.eq(ts.image~=nil,true,'busy protection includes the third backing image') + T.eq(dead(otherImage),true);T.eq(Native.resident(),1) + -- Structural replacement publishes a fresh atlas while a leased prior + -- resource remains usable until its owner explicitly releases it. + local held=assert(Native.acquire(f.pair));local oldImage=held.atlas.image + f.game.data.map_tilesets.P.tiles=string.rep('\17',#f.game.data.map_tilesets.P.tiles) + local replacement=assert(Native.get(f.pair)) + T.check(replacement~=ts);T.eq(dead(oldImage),false) + held.release();T.eq(dead(oldImage),true) + package.loaded['src.core.game3.field_view']=previousView + -- Restore pristine pixels for independent native-clock animation tests. + Native.release();f.views:release();f=F.new(edition) + local base=f.game.data.map_tilesets.P + base.animations={{tile=0,count=1,step=3,frames={string.rep('\17',32),string.rep('\34',32),string.rep('\51',32)}}} + ts=assert(Native.get(f.pair)) + local expected=Pixels.new({primary=base,secondary=f.game.data.map_tilesets.S},f.picture.layout) + for tick=1,12 do + renderer.tick(1/60);Native.update();expected:update(renderer.animFrame()) + T.eq(ts.imageData:getString(),expected:rgba(false),'atlas follows the existing native animation clock') + equalImage(ts,false,'native-clock pixel updates reach the GPU') + equalImage(ts,true,'native-clock overhead updates reach the GPU') + end + local image=ts.image;local data=ts.imageData + local generation=Native._gen + require('src.mods.Loader').teardown() + T.eq(animation._pairs[f.pair],nil,'retired native resources disappear from animation observations') + T.eq(Native.resident(),0);T.eq(dead(image),true);T.eq(dead(data),true) + T.eq(Native.flush,original,'real loader unwind restores source resource wrappers') + T.eq(Native._cache,nil);T.check(Native._gen>generation) + f.views:release() + end + -- A source mod can replace a public cache entry with its own atlas. The + -- provider retires the displaced owned resource, while a borrowed-image + -- alias keeps the original backing image alive for as long as it is live. + do + local f=F.new('firered');local ts=assert(Native.get(f.pair));local image=ts.image + local alias={image=ts.image,overImage=ts.overImage,imageData=ts.imageData,overImageData=ts.overImageData, + midToSlot=ts.midToSlot,cols=ts.cols,rows=ts.rows,quads={},overQuads={}} + Native._pairs[f.pair]=alias + T.eq(Native.get(f.pair),alias);T.eq(dead(image),false,'cache aliases cannot free borrowed backing textures') + Native._pairs[f.pair]=nil + Native.ready(f.pair);T.eq(dead(image),true,'displaced unreferenced atlases are retired without waiting for exit') + Native.release();f.views:release() + end + -- Native model validation and GPU creation failure both release the job's + -- partial payload and retain the diagnostic for the explicit get caller. + do + local f=F.new('firered') + f.game.data.map_tilesets.S.palettes=nil + local ts,why=Native.get(f.pair) + T.eq(ts,nil);T.check(why:find('palette data unavailable',1,true)~=nil);T.eq(Native.resident(),0) + Native.release();f.views:release();f=F.new('firered') + local constructor=love.graphics.newImage + love.graphics.newImage=function()error('atlas GPU probe failure')end + ts,why=Native.get(f.pair) + love.graphics.newImage=constructor + T.eq(ts,nil);T.check(why:find('atlas GPU probe failure',1,true)~=nil);T.eq(Native.resident(),0) + T.eq(Native._stream.pending[f.pair],nil) + T.check(Native.get(f.pair)~=nil,'a failed upload can retry after the graphics device recovers') + Native.release();f.views:release() + end + Native.release();package.loaded['src.core.Game']=previousGame;package.loaded['src.core.game3.field_view']=previousView +end +return C diff --git a/tools/native-atlas/row-copy-benchmark.lua b/tools/native-atlas/row-copy-benchmark.lua new file mode 100644 index 000000000..6e724662f --- /dev/null +++ b/tools/native-atlas/row-copy-benchmark.lua @@ -0,0 +1,35 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Owned QA CPU comparison; never a game FPS benchmark. +local ffi=require('ffi') +local old=assert(loadfile(assert(arg[1])))() +local current=require('src.compat.gen1.AtlasUpload') +local frames={} +for f=1,32 do + local bytes={};for i=1,1024 do bytes[i]=string.char((f*19+i*31)%256)end + frames[f]=table.concat(bytes) +end +local function resource() + local memory=ffi.new('uint8_t[?]',256*256*4) + return memory,{getWidth=function()return 256 end,getFormat=function()return'rgba8'end, + getFFIPointer=function()return memory end} +end +for _,module in ipairs{old,current}do + local keep,data=resource();for i=1,2000 do module.patch(data,16,i%256,frames[i%32+1],false)end +end +local expected,times +times={} +for leg,on in ipairs{false,true,true,false}do + local module=on and current or old + local memory,data=resource() + collectgarbage('collect') + local start=os.clock() + for i=1,30000 do module.patch(data,16,i%256,frames[i%32+1],false)end + times[leg]=os.clock()-start + local bytes=ffi.string(memory,256*256*4) + if expected then assert(bytes==expected,'atlas row CPU comparison changed final bytes')else expected=bytes end + print('ATLAS_ROW_COPY_CPU_LEG',leg,on,times[leg]) +end +local ratio=(times[1]+times[4])/(times[2]+times[3]) +print('ATLAS_ROW_COPY_CPU_RATIO',ratio) +print('MICROBENCHMARK_ONLY_NOT_GAME_FPS') +assert(ratio>=1,'candidate did not reduce isolated row-copy CPU time') diff --git a/tools/native-atlas/session-fixture.lua b/tools/native-atlas/session-fixture.lua new file mode 100644 index 000000000..543c509df --- /dev/null +++ b/tools/native-atlas/session-fixture.lua @@ -0,0 +1,17 @@ +local F={} +function F.new(edition) + local f=require('tools.map-runtime.fixture').new(edition) + local picture=require('tools.native-atlas.fixture').new(edition) + local data=f.game.data;local previous=data.map_tilesets + picture.pair.primary.address=previous.P.address + picture.pair.secondary.address=previous.S.address + local secondary={};for key,value in pairs(picture.pair.secondary)do secondary[key]=value end + secondary.address=previous.T.address + data.map_tilesets={P=picture.pair.primary,S=picture.pair.secondary,T=secondary} + data.constants.gen3Layout=picture.layout + f.world.player=nil -- atlas prewarming must not require a spawned avatar + package.loaded['src.core.Game']=f.game + f.picture=picture + return f +end +return F diff --git a/tools/native-atlas/source-oracle.lua b/tools/native-atlas/source-oracle.lua new file mode 100644 index 000000000..1253a2ac9 --- /dev/null +++ b/tools/native-atlas/source-oracle.lua @@ -0,0 +1,42 @@ +-- Run from the pinned reference checkout; writes only synthetic byte fixtures. +local fixturePath,output=assert(arg[1]),assert(arg[2]) +local F=assert(loadfile(fixturePath))() +local Pack=require('src.import.gba.native_pack') +local Versions=require('src.import.gba.versions') +local function write(name,data)local f=assert(io.open(output..'/'..name,'wb'));f:write(data);f:close()end +for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'})do + require('src.core.GameVersion').set(edition) + Versions.select(edition) + local f=F.new(edition) + local under,over=Pack.buildLayeredIdx(f.bundle,f.mids) + local palettes=require('src.import.gba.tileset').mergeMapPalettes(f.bankPalettes.primary,f.bankPalettes.secondary) + local colors=Pack.palsToRgb8(palettes) + write(edition..'-under.idx',Pack.encodeIdx(under));write(edition..'-over.idx',Pack.encodeIdx(over)) + write(edition..'-under.rgba',Pack.bakeRgba(under,colors)) + write(edition..'-over.rgba',Pack.bakeRgba(over,colors,{transparentZero=true})) + local base=Pack.bakeRgba(under,colors) + local function patched(label,slot,words) + local changed={};for key,value in pairs(palettes)do changed[key]=value end;changed[slot]=words + local rgb=Pack.palsToRgb8(changed) + local rgba,w,h=Pack.bakeRgba(under,rgb);local lines={} + for y=0,h-1 do + local row={} + for x=0,w-1 do + local atlasSlot=math.floor(y/16)*16+math.floor(x/16);local at=(y*w+x)*4+1 + local bytes=atlasSlot>=#f.mids and base or rgba + row[#row+1]=bytes:sub(at,at+3) + end + lines[#lines+1]=table.concat(row) + end + write(edition..'-'..label..'-under.rgba',table.concat(lines)) + write(edition..'-'..label..'-over.rgba',Pack.bakeRgba(over,rgb,{transparentZero=true})) + end + local red={};for color=0,15 do red[color]=31 end + patched('palette0',0,red);patched('palette1',1,red) + if edition=='emerald'then + for frame=0,1 do + local words={};for c=0,15 do words[c]=(c*3+frame*7)%32+((c*5+frame*11)%32)*32+((c*9+frame*13)%32)*1024 end + patched('palette-frame'..frame,8,words) + end + end +end diff --git a/tools/native-atlas/warm-check.lua b/tools/native-atlas/warm-check.lua new file mode 100644 index 000000000..3178c3f2b --- /dev/null +++ b/tools/native-atlas/warm-check.lua @@ -0,0 +1,24 @@ +package.path='./?.lua;./?/init.lua;'..package.path +love=require('tests.love_stub') +local T=require('tests.harness') +local Warm=require('src.core.game3.warm') +local done={};local cancelled=0 +Warm.add('slow',function()done[#done+1]='s1';Warm.yield();done[#done+1]='s2'end,8,function()cancelled=cancelled+1 end) +Warm.add('fast',function()done[#done+1]='f1';Warm.yield();done[#done+1]='f2'end,1) +Warm.step(0);T.same(done,{'f1'});T.eq(Warm.pending('fast'),true) +T.eq(Warm.flush('absent'),false);T.same(done,{'f1'},'a missing named request cannot flush other work') +T.eq(Warm.flush('fast'),true);T.same(done,{'f1','f2'});T.eq(Warm.pending('slow'),true) +Warm.reset();T.eq(cancelled,1);T.eq(Warm.pending('slow'),false) +Warm.add('bad',function()error('warm probe failure')end,1,function()cancelled=cancelled+1 end) +Warm.step(0);T.check(Warm.errors.bad:find('warm probe failure',1,true)~=nil,'optional warm failures retain their diagnostic without stopping the game') +T.eq(cancelled,2);T.eq(Warm.pending('bad'),false) +Warm.add('replace',function() + Warm.reset();Warm.add('replace',function()done[#done+1]='replacement'end) +end) +Warm.step(0);T.eq(Warm.pending('replace'),true,'a retired completion cannot delete a replacement request') +Warm.flush('replace');T.eq(done[#done],'replacement') +Warm.add('recursive',function()T.eq(Warm.flush('recursive'),false,'a request cannot resume its own coroutine')end) +Warm.flush('recursive') +Warm.add('teardown',function()error('should be cancelled')end) +require('src.mods.Loader').teardown();T.eq(Warm.pending('teardown'),false) +T.finish('native source warm queue')