diff --git a/docs/native-sprite-runtime.md b/docs/native-sprite-runtime.md new file mode 100644 index 000000000..238e55ddb --- /dev/null +++ b/docs/native-sprite-runtime.md @@ -0,0 +1,66 @@ +# Owned original-order sprite resources + +`src.core.game3.ow_sprites` exposes the original image-table sprite interface +over the native GBA dataset. It owns CPU image data, textures and zero-based +quads. It does not lend native `Assets` textures, run an actor simulation, +advance a clock, import a cartridge or create a game session. + +The schema-2 object cache supplies indexed original frames, palettes, anchors, +avatar state tables and the native walk/run frame maps. Native image-definition +and asset-search overrides are projected into those original slots. The +`overworld/g3_NNN_source.png` override accepts a whole original-order sheet. +Static native definitions retain their one-frame behavior. Original-only poses +remain available when normalized walk/run art is overridden. Resource geometry +and the per-runtime 128 MiB CPU/texture budget are checked before publication. + +An explicit cache provider may supply SVOW versions 1–4 and RGBA payloads without +a game session. Missing entries fall back to the corresponding native sheet; +declared invalid metadata or pixels fail rather than borrowing unrelated art. +Generated manifest chunks use an empty environment and an instruction guard; +LuaJIT compilation is disabled for that chunk so loops cannot evade the guard. + +Preparation yields between decode rows, uploads and quad batches. Synchronous +getters flush the same job; asynchronous users step through the existing stream. +Cancellation, edition/dataset/save/mod-loader/asset-search changes and teardown +retire all owned allocations, including incomplete work and displaced public +cache entries. A failed mod loses only its claims on shared resources. Reflection +claims include every consumer; caller-injected graphics remain caller-owned. +Late script-validation failures unwind entry wrappers before owner cleanup. + +Pose, field-move, fishing and avatar observations use the existing native state. +The Hoenn and Kanto running frame rules differ. Observing a pose or selecting +graphics never advances the live player's pose timers or movement. + +## Validation + +Run with LuaJIT from the repository root: + +``` +luajit tools/sprite-runtime/resources-check.lua +luajit tools/sprite-runtime/cache-check.lua +SESSION_RUNTIME_API=2 luajit tools/sprite-runtime/lifecycle-check.lua +SESSION_RUNTIME_API=3 luajit tools/sprite-runtime/lifecycle-check.lua +``` + +The lifecycle suite loads real SDK entries, a native peer, an entry failure and +a successful entry rejected by later script validation. The resource suite is +also reused by `tools/sprite-runtime/host` with real GPU texture readbacks. +The host takes explicit `SPRITE_RUNTIME_ENGINE`, `SPRITE_RUNTIME_INPUTS`, +`SPRITE_RUNTIME_EXPECTED`, `SPRITE_RUNTIME_OUT`, `POKEPORT_IDENTITY` and +`POKEPORT_GRAPHICS_BACKEND` inputs. Expected cartridge pixels come from an +independent extraction contract, not this implementation's metadata. + +`pose-oracle.lua` can run the reference and this implementation in separate +processes for independent pose comparisons. Cartridge dumps, generated artwork, +player data and private mod sources are not part of these public fixtures. + +## Remaining integration + +This interface does not establish full original-package or renderer acceptance. +The unchanged package still requires further scripting, collection, field, UI, +battle and audio services. Automatic field scheduling, arbitrary actor-provider +semantics and special live berry/disguise/multipart actors need further work. +Native asset projection does not implement arbitrary native renderer monkey +patches. Full five-edition gameplay, native-mod coexistence, performance and app +packaging remain separate acceptance requirements. QA keeps an 8 GiB reserve +and uses isolated identities, never player profiles. diff --git a/src/compat/gen1/SpriteCache.lua b/src/compat/gen1/SpriteCache.lua new file mode 100644 index 000000000..b28ecd656 --- /dev/null +++ b/src/compat/gen1/SpriteCache.lua @@ -0,0 +1,58 @@ +-- Typed binary cache input. A caller-supplied cache owns its I/O; this module +-- never reads profiles or imports a cartridge to satisfy a sprite request. +local M={} +local function u16(s,i)return s:byte(i)+s:byte(i+1)*256 end +local function signed(n)return n>=128 and n-256 or n end +function M.decode(bytes,gid) + if type(bytes)~='string'or#bytes<16 or bytes:sub(1,4)~='SVOW'then return nil,'invalid sprite metadata'end + local version=bytes:byte(5);if version<1 or version>4 then return nil,'unsupported sprite metadata version'end + local r={formatVersion=version,inanimate=bytes:byte(6)==1,graphicsId=u16(bytes,7), + width=u16(bytes,9),height=u16(bytes,11),frameCount=u16(bytes,13),paletteTag=u16(bytes,15),drawOffX=0,drawOffY=0} + if r.graphicsId~=gid then return nil,'sprite metadata identity mismatch'end + if r.width<8 or r.width>128 or r.height<8 or r.height>128 or r.frameCount<1 or r.frameCount>256 + or r.width*r.height*r.frameCount>2097152 then return nil,'sprite metadata geometry limit'end + if version>=2 then + if #bytes<118 then return nil,'truncated sprite palettes'end + r.reflectionPaletteTag=u16(bytes,17);r.paletteSlot=bytes:byte(19);r.reflectionPaletteMappedTag=u16(bytes,20) + local flags=bytes:byte(22) + for index,key in ipairs({'palette','reflectionPalette','mappedReflectionPalette'})do + local p={};for c=0,15 do p[c]=u16(bytes,23+(index-1)*32+c*2)end;r[key]=p + end + if flags%2==0 then r.reflectionPalette=nil end + if math.floor(flags/2)%2==0 then r.mappedReflectionPalette=nil end + if r.reflectionPaletteTag==65535 then r.reflectionPaletteTag=nil end + if r.reflectionPaletteMappedTag==65535 then r.reflectionPaletteMappedTag=nil end + end + if version==4 then + if #bytes<120 then return nil,'truncated sprite anchors'end + r.drawOffX,r.drawOffY=signed(bytes:byte(119)),signed(bytes:byte(120)) + end + return r +end +function M.read(cache,root,gid) + local meta=cache:read(root..'/'..gid..'.meta');if not meta then return nil end + local r,why=M.decode(meta,gid);if not r then return nil,why end + local rgba=cache:read(root..'/'..gid..'.rgba') + if type(rgba)~='string'or#rgba~=r.width*r.height*r.frameCount*4 then return nil,'invalid sprite RGBA payload'end + return r,rgba +end +function M.manifest(cache,root) + local bytes=cache:read(root..'/manifest.lua') + if not bytes then return nil end + if type(bytes)~='string'or#bytes>1048576 then return nil,'sprite manifest size limit'end + local chunk,why + if setfenv then chunk,why=loadstring(bytes,'@sprite-manifest') + else chunk,why=load(bytes,'@sprite-manifest','t',{})end + if not chunk then return nil,why end + if setfenv then setfenv(chunk,{})end + -- Count hooks do not interrupt an already compiled LuaJIT loop. The tiny + -- generated manifest runs once; disable traces for it and its nested data + -- constructors so the instruction guard remains effective. + if jit and jit.off then jit.off(chunk,true)end + local hook,mask,count=debug.gethook() + debug.sethook(function()error('sprite 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 sprite manifest'end + return result +end +return M diff --git a/src/compat/gen1/SpritePose.lua b/src/compat/gen1/SpritePose.lua new file mode 100644 index 000000000..3e8279e1f --- /dev/null +++ b/src/compat/gen1/SpritePose.lua @@ -0,0 +1,79 @@ +-- Pure image-table pose rules. Observing a pose never advances a game clock. +local P={} +local Version=require('src.core.GameVersion') +local axis={down=0,up=1,left=2,right=2} +local function number(v,default) + v=tonumber(v);return v and v==v and math.abs(v)=12 then return P.fishingAbsFrame(facing,math.max(0,math.min(3,number(opts.fishFrame,3)))),mirrored end + if opts.frame~=nil then return math.max(0,math.min(n-1,number(opts.frame,0))),mirrored end + if n<=1 or sprite.inanimate then return 0,false end + if opts.bow and n>9 then return 9,false end + if opts.fieldMove and n>=5 then local f=number(opts.fieldMoveFrame,4);return f>=n and 0 or f,false end + if opts.running~=nil and n>=18 then + local profile=package.loaded['src.core.game3.profile'] + local ok,row + if profile and profile.forSession then ok,row=pcall(profile.forSession)end + local frames=ok and row and row.field and row.field.runFrames + local r=frames and frames[opts.running==1 and 2 or 1];r=r and r[flip and'a'or'b'] + if r and r[facing]then return r[facing],mirrored end + local id=Version.get() + if id=='emerald'or id=='ruby'or id=='sapphire'then + return opts.running==1 and 9+a or 12+a*2+(flip and 0 or 1),mirrored + end + return 9+a*3+(opts.running==1 and(flip and 1 or 2)or 0),mirrored + end + if sprite.nativeStandOrder and sprite.nativeWalker==false then return sprite.nativeStandOrder[a+1]or 0,mirrored end + if (phase==1 or phase==true)and n>=9 then return 3+a*2+(flip and 0 or 1),mirrored end + return math.min(a,n-1),mirrored +end +function P.avatarState(player) + if not player then return 'NORMAL'end + if number(player.fieldMoveAnim,0)>0 then return 'FIELD_MOVE'end + if player.underwater then return 'UNDERWATER'end + if player.fishing then return 'FISHING'end + if player.surfing and not player.dismounting or player.flyRide or player.surfJumpFrame and player.surfJumpFrame()then return 'SURFING'end + if player.biking then return player.bikeType=='acro'and'ACRO_BIKE'or'MACH_BIKE'end + return player.watering and'WATERING'or'NORMAL' +end +function P.avatarGraphicsId(state,female,avatars,who) + local rows=avatars and avatars[who or'player'];if not rows then return nil end + local key=female and'female'or'male';local normal + for _,r in ipairs(rows)do if r.state==state then return r[key]end;if r.state=='NORMAL'then normal=r[key]end end + return normal +end +return P diff --git a/src/compat/gen1/SpriteRuntime.lua b/src/compat/gen1/SpriteRuntime.lua new file mode 100644 index 000000000..b7f7c79a5 --- /dev/null +++ b/src/compat/gen1/SpriteRuntime.lua @@ -0,0 +1,349 @@ +-- Owned sprite resources over native imported sheets or an explicit binary +-- cache provider. The native SpriteRenderer/Assets GPU caches are never lent. +local Session=require('src.compat.gen1.NativeSession') +local Version=require('src.core.GameVersion') +local Sheets=require('src.import.Gen3ObjectSheets') +local Assets=require('src.render.Assets') +local Cache=require('src.compat.gen1.SpriteCache') +local R={};R.__index=R +local ENGINE={} +local function finite(n)return type(n)=='number'and n==n and math.abs(n)=lo and n<=hi end +local function release(x)if x and x.release then pcall(x.release,x)end end +local function copy(v,seen,depth) + if type(v)~='table'then + if type(v)=='number'or type(v)=='string'or type(v)=='boolean'then return v end + return nil + end + seen=seen or{};if seen[v]then return seen[v]end + assert((depth or 0)<32,'sprite metadata nesting limit') + local out={};seen[v]=out + for k,x in pairs(v)do if type(k)=='number'or type(k)=='string'then out[k]=copy(x,seen,(depth or 0)+1)end end + return out +end +local function owner() + local native=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + return native and native.currentMod or facade and facade._callbackOwner and facade._callbackOwner()or ENGINE +end +local function root() + local extract=package.loaded['src.import.gba.extract_island1'] + return ((extract and extract.CACHE_ROOT)or'data/generated/gba')..'/ow' +end +local function dropSprite(sprite) + if not sprite then return end + release(sprite.image);release(sprite.imageData) + for _,q in pairs(sprite.quads or{})do release(q)end + sprite.image,sprite.imageData,sprite.quads=nil,nil,{} + sprite._retired=true +end +function R.new() + return setmetatable({loaded={},reflections={},overrides={},errors={},jobs={},providers={},owned={},serial=0,bytes=0,maxBytes=128*1024*1024},R) +end +function R:cancelJob(job) + if self.jobs[job.id]==job then self.jobs[job.id]=nil end + job.cancelled=true + for _,p in ipairs(job.parts)do release(p)end;job.parts={} +end +function R:invalidate() + local old=self.jobs;self.jobs={};for _,job in pairs(old)do self:cancelJob(job)end + for sprite in pairs(self.owned)do dropSprite(sprite)end + self.owned={};self.loaded,self.reflections,self.overrides={},{},{} + self.errors={};self.context=nil;self.bytes=0 +end +function R:release()self:invalidate();self.providers={}end +function R:install(provider) + if provider~=nil and(type(provider)~='table'or type(provider.read)~='function')then return nil,'invalid sprite cache provider'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 + self.providers[#self.providers+1]={owner=who,cache=provider,edition=Version.get()} + self:invalidate();return self:ready() +end +function R:current() + local game=Session.game();local edition=Version.get();local info=Version.info(edition) + if not info or info.generation~=3 then self:invalidate();return nil,'no native GBA sprite session'end + local data=game and game.data;local meta=data and data.gen3_object_sheets + local lease=self.providers[#self.providers];local provider=lease and lease.edition==edition and lease.cache + if meta and(meta.schema~=Sheets.SCHEMA or meta.edition~=edition or not Version.acceptsSha1(edition,meta.romSha1))then + meta=nil + end + if not meta and not provider then self:invalidate();return nil,'native original sprite cache unavailable or outdated'end + local c=self.context;local saved=game and game.save;local mods=game and game.mods;local prefix=root() + if c and(c.game~=game or c.data~=data or c.meta~=meta or c.save~=saved or c.mods~=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=data,meta=meta,save=saved,mods=mods,edition=edition,provider=provider,root=prefix,assets=Assets.loader} + self.context=c + end + return c +end +function R:manifest() + local c,why=self:current();if not c then return nil,why end + if not c.manifest then + if c.provider then c.manifest=copy(Cache.manifest(c.provider,c.root))end + if not c.manifest and c.meta then + local manifest={sprites={},avatars=copy(c.meta.avatars),count=0} + for id,r in pairs(c.meta.objects)do + manifest.sprites[id]={width=r.width,height=r.height,frameCount=r.frameCount,paletteTag=r.paletteTag,inanimate=r.inanimate} + manifest.count=manifest.count+1 + end + c.manifest=manifest + end + end + return c.manifest +end +function R:ready()local c=self:current();return c~=nil and(self:manifest()~=nil)end +function R:record(id) + id=tonumber(id);if not integer(id,0,65535)then return nil,'invalid sprite graphics ID'end + local c,why=self:current();if not c then return nil,why end + if c.provider then + c.sourceRecords=c.sourceRecords or{} + if c.sourceRecords[id]==nil then + local ok,record,rgba=pcall(Cache.read,c.provider,c.root,id) + if not ok then c.sourceRecords[id]={error=tostring(record)} + elseif record then c.sourceRecords[id]={record=record,rgba=rgba} + elseif rgba then c.sourceRecords[id]={error=rgba} + else c.sourceRecords[id]=false end + end + local found=c.sourceRecords[id] + if found then + if found.error then return nil,found.error end + return found.record,c,found.rgba + end + end + local r=c.meta and c.meta.objects[id] + if not r then return nil,'original sprite graphics unavailable'end + if not(r.graphicsId==id and integer(r.width,8,128)and integer(r.height,8,128)and integer(r.frameCount,1,256) + and r.width*r.height*r.frameCount<=2097152 and type(r.indexed)=='string' + and#r.indexed==r.width*r.height*r.frameCount)then return nil,'invalid native sprite geometry'end + return r,c +end +local function selection(c,r) + local n=r.native;local out={} + local raw=('assets/generated/overworld/g3_%03d_source.png'):format(r.graphicsId) + local resolved=Assets.resolve(raw) + if resolved~=raw then out.raw=resolved;return out end + for i,pair in ipairs({{n and n.key,n and n.order},{n and n.runKey,n and n.runOrder}})do + local key,order=pair[1],pair[2];local def=key and c.data and c.data.sprites and c.data.sprites[key] + if def and type(def.image)=='string'and order then + local original=i==2 and n.runImage or n.image + local path=Assets.resolve(def.image) + if path~=original or def.frameWidth~=(i==2 and n.runWidth or n.defaultWidth) or def.frameHeight~=(i==2 and n.runHeight or n.defaultHeight) + or math.max(def.frames or 1,def.poseFrames or 1)~=(i==2 and n.runFrames or n.frames) or i==1 and def.walker~=n.walker then + out[i]={key=key,path=path,def=def,order=order,width=def.frameWidth or n.width,height=def.frameHeight or n.height, + frames=math.max(def.frames or 1,def.poseFrames or 1),inanimate=(def.frames or 1)<=1,walker=def.walker} + end + end + end + return out +end +local function same(a,b) + if a.raw~=b.raw then return false end + for i=1,2 do + local x,y=a[i],b[i] + if (x==nil)~=(y==nil)then return false end + if x and(x.path~=y.path or x.def~=y.def or x.width~=y.width or x.height~=y.height or x.frames~=y.frames or x.walker~=y.walker)then return false end + end + return true +end +function R:yield(job) + self:current() + if self.context~=job.context or job.cancelled then error('sprite preparation retired')end + if not job.flushing then coroutine.yield()end +end +function R:build(job) + local r,c=job.record,job.context + local rgba=job.rgba or Sheets.rgba(r,nil,function()self:yield(job)end) + local data=love.image.newImageData(r.width,r.height*r.frameCount,'rgba8',rgba);job.parts[#job.parts+1]=data + local width,height,count=r.width,r.height,r.frameCount + local pick=job.selection + if pick.raw then + local source=love.image.newImageData(pick.raw);job.parts[#job.parts+1]=source + assert(source:getWidth()==width and source:getHeight()==height*count,'original sprite override dimensions differ') + data:paste(source,0,0,0,0,width,height*count) + else + for i=1,2 do + local s=pick[i] + if s then + local source=love.image.newImageData(s.path);job.parts[#job.parts+1]=source + assert(source:getWidth()==s.width and source:getHeight()>=s.height*s.frames,'native sprite override dimensions differ') + if s.inanimate and i==1 then + width,height,count=s.width,s.height,1 + data=love.image.newImageData(width,height,'rgba8');job.parts[#job.parts+1]=data + data:paste(source,0,0,0,0,width,height) + job.inanimate=true;break + end + assert(s.width>=width and s.height>=height,'native override cannot represent original sprite geometry') + for frame,original in ipairs(s.order)do + assert(original=deadline +end +function R:cancelPending()local jobs=self.jobs;self.jobs={};for _,job in pairs(jobs)do self:cancelJob(job)end end +function R:drop(sprite) + local held=self.owned[sprite] + if held then self.bytes=self.bytes-held.size;self.owned[sprite]=nil;dropSprite(sprite)end +end +function R:get(id) + id=tonumber(id);local c,why=self:current();if not c then return nil,why end + local sprite=self.loaded[id] + if sprite and not sprite._retired then + local held=self.owned[sprite] + if not held then return sprite end -- explicit source injection remains caller-owned + local r,context,rgba=self:record(id) + if r==held.record and context==held.context and(rgba or same(selection(c,r),held.selection))then held.owners[owner()]=true;return sprite end + self:drop(sprite);self.loaded[id]=nil;self:clearPalette(id);self:drop(self.reflections[id]);self.reflections[id]=nil + end + local ok,reason=self:prefetch(id,0);local job=self.jobs[id] + if not job then return self.loaded[id],reason end + if job.co==coroutine.running()then return nil,'recursive sprite preparation'end + job.flushing=true + while self.jobs[id]==job do if not self:advance(job)then break end end + return self.loaded[id],self.errors[id] +end +function R:variant(base,from,to) + if type(from)~='table'or type(to)~='table'or#from~=#to or#from==0 then return nil,'invalid sprite palette substitution'end + local colors,lut={},{};for i,a in ipairs(from)do + local b=to[i];assert(type(a)=='table'and type(b)=='table','invalid palette color') + for c=1,3 do assert(integer(a[c],0,255)and integer(b[c],0,255),'invalid palette channel')end + colors[i]={a[1],a[2],a[3],b} + lut[a[1]*65536+a[2]*256+a[3]]=b + end + local data=base.imageData:clone();local image,quads;local matches=0 + local ok,why=pcall(function() + data:mapPixel(function(_,_,r,g,b,a) + if a<=0 then return r,g,b,a end + local rr,gg,bb=math.floor(r*255+.5),math.floor(g*255+.5),math.floor(b*255+.5) + local best,distance=lut[rr*65536+gg*256+bb] + if not best then + for _,c in ipairs(colors)do + local d=(rr-c[1])^2+(gg-c[2])^2+(bb-c[3])^2 + if d<=48 and(not distance or d0,'sprite palette substitution matched no pixels') + assert(self.bytes+base.width*base.height*base.frameCount*8<=self.maxBytes,'owned sprite texture budget exhausted') + image=love.graphics.newImage(data);image:setFilter('nearest','nearest');quads={} + for frame=0,base.frameCount-1 do quads[frame]=love.graphics.newQuad(0,frame*base.height,base.width,base.height,base.width,base.height*base.frameCount)end + end) + if not ok then release(data);release(image);for _,q in pairs(quads or{})do release(q)end;return nil,tostring(why)end + local sprite={};for k,v in pairs(base)do sprite[k]=v end + sprite.image,sprite.imageData,sprite.quads=image,data,quads + local size=base.width*base.height*base.frameCount*8;self.bytes=self.bytes+size + self.owned[sprite]={size=size,owners={[owner()]=true},base=base};return sprite +end +function R:clearPalette(id) + id=tonumber(id);local o=self.overrides[id];if not o then return false end + self:drop(o.spr);self.overrides[id]=nil;return true +end +function R:setPalette(id,key,to,from) + id=tonumber(id);if type(key)~='string'or not id then return false end + local base=self:get(id);if not base then return false end + local previous=self.overrides[id];if previous and previous.key==key and previous.base==base then return true end + if not from then local d=package.loaded['src.core.game3.deoxys'];from=d and d.ROCK_PALS and d.ROCK_PALS[1]end + local ok,sprite=pcall(self.variant,self,base,from,to);if not ok or not sprite then return false end + self:clearPalette(id);self.overrides[id]={key=key,spr=sprite,base=base,colours=copy(to),sourceColours=copy(from),owner=owner()};return true +end +function R:getDraw(id) + local base,why=self:get(id);if not base then return nil,why end + local ov=self.overrides[tonumber(id)];return ov and ov.spr or base +end +local function rgb(p) + if not p then return nil end + local rows={};for i=1,15 do local n=p[i];if n==nil then return nil end + rows[i]={math.floor(n%32*255/31+.5),math.floor(math.floor(n/32)%32*255/31+.5),math.floor(math.floor(n/1024)%32*255/31+.5)}end + return rows +end +function R:reflection(id) + id=tonumber(id);local base=self:get(id);if not base or not base.reflectionPaletteMappedTag then return nil end + local cached=self.reflections[id] + if cached then + local held=self.owned[cached];if held then held.owners[owner()]=true end + return cached + end + local from,to=rgb(base.palette),rgb(base.mappedReflectionPalette);if not from or not to then return nil end + local sprite=self:variant(base,from,to);if sprite then self.reflections[id]=sprite end;return sprite +end +function R:releaseOwner(who) + if type(who)~='string'then return end + for i=#self.providers,1,-1 do if self.providers[i].owner==who then table.remove(self.providers,i)end end + self:current() + for _,job in pairs(self.jobs)do job.owners[who]=nil;if not next(job.owners)then self:cancelJob(job)end end + -- The public cache may replace a resource without releasing it. Retire + -- through our allocation registry so displaced and derived resources are + -- included, while caller-injected images remain outside our ownership. + for sprite,held in pairs(self.owned)do + held.owners[who]=nil + if not next(held.owners)then self:drop(sprite)end + end + for id,sprite in pairs(self.loaded)do + if sprite._retired then self.loaded[id]=nil;self:clearPalette(id)end + end + for id,sprite in pairs(self.reflections)do + if sprite._retired then self.reflections[id]=nil end + end + for id,o in pairs(self.overrides)do if o.owner==who then self:clearPalette(id)end end +end +R.copy=copy +return R diff --git a/src/core/game3/asset_stream.lua b/src/core/game3/asset_stream.lua new file mode 100644 index 000000000..26d813430 --- /dev/null +++ b/src/core/game3/asset_stream.lua @@ -0,0 +1,32 @@ +-- Main-thread resource services share a wall-clock slice. Providers retain +-- their own typed jobs and cancellation; no private worker module is copied. +local S={BUDGET=.002,_uploadedThisFrame=false} +local services=setmetatable({},{__mode='v'}) +function S.register(service)services[#services+1]=service;return service end +function S.poll() + for _,service in pairs(services)do if service.poll then service:poll()end end +end +function S.update() + local clock=love and love.timer and love.timer.getTime or os.clock + local deadline=clock()+S.BUDGET + for _,service in pairs(services)do + if service.step then service:step(math.max(0,deadline-clock()))end + if service.update then service:update()end + if clock()>=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.cancelOwner(owner) + for _,service in pairs(services)do if service.releaseOwner then + local ok,why=pcall(service.releaseOwner,service,owner) + if not ok then print('[compat/stream] owner cleanup failed: '..tostring(why))end + 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/ow_sprites.lua b/src/core/game3/ow_sprites.lua new file mode 100644 index 000000000..b64e3ae97 --- /dev/null +++ b/src/core/game3/ow_sprites.lua @@ -0,0 +1,112 @@ +-- Original-order sprite interface backed by the native dataset. This facade +-- owns its resources and has no private actor, frame or extraction loop. +local Runtime=require('src.compat.gen1.SpriteRuntime') +local Pose=require('src.compat.gen1.SpritePose') +local Session=require('src.compat.gen1.NativeSession') +local Stream=require('src.core.game3.asset_stream') +local Assets=require('src.render.Assets') +local runtime=Runtime.new() +local S={} +Stream.register(runtime) +Assets.register(function()runtime:invalidate()end) +function S.install(cache)return runtime:install(cache)end +function S.invalidate()runtime:invalidate()end +function S.release()runtime:release()end +function S.ready()return runtime:ready()end +function S.get(id)return runtime:get(id)end +function S.getDraw(id)return runtime:getDraw(id)end +function S.getReflectionDraw(id)return runtime:reflection(id)end +function S.prefetch(id,priority)return runtime:prefetch(id,priority)end +function S.peekDraw(id) + runtime:current();id=tonumber(id) + if runtime.loaded[id]or runtime.overrides[id]then return runtime:getDraw(id)end + runtime:prefetch(id,0);return nil +end +function S.setObjectPalette(id,key,colors,from)return runtime:setPalette(id,key,colors,from)end +function S.clearObjectPalette(id)return runtime:clearPalette(id)end +function S.objectPaletteKey(id) + runtime:current();local o=runtime.overrides[tonumber(id)];return o and o.key +end +S.pose=Pose.pose +S.fieldMoveFrame=Pose.fieldMoveFrame +S.fishingFrame=Pose.fishingFrame +S.fishingAbsFrame=Pose.fishingAbsFrame +S.fishingOffset=Pose.fishingOffset +S.avatarState=Pose.avatarState +function S.avatars()local manifest=runtime:manifest();return manifest and manifest.avatars end +function S.avatarGraphicsId(state,female,avatars,who)return Pose.avatarGraphicsId(state,female,avatars or S.avatars(),who)end +function S.playerGraphicsId(game,provided) + local native,why=Session.game(game);if game~=nil and not native then return nil,why end + local _,world,player=Session.resolve(native) + local observed=player and{ + fieldMoveAnim=player.fieldMove and 1 or 0,underwater=world.map.def.mapType=='UNDERWATER', + fishing=player.fishing,surfing=player.surfing,dismounting=player.dismounting,flyRide=player.flyRide, + biking=native.save and native.save.onBike,bikeType=player.acroBike and'acro'or native.save and native.save.bikeType, + watering=player.watering, + }or{} + local source=provided or package.loaded['src.core.game3.player'] + local p=source and setmetatable({},{__index=function(_,key) + local value=source[key];if value~=nil then return value end;return observed[key] + end})or observed + local session=type(game)=='table'and game.session + local save=type(game)=='table'and game.save or native and native.save + local gender=session and session.gender or save and(save.gender or save.player and save.player.gender) + local female=gender==1 or gender=='female'or gender=='F' + local avatars=S.avatars() + if avatars then return Pose.avatarGraphicsId(Pose.avatarState(p),female,avatars)end + -- FRLG's published interface uses these cartridge graphics constants when + -- no avatar-state table exists. Hoenn always uses its imported state table. + local c=runtime:current();if not c or(c.edition~='firered'and c.edition~='leafgreen')then return nil end + local base=female and 7 or 0;local state=Pose.avatarState(p) + if state=='FIELD_MOVE'then return base+(p.fieldMoveKind=='vs_seeker_bike'and 6 or 3)end + return base+(({FISHING=4,SURFING=2,MACH_BIKE=1,ACRO_BIKE=1})[state]or 0) +end +function S.draw(id,px,py,camX,camY,facing,phase,flip,opts) + local sprite=S.getDraw(id);if not sprite then return false end + local frame,mirror=S.pose(sprite,facing,phase,flip,opts);local quad=sprite.quads[frame] + if not quad then return false end + local offX=sprite.drawOffX or 0;if mirror then offX=-offX end + local x=px-camX+(16-sprite.width)/2+offX + local y=py-camY+16-sprite.height+(sprite.drawOffY or 0) + love.graphics.setColor(1,1,1,opts and opts.alpha or 1) + love.graphics.draw(sprite.image,quad,x+(mirror and sprite.width or 0),y,0,mirror and -1 or 1,1) + if opts and opts.alpha then love.graphics.setColor(1,1,1,1)end + return true +end +-- Only the provider protocol is public. Its closure never exposes the native +-- game/dataset, owner sets or partially constructed GPU resources. +local stream={} +function stream:prefetch(id,priority)return S.prefetch(id,priority)end +function stream:get(id)return S.get(id)end +function stream:poll()runtime:poll()end +function stream:step(budget)runtime:step(budget)end +function stream:cancel(id) + if id==nil then runtime:cancelPending();return true end + local job=runtime.jobs[tonumber(id)];if not job then return false end;runtime:cancelJob(job);return true +end +function stream:retain(wanted) + for id,job in pairs(runtime.jobs)do if not wanted[id]then runtime:cancelJob(job)end end +end +function stream:shutdown()S.release()end +setmetatable(stream,{__index=function(_,key) + if key=='pending'then local out={};for id in pairs(runtime.jobs)do out[id]=true end;return out end +end}) +setmetatable(S,{ + __index=function(_,key) + runtime:current() + if key=='_loaded'then return runtime.loaded end + if key=='_overrides'then return runtime.overrides end + if key=='_reflectionLoaded'then return runtime.reflections end + if key=='_stream'then return stream end + if key=='_manifest'then return runtime:manifest()end + if key=='errors'then return runtime.errors end + if key=='_cache'then local lease=runtime.providers[#runtime.providers];return lease and lease.cache end + if key=='_root'then local c=runtime:current();return c and c.root end + end, + __newindex=function(t,key,value) + if key=='_cache'then runtime:install(value) + elseif key=='_loaded'then assert(value==nil or type(value)=='table','invalid sprite cache table');runtime:invalidate();runtime.loaded=value or{} + else rawset(t,key,value)end + end, +}) +return S diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..18784aedf 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1196,6 +1196,8 @@ end -- migration it took goes with them. The journal only exists around an -- entry chunk; a later failure (script validation) purges every registry. function Loader:_rollback(modId) + -- Restore failed wrappers before any native registry/resource cleanup. + ModSeams.unwindOwner(modId) for name in pairs(self.journal or self.content) do self.content[name]:rollback(modId) end @@ -1206,6 +1208,12 @@ function Loader:_rollback(modId) self.optionStatus[modId] = nil self.migrations[modId] = nil self.modSave[modId] = nil + local stream=package.loaded['src.core.game3.asset_stream'] + if stream and stream.cancelOwner then pcall(stream.cancelOwner,modId)end + local space=package.loaded['src.core.game3.scripting.space'] + if space and space.cancelOwner then pcall(space.cancelOwner,modId)end + local transition=package.loaded['src.core.game3.battle_transition'] + if transition and transition.cancelOwner then pcall(transition.cancelOwner,modId)end end -- a mod that explicitly swears it stays link-compatible while writing into a @@ -1309,12 +1317,26 @@ local function stampAudioOwners(data, name, registry) owners[key] = map end +-- Optional sprite resources own no native game state. Do not load a +-- compatibility module or Game just to retire a previous resource cohort. +local function retireSpriteResources() + local sprites=package.loaded['src.core.game3.ow_sprites'] + if sprites and type(sprites.release)=='function' then sprites.release()end + local space=package.loaded['src.core.game3.scripting.space'] + if space and type(space.release)=='function'then space.release()end + local transition=package.loaded['src.core.game3.battle_transition'] + if transition and type(transition.release)=='function'then transition.release()end + local effects=package.loaded['src.core.game3.field_effects'] + if effects and type(effects.release)=='function'then effects.release()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() + retireSpriteResources() self.baseData = data -- every registry folds against the pristine view of its Data target; -- resolution is lazy so optional namespaces may appear later @@ -1471,7 +1493,6 @@ function Loader:load(data) Runtime.currentMod = nil if not success then self:_rollback(modId) - ModSeams.unwindOwner(modId) end self.journal = nil if success then @@ -1654,6 +1675,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()) + retireSpriteResources() pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) Runtime.currentMod = nil diff --git a/tests/engine/native_sprite_cache.lua b/tests/engine/native_sprite_cache.lua new file mode 100644 index 000000000..da6ae1878 --- /dev/null +++ b/tests/engine/native_sprite_cache.lua @@ -0,0 +1 @@ +dofile('tools/sprite-runtime/cache-check.lua') diff --git a/tests/engine/native_sprite_lifecycle.lua b/tests/engine/native_sprite_lifecycle.lua new file mode 100644 index 000000000..0164d6627 --- /dev/null +++ b/tests/engine/native_sprite_lifecycle.lua @@ -0,0 +1 @@ +dofile('tools/sprite-runtime/lifecycle-check.lua') diff --git a/tests/engine/native_sprite_resources.lua b/tests/engine/native_sprite_resources.lua new file mode 100644 index 000000000..55a5f0561 --- /dev/null +++ b/tests/engine/native_sprite_resources.lua @@ -0,0 +1 @@ +dofile('tools/sprite-runtime/resources-check.lua') diff --git a/tools/sprite-runtime/cache-check.lua b/tools/sprite-runtime/cache-check.lua new file mode 100644 index 000000000..c50904bcc --- /dev/null +++ b/tools/sprite-runtime/cache-check.lua @@ -0,0 +1,80 @@ +package.path='./?.lua;./?/init.lua;'..package.path +love=require('tests.love_stub') +local T=require('tests.harness') +local read,asset,created=require('tools.sprite-runtime.cpu-gpu').install(love) +local S=require('src.core.game3.ow_sprites');local F=require('tools.sprite-runtime.fixture') +local Runtime=require('src.mods.Runtime');local Stream=require('src.core.game3.asset_stream') +local function u16(n)return string.char(n%256,math.floor(n/256)%256)end +local function meta(id,version) + local s='SVOW'..string.char(version or 4,0)..u16(id)..u16(16)..u16(16)..u16(1)..u16(4) + if version==1 then return s end + s=s..u16(65535)..string.char(2)..u16(65535)..string.char(0) + for _=1,3 do for i=0,15 do s=s..u16(i*1057)end end + return s..string.char(254,3) +end +local f=F.new('emerald');local count=0 +local bytes={['data/generated/gba/ow/4.meta']=meta(4),['data/generated/gba/ow/4.rgba']=string.rep('\7\17\27\255',256), + ['data/generated/gba/ow/manifest.lua']='return {count=1,sprites={[4]={}},avatars={player={{state="NORMAL",male=4,female=4}}}}'} +local cache={};function cache:read(path)count=count+1;return bytes[path]end +Runtime.currentMod='provider_a';T.eq(S.install(cache),true) +local custom=assert(S.get(4));T.eq(custom.drawOffX,-2);T.eq(custom.drawOffY,3) +T.eq(read(custom.image),bytes['data/generated/gba/ow/4.rgba']) +T.eq(S.playerGraphicsId(f.game),4) +local fallback=assert(S.get(1));T.eq(fallback.graphicsId,1,'missing explicit payload falls back to its native sheet') +local reads=count +for _=1,20 do T.eq(S.get(4),custom)end +T.eq(count,reads,'cache-backed sprites do not reread disk payloads per query') +-- Explicit malformed provider data declines rather than selecting another +-- original/native record with the same requested id. +bytes['data/generated/gba/ow/0.meta']=meta(4) +T.eq(S.get(0),nil,'a declared wrong identity cannot borrow native graphic zero') +bytes['data/generated/gba/ow/5.meta']=meta(5);bytes['data/generated/gba/ow/5.rgba']='short' +T.eq(S.get(5),nil) +local malformed=require('src.compat.gen1.SpriteCache') +T.eq(malformed.decode('SVOW',4),nil);T.eq(malformed.decode(meta(4):sub(1,118),4),nil) +T.eq(malformed.decode(meta(4,1),4).drawOffX,0) +local image=custom.image;Stream.cancelOwner('provider_a') +T.eq(custom._retired,true);T.eq(not pcall(image.getWidth,image),true) +Runtime.currentMod=nil;T.eq(S.playerGraphicsId(f.game),0,'provider rollback restores the native dataset') +local base=assert(S.get(0)) +Runtime.currentMod='good';local shared=S.get(0) +Runtime.currentMod='bad';T.eq(S.get(0),shared);S.prefetch(1) +for _=1,6 do S._stream:step(0)end +T.eq(S._stream.pending[1],true) +Stream.cancelOwner('bad');T.eq(S._stream.pending[1],nil);T.eq(S.get(0),base,'another consumer retains a shared resident sprite') +Runtime.currentMod='good';Stream.cancelOwner('good');Runtime.currentMod=nil +-- Derived resources and allocations displaced from a public cache are still +-- owned by their original consumers. Rollback must not wait for global exit. +Runtime.currentMod='failed_reflection';local mirror=assert(S.getReflectionDraw(0)) +local displaced=assert(S.get(2));local injectedData=love.image.newImageData(1,1) +local injected=love.graphics.newImage(injectedData);injectedData:release() +S._loaded[2]={image=injected,quads={},frameCount=1} +Stream.cancelOwner('failed_reflection') +T.eq(mirror._retired,true);T.eq(displaced._retired,true) +T.eq(S._reflectionLoaded[0],nil);T.eq(injected:getWidth(),1,'injected images remain caller-owned') +T.eq(S.get(0),base,'engine-held base survives a failed derived-resource consumer') +injected:release();S._loaded[2]=nil;Runtime.currentMod=nil +Runtime.currentMod='reflection_a';local sharedMirror=assert(S.getReflectionDraw(0)) +Runtime.currentMod='reflection_b';T.eq(S.getReflectionDraw(0),sharedMirror) +Stream.cancelOwner('reflection_a');T.eq(sharedMirror._retired,nil,'a second reflection consumer retains its texture') +Stream.cancelOwner('reflection_b');T.eq(sharedMirror._retired,true);Runtime.currentMod=nil +-- A provider can prepare assets before Game exists; no session is invented. +S.release();package.loaded['src.core.Game']=nil;Runtime.currentMod='preview' +T.eq(S.install(cache),true);local before=assert(S.get(4));T.eq(package.loaded['src.core.Game'],nil) +S.release();T.eq(before._retired,true);Runtime.currentMod=nil +-- Untrusted manifest chunks have an empty environment and a bounded VM slice. +local hook,mask,interval=debug.gethook() +local bad={read=function(_,path)if path:find('manifest',1,true)then return 'while true do end'end end} +T.eq(S.install(bad),false);T.eq(debug.gethook(),hook,'manifest errors restore the previous debug hook') +S.release() +bad.read=function()return 'return {danger = os.execute("false")}'end +T.eq(S.install(bad),false);S.release() +package.loaded['src.core.Game']=f.game +-- No failed upload is allowed to retain a partial image, quad or data block. +local oldImage=love.graphics.newImage +love.graphics.newImage=function()error('intentional texture upload failure')end +T.eq(S.get(2),nil);T.check(S.errors[2]:find('intentional texture upload failure',1,true)~=nil) +love.graphics.newImage=oldImage;S.invalidate();T.check(S.get(2)~=nil) +S.release() +for _,object in ipairs(created)do T.eq(object.dead,true,'every owned allocation is released, including aborted jobs')end +T.finish('sprite cache providers and resource owners') diff --git a/tools/sprite-runtime/cpu-gpu.lua b/tools/sprite-runtime/cpu-gpu.lua new file mode 100644 index 000000000..d0c534dac --- /dev/null +++ b/tools/sprite-runtime/cpu-gpu.lua @@ -0,0 +1,29 @@ +local M={} +function M.install(love) + local read,created=require('tools.native-atlas.cpu-gpu').install(love) + local new=love.image.newImageData;local probe=new(1,1);local Data=getmetatable(probe);probe:release() + local files={} + function Data:clone()return new(self:getWidth(),self:getHeight(),'rgba8',self:getString())end + function Data:mapPixel(fn) + local pixels={};local bytes=self:getString() + for y=0,self.h-1 do for x=0,self.w-1 do + local at=(y*self.w+x)*4+1;local r,g,b,a=bytes:byte(at,at+3) + r,g,b,a=fn(x,y,r/255,g/255,b/255,a/255) + pixels[#pixels+1]=string.char(math.floor(r*255+.5),math.floor(g*255+.5),math.floor(b*255+.5),math.floor(a*255+.5)) + end end + self.bytes=table.concat(pixels) + end + love.image.newImageData=function(w,h,format,bytes) + if type(w)=='string'then local f=assert(files[w],'unknown CPU image '..w);return new(f.w,f.h,'rgba8',f.bytes)end + return new(w,h,format,bytes) + end + love.graphics.newQuad=function(x,y,w,h,tw,th) + local q={x=x,y=y,w=w,h=h,tw=tw,th=th};created[#created+1]=q + function q:release()assert(not self.dead,'quad released twice');self.dead=true end + function q:getViewport()assert(not self.dead,'quad released');return self.x,self.y,self.w,self.h end + return q + end + love.graphics.getSystemLimits=function()return {texturesize=16384}end + return read,function(path,w,h,bytes)files[path]={w=w,h=h,bytes=bytes}end,created +end +return M diff --git a/tools/sprite-runtime/fixture.lua b/tools/sprite-runtime/fixture.lua new file mode 100644 index 000000000..8d8a7ad7f --- /dev/null +++ b/tools/sprite-runtime/fixture.lua @@ -0,0 +1,42 @@ +local F={} +function F.new(edition) + local V=require('src.core.GameVersion');V.set(edition) + local Sheets=require('src.import.Gen3ObjectSheets') + local records={} + local function record(id,n) + local parts={} + for frame=0,n-1 do for y=0,15 do + local row={};for x=0,15 do row[x+1]=string.char((frame*3+x+y*5+id)%16)end;parts[#parts+1]=table.concat(row) + end end + local palette,mapped={},{} + for i=0,15 do palette[i]=i+i*32+i*1024;mapped[i]=i*1024 end + return {graphicsId=id,width=16,height=16,frameCount=n,indexed=table.concat(parts),inanimate=false, + drawOffX=-2,drawOffY=3,palette=palette,paletteTag=4,paletteSlot=2, + reflectionPaletteTag=5,reflectionPaletteMappedTag=5,mappedReflectionPalette=mapped} + end + records[0]=record(0,18);records[1]=record(1,9);records[2]=record(2,3);records[3]=record(3,12) + local native={} + local r=records[0] + r.native={key='SPRITE_G3_000',width=16,height=16,defaultWidth=16,defaultHeight=16, + image='assets/generated/overworld/g3_000.png',frames=9,walker=true, + order={0,1,2,3,5,7,4,6,8},runKey='SPRITE_G3_000_RUN',runImage='assets/generated/overworld/g3_000_run.png', + runWidth=16,runHeight=16,runFrames=9,runOrder={9,12,15,10,13,16,11,14,17}} + for _,suffix in ipairs({'','_RUN'})do + local key='SPRITE_G3_000'..suffix + native[key]={id=key,image='assets/generated/overworld/g3_000'..(suffix==''and''or'_run')..'.png',frames=9,frameWidth=16,frameHeight=16,walker=true} + end + local avatars + if edition~='firered'and edition~='leafgreen'then avatars={player={ + {state='NORMAL',male=0,female=89},{state='MACH_BIKE',male=1,female=90}, + {state='ACRO_BIKE',male=63,female=91},{state='SURFING',male=2,female=92}, + {state='UNDERWATER',male=111,female=112},{state='FIELD_MOVE',male=3,female=93}, + {state='FISHING',male=137,female=138},{state='WATERING',male=191,female=192}}}end + local meta={schema=Sheets.SCHEMA,edition=edition,romSha1=V.info(edition).sha1,objects=records,avatars=avatars,count=4} + local map={id='SPRITE_TEST',def={mapType='ROUTE'}} + local player={cellX=4,cellY=4,px=64,py=64,elevation=3,facing='down'} + local world={isOverworld=true,map=map,player=player,entities={player}} + local game={data={gen3_object_sheets=meta,sprites=native,maps={SPRITE_TEST=map.def}},save={player={gender=0},onBike=false},stack={states={world}}} + package.loaded['src.core.Game']=game + return {game=game,world=world,player=player,meta=meta,records=records} +end +return F diff --git a/tools/sprite-runtime/host/conf.lua b/tools/sprite-runtime/host/conf.lua new file mode 100644 index 000000000..4e1647324 --- /dev/null +++ b/tools/sprite-runtime/host/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + t.identity=assert(os.getenv('POKEPORT_IDENTITY'));assert(t.identity:match('^oasc%-gen2recomp%-qa%-sprite%-gpu%-')) + t.version='12.0';t.gammacorrect=false + t.window.width,t.window.height=320,240;t.window.vsync=0;t.window.highdpi=false + t.modules.audio,t.modules.joystick,t.modules.physics=false,false,false + t.graphics.renderers={assert(os.getenv('POKEPORT_GRAPHICS_BACKEND'))} +end diff --git a/tools/sprite-runtime/host/main.lua b/tools/sprite-runtime/host/main.lua new file mode 100644 index 000000000..e31e73129 --- /dev/null +++ b/tools/sprite-runtime/host/main.lua @@ -0,0 +1,62 @@ +io.stdout:setvbuf('no') +local root=assert(os.getenv('SPRITE_RUNTIME_ENGINE')) +package.path=root..'/?.lua;'..root..'/?/init.lua;'..package.path +function love.errorhandler(err)io.stderr:write(debug.traceback(tostring(err),2)..'\n');return function()return 1 end end +local function read(path)local f=assert(io.open(path,'rb'));local b=f:read('*a');f:close();return b end +local function hash(bytes)return love.data.hash('sha256',bytes):gsub('.',function(c)return('%02x'):format(c:byte())end)end +function love.load() + local identity=assert(os.getenv('POKEPORT_IDENTITY'));assert(love.filesystem.getIdentity()==identity) + local g=love.graphics;local renderer,version,vendor,device=g.getRendererInfo() + local backend=assert(os.getenv('POKEPORT_GRAPHICS_BACKEND'));assert(renderer:lower():find(backend,1,true)) + local T=require('tests.harness').suite('owned sprite GPU resources ('..backend..')') + local readbacks=0 + local function readImage(image) + local w,h=image:getDimensions();local canvas=g.newCanvas(w,h,{format='rgba8',dpiscale=1,msaa=0}) + g.push('all');g.setCanvas(canvas);g.origin();g.setShader();g.setColor(1,1,1,1) + g.setBlendMode('replace','premultiplied');g.clear(0,0,0,0);g.draw(image,0,0);g.pop() + local data=g.readbackTexture and g.readbackTexture(canvas)or canvas:newImageData() + local bytes=data:getString();data:release();canvas:release();readbacks=readbacks+1;return bytes + end + local function asset(path,w,h,bytes) + assert(path:match('^sprite%-tests/'),'only own QA image fixtures may be written') + love.filesystem.createDirectory('sprite-tests') + local data=love.image.newImageData(w,h,'rgba8',bytes);data:encode('png',path);data:release() + end + require('tools.sprite-runtime.resources').run(T,readImage,asset) + local J=require('src.link.Json');local V=require('src.core.GameVersion');local S=require('src.core.game3.ow_sprites') + local inputs=J.decode(read(assert(os.getenv('SPRITE_RUNTIME_INPUTS')))) + local expected=J.decode(read(assert(os.getenv('SPRITE_RUNTIME_EXPECTED')))) + local report={backend=backend,renderer=renderer,version=version,vendor=vendor,device=device, + identity=identity,editions={},playerProfilesUsed=false,fullOriginalModAccepted=false} + for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'})do + V.set(edition);S.release() + local manifest=J.decode(read(root..'/tools/rom_manifest_'..edition..'.json')) + local raw=read(inputs[edition]);local sha=love.data.hash('sha1',raw):gsub('.',function(c)return('%02x'):format(c:byte())end) + assert(sha==manifest.romSha1,'cartridge identity differs') + local imported={};local x=require('src.import.RomExtractorGen3').new(raw,V.info(edition),manifest) + x.write=function(_,name,value)imported[name]=value end;x.objectGfxSheet=function()return nil end + x:extractOverworldSprites() + local game={data={gen3_object_sheets=assert(imported.gen3_object_sheets),sprites=imported.sprites},save={player={gender=0}}} + package.loaded['src.core.Game']=game + local result={sheets=0,frames=0,pixels=0,different={}} + for key,prior in pairs(expected.editions[edition].objects)do + local id=assert(tonumber(key));local sprite,why=S.get(id);assert(sprite,why) + local pixels=readImage(sprite.image) + if hash(pixels)~=prior.rgba then result.different[#result.different+1]={id=id,field='gpu pixels'}end + T.eq(sprite.width,prior.width);T.eq(sprite.height,prior.height);T.eq(sprite.frameCount,prior.frames) + T.eq(sprite.drawOffX,prior.drawOffX);T.eq(sprite.drawOffY,prior.drawOffY) + T.eq(pixels,sprite.imageData:getString(),'GPU and owned CPU bytes agree') + for frame=0,sprite.frameCount-1 do + local qx,qy,qw,qh=sprite.quads[frame]:getViewport() + T.eq(qx,0);T.eq(qy,frame*sprite.height);T.eq(qw,sprite.width);T.eq(qh,sprite.height) + end + result.sheets=result.sheets+1;result.frames=result.frames+sprite.frameCount;result.pixels=result.pixels+#pixels/4 + if result.sheets%32==0 then print('SPRITE_GPU_PROGRESS',backend,edition,result.sheets,#result.different)end + end + report.editions[edition]=result;assert(#result.different==0,'original sprite texture pixels differ') + S.release();collectgarbage('collect') + end + T.finish();report.checks=T.checks;report.readbacks=readbacks;report.status='pass' + local f=assert(io.open(assert(os.getenv('SPRITE_RUNTIME_OUT'))..'/result.json','w'));f:write(J.encode(report));f:close() + love.event.quit(0) +end diff --git a/tools/sprite-runtime/lifecycle-check.lua b/tools/sprite-runtime/lifecycle-check.lua new file mode 100644 index 000000000..71a36970a --- /dev/null +++ b/tools/sprite-runtime/lifecycle-check.lua @@ -0,0 +1,70 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +require('tools.sprite-runtime.cpu-gpu').install(love) +local f=require('tools.session-runtime.fixture').new('emerald') +local native=f.game +local texture=require('tools.sprite-runtime.fixture').new('emerald') +package.loaded['src.core.Game']=native +native.data.gen3_object_sheets=texture.meta;native.data.sprites=texture.game.data.sprites +local Json=require('src.link.Json') +local api=tonumber(os.getenv('SESSION_RUNTIME_API'))or require('src.core.Version').modApi +assert(api==2 or api==3) +local files={} +local function mod(id,priority,body) + files['mods/'..id..'/manifest.json']=Json.encode({id=id,name=id,version='1.0.0',api=api,entry='main.lua',games={'gen3'}, + priority=priority,permissions={'engine_internals'}}) + files['mods/'..id..'/main.lua']=body +end +mod('bad_sprite',200,[[ + local S=require('src.core.game3.ow_sprites') + S.prefetch(1) + S.get(2) + S.get=function()error('failed sprite wrapper survived')end + error('intentional sprite entry failure') +]]) +mod('source_sprite',100,[[ + local mod=... + require('src.core.Game3') + local S=require('src.core.game3.ow_sprites') + local original=S.get;mod.exports.base=original + S.get=function(...)mod.exports.calls=(mod.exports.calls or 0)+1;return original(...)end + mod.exports.observe=function()return S.get(0)end + mod.events:on('game.ready',function(p)mod.exports.game=p.game end) +]]) +mod('native_sprite_peer',0,[[ + local mod=... + mod.events:on('game.ready',function(p)mod.exports.game=p.game end) +]]) +mod('late_sprite',300,[[ + local mod=... + local S=require('src.core.game3.ow_sprites') + S.prefetch(3) + S.get=function()error('late-validation sprite wrapper survived')end + -- This entry returns successfully; only the subsequent script validation + -- can reject it and retire its wrapper and queued resource. + mod.content.map_scripts:register('SPRITE_VALIDATION',{ + scripts={invalid={{'sprite_intentionally_unknown_command'}}} + }) +]]) +local run=T.sdk.loadMods({'mods/bad_sprite','mods/source_sprite','mods/native_sprite_peer','mods/late_sprite'}, + {fs=T.sdk.memfs(files),data=T.fixtures.fresh()}) +native.mods=run.loader;run.loader.game=native +T.eq(#run.errors,2,'entry failure and late script validation both reject their owners') +local S=require('src.core.game3.ow_sprites') +T.eq(S._stream.pending[1],nil,'failed owner cannot leave a pending texture job') +T.eq(S._loaded[2],nil,'failed owner does not leave a resident sprite') +T.eq(S._stream.pending[3],nil,'late validation cannot leave a pending texture job') +T.eq(run.loader.exports.late_sprite,nil,'late validation removes the successful entry export') +run.loader.events:emit('game.ready',setmetatable({game=native},{__index=native})) +local src,peer=run.loader.exports.source_sprite,run.loader.exports.native_sprite_peer +T.eq(src.game,require('src.core.Game3').forNative(native));T.eq(peer.game,native) +local calls=src.calls or 0 +local view=assert(src.observe());T.eq(view.frameCount,18);T.eq(src.calls,calls+1,'the restored wrapper runs once for this request') +local image=view.image;local originalImage=native.data.sprites.SPRITE_G3_000.image +require('src.mods.Loader').teardown() +T.eq(view._retired,true);T.eq(not pcall(image.getWidth,image),true) +T.eq(native.data.sprites.SPRITE_G3_000.image,originalImage,'native records are untouched by resource teardown') +T.eq(S.get,src.base,'successful source sprite wrappers unwind') +T.eq(S._cache,nil) +run.release() +T.finish('sprite resources through SDK lifecycle API '..api) diff --git a/tools/sprite-runtime/pose-oracle.lua b/tools/sprite-runtime/pose-oracle.lua new file mode 100644 index 000000000..b6ce25909 --- /dev/null +++ b/tools/sprite-runtime/pose-oracle.lua @@ -0,0 +1,30 @@ +-- Run as two isolated processes with an explicit source root. The reference +-- performs its real profile lookup; no fabricated expected frame tables. +local code=assert(arg[1]);package.path=code..'/?.lua;'..code..'/?/init.lua;'..package.path +local mode=assert(arg[2]);local path=assert(arg[3]) +local S=require(mode=='source'and'src.core.game3.ow_sprites'or'src.compat.gen1.SpritePose') +local V=require('src.core.GameVersion');local Json=require('src.link.Json') +local out={};local count=0 +for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'})do + V.set(edition) + local rows={};out[edition]=rows + for _,n in ipairs({1,3,5,9,10,12,18,32})do for _,inanimate in ipairs({false,true})do + local sprite={frameCount=n,inanimate=inanimate} + for _,facing in ipairs({'down','up','left','right'})do for _,phase in ipairs({0,1,false,true})do for _,flip in ipairs({false,true})do + local options={{},{bow=true},{fieldMove=true},{fieldMove=true,fieldMoveFrame=2},{fishing=true,fishFrame=2},{running=0},{running=1}} + for _,frame in ipairs({-1,0,n-1,n+3})do options[#options+1]={frame=frame}end + for _,opts in ipairs(options)do + local a,b=S.pose(sprite,facing,phase,flip,opts);rows[#rows+1]={a,b};count=count+1 + end + end end end + end end + for t=-2,180 do + for _,kind in ipairs({'normal','vs_seeker','vs_seeker_bike'})do rows[#rows+1]={S.fieldMoveFrame(t,kind)};count=count+1 end + for _,dir in ipairs({'down','up','left','right'})do + for _,anim in ipairs({'takeout','putaway','hooked'})do rows[#rows+1]={S.fishingFrame(dir,anim,t)};count=count+1 end + for frame=0,11 do rows[#rows+1]={S.fishingAbsFrame(dir,frame),S.fishingOffset(frame,dir)};count=count+1 end + end + end +end +local f=assert(io.open(path,'w'));f:write(Json.encode({count=count,editions=out}));f:close() +print('POSE_ORACLE',mode,count) diff --git a/tools/sprite-runtime/resources-check.lua b/tools/sprite-runtime/resources-check.lua new file mode 100644 index 000000000..74274a731 --- /dev/null +++ b/tools/sprite-runtime/resources-check.lua @@ -0,0 +1,7 @@ +package.path='./?.lua;./?/init.lua;'..package.path +love=require('tests.love_stub') +local T=require('tests.harness') +local read,asset=require('tools.sprite-runtime.cpu-gpu').install(love) +T.eq(package.loaded['src.core.Game'],nil) +require('tools.sprite-runtime.resources').run(T,read,asset) +T.finish('owned sprite resources') diff --git a/tools/sprite-runtime/resources.lua b/tools/sprite-runtime/resources.lua new file mode 100644 index 000000000..c8c64c556 --- /dev/null +++ b/tools/sprite-runtime/resources.lua @@ -0,0 +1,114 @@ +local M={} +local function expected(r) + local parts={} + for i=1,#r.indexed do + local n=r.indexed:byte(i) + if n==0 then parts[i]='\0\0\0\0' + else local v=math.floor(n*255/31+.5);parts[i]=string.char(v,v,v,255)end + end + return table.concat(parts) +end +function M.run(T,read,asset) + local S=require('src.core.game3.ow_sprites');local Stream=require('src.core.game3.asset_stream') + local Assets=require('src.render.Assets');local F=require('tools.sprite-runtime.fixture') + local originalGame=package.loaded['src.core.Game'];local sourcePlayer=package.loaded['src.core.game3.player'] + package.loaded['src.core.game3.player']=nil + local function dead(o)return not pcall(function()return o:getWidth()end)end + for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'})do + S.release();local f=F.new(edition) + T.eq(S.ready(),true);T.eq(S._stream.context,nil,'the stream protocol cannot lend its native context') + T.eq(S._manifest.sprites[0].frameCount,18) + local base=assert(S.get(0));local pixels=expected(f.records[0]) + T.eq(base.width,16);T.eq(base.height,16);T.eq(base.frameCount,18);T.eq(base.inanimate,false) + T.eq(base.imageData:getString(),pixels);T.eq(read(base.image),pixels,'GPU upload preserves original image-table pixels') + T.eq(S.get(0),base);T.eq(S._loaded[0],base) + for frame=0,17 do + local x,y,w,h=base.quads[frame]:getViewport();T.eq(x,0);T.eq(y,frame*16);T.eq(w,16);T.eq(h,16) + end + base.palette[1]=999;T.eq(f.records[0].palette[1],1057,'source palette records are detached') + base.palette[1]=1057 + T.eq(S.get(-1),nil);T.eq(S.get(0/0),nil);T.eq(S.get(65536),nil);T.eq(S.get(999),nil) + -- Source queue work is cooperative, and cancelled work is never published. + T.eq(S.prefetch(1,4),true);T.eq(S._stream.pending[1],true) + S._stream:step(0);T.eq(S._loaded[1],nil) + S._stream:retain({[0]=true});T.eq(S._stream.pending[1],nil);T.eq(S._loaded[1],nil) + S.prefetch(1,4);local steps=0 + while S._stream.pending[1]do S._stream:step(0);steps=steps+1;assert(steps<100)end + T.check(steps>2,'data, texture and quad preparation use separate slices') + local npc=assert(S.get(1));T.eq(read(npc.image),expected(f.records[1]));T.eq(S.peekDraw(1),npc) + T.eq(S.peekDraw(2),nil);T.eq(S._stream.pending[2],true) + local mount=assert(S.get(2));T.eq(mount.frameCount,3) + -- Exact palette replacement, no silent successful no-op and no mutation + -- of the base pixels that another source/native consumer already holds. + local red={{255,0,0}};T.eq(S.setObjectPalette(0,'red',red,{{8,8,8}}),true) + local alt=assert(S.getDraw(0));T.check(alt~=base);T.eq(S.get(0),base) + T.eq(S.objectPaletteKey(0),'red');T.eq(alt.imageData:getString():sub(5,8),string.char(255,0,0,255)) + T.eq(base.imageData:getString(),pixels);T.eq(read(alt.image),alt.imageData:getString()) + T.eq(S.setObjectPalette(0,'no match',{{1,2,3}},{{70,11,240}}),false) + T.eq(S.objectPaletteKey(0),'red','a rejected substitution cannot discard the old valid palette') + T.eq(S.setObjectPalette(0,'bad',{{0/0,2,3}},{{8,8,8}}),false) + local altImage=alt.image;T.eq(S.clearObjectPalette(0),true);T.eq(dead(altImage),true);T.eq(S.getDraw(0),base) + local mirror=assert(S.getReflectionDraw(0));T.eq(S.getReflectionDraw(0),mirror) + T.eq(read(mirror.image),mirror.imageData:getString());T.eq(base.imageData:getString(),pixels) + -- Native image overrides are projected through the imported frame map, + -- while the engine's shared image cache is never used or released. + local nativeFile='sprite-tests/'..edition..'-native.png';local runFile='sprite-tests/'..edition..'-run.png' + local frames={};local runs={} + for frame=0,8 do + frames[#frames+1]=string.rep(string.char(20+frame,70,90,255),256) + runs[#runs+1]=string.rep(string.char(120+frame,80,100,255),256) + end + asset(nativeFile,16,144,table.concat(frames));asset(runFile,16,144,table.concat(runs)) + f.game.data.sprites.SPRITE_G3_000.image=nativeFile + f.game.data.sprites.SPRITE_G3_000_RUN.image=runFile + local oldImage=base.image;local replaced=assert(S.get(0));T.check(replaced~=base);T.eq(dead(oldImage),true) + for frame,original in ipairs(f.records[0].native.order)do + T.eq(replaced.imageData:getString():sub(original*1024+1,(original+1)*1024),frames[frame],'native walk-frame projection') + end + for frame,original in ipairs(f.records[0].native.runOrder)do + T.eq(replaced.imageData:getString():sub(original*1024+1,(original+1)*1024),runs[frame],'native run-frame projection') + end + T.eq(read(replaced.image),replaced.imageData:getString()) + -- An original-order override keeps every slot, including poses absent + -- from normalized walk/run sheets. Use the real asset-search bridge. + local oldLoader=Assets.loader + local rawFile='sprite-tests/'..edition..'-source.png' + local rawBytes=string.rep('\19\29\39\255',256*18) + local replacedImage=replaced.image + asset(rawFile,16,288,rawBytes) + Assets.loader={overrideOrder=function()return{}end,derivedPath=function(_,rel) + if rel=='overworld/g3_000_source.png'then return rawFile end + end} + local raw=assert(S.get(0));T.eq(raw.frameCount,18);T.eq(read(raw.image),rawBytes) + T.eq(dead(replacedImage),true,'a changed asset search retires the previous resource') + Assets.loader=oldLoader + -- Changing a native definition to a static sheet retains that behavior. + local static='sprite-tests/'..edition..'-static.png';asset(static,16,16,string.rep('\12\23\34\255',256)) + f.game.data.sprites.SPRITE_G3_000.image=static;f.game.data.sprites.SPRITE_G3_000.frames=1 + local still=assert(S.get(0));T.eq(still.frameCount,1);T.eq(still.inanimate,true) + T.eq(S.pose(still,'right',1,false),0);T.eq(read(still.image),string.rep('\12\23\34\255',256)) + -- Base metadata cannot be silently reused after any owning cohort changes. + for _,replace in ipairs({ + function()f.game.save={player={gender=1}}end, + function()f.game.mods={}end, + function()local data={};for k,v in pairs(f.game.data)do data[k]=v end;f.game.data=data end, + function()local meta={};for k,v in pairs(f.game.data.gen3_object_sheets)do meta[k]=v end;f.game.data.gen3_object_sheets=meta end, + })do + local old=assert(S.get(1));local image=old.image;replace();local next=assert(S.get(1));T.check(next~=old);T.eq(dead(image),true) + end + local old=assert(S.get(1));local image,data=old.image,old.imageData + Assets.invalidate();T.eq(dead(image),true);T.eq(dead(data),true);T.check(S.get(1)~=old) + -- Avatar observations use the native state without touching its timers. + f.game.save.player.gender=0 + T.eq(S.playerGraphicsId(f.game),0) + f.player.fishing=true;T.eq(S.playerGraphicsId(f.game),(edition=='firered'or edition=='leafgreen')and 4 or 137) + f.player.fishing=nil;f.game.save.player.gender=1;T.eq(S.playerGraphicsId(f.game),(edition=='firered'or edition=='leafgreen')and 7 or 89) + -- Earlier generations keep the native rendering path and cannot inherit + -- this GBA resource context, even while the same Game table remains live. + local held=S.get(1);local heldImage=held.image + require('src.core.GameVersion').set('crystal');T.eq(S.ready(),false);T.eq(dead(heldImage),true);T.eq(S.get(1),nil) + end + package.loaded['src.core.game3.player']=sourcePlayer + package.loaded['src.core.Game']=originalGame;S.release() +end +return M