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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions docs/native-sprite-runtime.md
Original file line number Diff line number Diff line change
@@ -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.
58 changes: 58 additions & 0 deletions src/compat/gen1/SpriteCache.lua
Original file line number Diff line number Diff line change
@@ -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
79 changes: 79 additions & 0 deletions src/compat/gen1/SpritePose.lua
Original file line number Diff line number Diff line change
@@ -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)<math.huge and v or default
end
local function sequence(rows,t,loop)
t=math.max(0,math.floor(number(t,0)))
local total=0;for _,r in ipairs(rows)do total=total+r[2]end
if loop then t=t%total end
for _,r in ipairs(rows)do if t<r[2]then return r[1],false end;t=t-r[2]end
return rows[#rows][1],true
end
local field={{0,4},{1,4},{2,4},{3,4},{4,8}}
local seeker={{0,4},{1,4},{5,4},{6,4}}
local bike={{0,4},{1,4},{2,4},{3,4}}
for _=1,7 do seeker[#seeker+1]={7,4};seeker[#seeker+1]={8,4};bike[#bike+1]={4,4};bike[#bike+1]={5,4}end
for _,r in ipairs({{6,4},{1,4},{0,4}})do seeker[#seeker+1]=r end
for _,r in ipairs({{3,4},{2,4},{1,4},{0,4}})do bike[#bike+1]=r end
local take={{0,4},{1,4},{2,4},{3,4}}
local put={{3,4},{2,6},{1,6},{0,6}}
local sidePut={{3,4},{2,4},{1,4},{0,4}}
local hooked={{2,6},{3,6},{2,6},{3,6},{3,30}}
function P.fieldMoveFrame(t,kind)
local frame=sequence(kind=='vs_seeker'and seeker or kind=='vs_seeker_bike'and bike or field,t)
return frame
end
function P.fishingFrame(facing,anim,t)
return sequence(anim=='hooked'and hooked or anim=='putaway'and((facing=='left'or facing=='right')and sidePut or put)or take,t,anim=='hooked')
end
function P.fishingAbsFrame(facing,frame)
return (facing=='left'or facing=='right')and number(frame,3)or(facing=='up'and 4 or 8)+number(frame,3)
end
function P.fishingOffset(frame,facing)
return (frame==1 or frame==2 or frame==3)and(facing=='left'and -8 or 8)or 0,
frame==5 and -8 or(frame==10 or frame==11)and 8 or 0
end
function P.pose(sprite,facing,phase,flip,opts)
if not sprite then return 0,false end
local n=sprite.frameCount or 1;local a=axis[facing]or 0;local mirrored=facing=='right';opts=opts or{}
if opts.fishing and n>=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
Loading