diff --git a/docs/native-object-sheets.md b/docs/native-object-sheets.md
new file mode 100644
index 000000000..7678498c7
--- /dev/null
+++ b/docs/native-object-sheets.md
@@ -0,0 +1,76 @@
+# Original object sheets alongside native actor art
+
+The GBA sprite importer normalizes cartridge facings into native standing,
+walking and second-step rows, with separate running sheets. Consumers of an
+original image-table interface need different row numbers and additional
+poses. Reusing the native image directly loses those semantics.
+
+`Gen3ObjectSheets` adds an independent compact cache containing the original
+row-major indexed pixels, all picture-table slots, exact 16-bit palette words,
+dimensions, inanimate flags, subsprite anchors and reflection metadata. Each
+pixel uses one byte rather than four RGBA bytes or a Lua number/table slot.
+Tile allocation is compiled once per sheet, and byte-pair lookup expands tiled
+4bpp data without allocating a table entry for every pixel. RGBA expansion is
+available on demand for a later owned texture upload.
+
+Schema 2 also retains each normalized native sheet's original frame indices,
+final image/geometry defaults and running-sheet mapping. An owned resource
+adapter can project a native asset override into original slots without
+assuming that row numbers match. Special native stage rewrites retain their
+actual final definitions rather than guessed default filenames. Hoenn's
+player/rival avatar state tables, link pairs and gender-specific flags are
+read from edition-specific cartridge offsets.
+
+Picture arrays end at their own boundaries, including lists named by adjacent
+graphics-info records. Mixed-size arrays retain their slots; a slot for a
+different graphics row's dimensions remains transparent instead of consuming
+adjacent art. This preserves all nine berry-stage slots and their proper
+small/tall projections. Ruby/Sapphire's eight-byte subsprite records and the
+four-byte records of the later editions are handled separately.
+
+The cache declares its schema, edition and cartridge SHA-1. The original
+interface uses paired reflection metadata on FireRed, LeafGreen, Ruby and
+Sapphire and a raw reflection profile on Emerald. The independently derived
+native reflection palette is retained under `nativeReflectionPaletteTag` in
+both profiles. This does not change the native engine's reflection rules.
+
+`extractOverworldSprites` writes `gen3_object_sheets.lua` in addition to its
+existing outputs. Existing normalized sprite records, image names, frame
+numbers, running sheets and renderer behavior are unchanged. Data loads this
+optional module only for a GBA dataset; an earlier generation cannot inherit
+it from a cache overlay. The GBA importer's required-file check requests the
+normal automatic re-import for an older cache missing this module. No global
+cache-format bump, mod-package conversion or player-save change is involved.
+The GBA-only completion marker includes the object-sheet schema, so a cache
+from an earlier schema follows the same automatic regeneration path even if
+the module already exists. Earlier-generation markers remain unchanged.
+
+## Verification
+
+`tools/object-sheets/check.lua` exercises tile order, alpha, palette expansion,
+noncontiguous pictures, mixed-size stages, both subsprite layouts, actual cache
+serialization and old/new cache readiness across five GBA and five earlier
+editions. The fixture uses the actual native marker producer, without reading
+or writing an installed cache or changing the production cache-format API.
+
+The optional `oracle-host` compares the pinned independent extraction contract
+against all five cartridge inputs. It compares every original indexed/RGBA
+pixel, palette word, frame count and anchor. Its baseline mode runs the previous
+native importer and checks that the new importer's normalized sprite records
+and composed image pixels are identical. It also compares avatar tables and
+checks native walk/run frame mappings against independently extracted original
+indices. Native color expansion floors 5-bit channels while the original-order
+interface rounds them; the mapping comparison preserves each rule. Special
+native geometry/stage rewrites and the additional native table rows are
+reported separately. Generated object modules also round-trip through the actual Lua
+cache writer and LuaJIT compiler. Emerald's seven additional native table rows
+are reported separately; they do not replace any original sheet.
+
+## Remaining runtime work
+
+This adds the data required by the sprite interface. It does not yet supply
+`src.core.game3.ow_sprites`, GPU textures/quads, palette-override resources,
+asset-override projection, prefetch/lifetime services or the original renderer.
+Full unchanged package startup, special live actor poses and gameplay/GPU
+acceptance remain open. No full compatibility or performance claim follows
+from a CPU pixel comparison.
diff --git a/src/core/Data.lua b/src/core/Data.lua
index 26a842ce5..1e3cd4f1d 100644
--- a/src/core/Data.lua
+++ b/src/core/Data.lua
@@ -376,13 +376,13 @@ local OPTIONAL = { "audio", "palettes", "icons", "unown_puzzle", "unown_dex",
"gen3_frontier_speech",
-- the in-game trade sequence's art and tables
-- (Emerald/FireRed, src/import/Gen3TradeArt.lua)
- "gen3_trade_art" }
+ "gen3_trade_art", "gen3_object_sheets" }
-- Gen 3's own optional tables. On a Gen 4 / Black-White cache nothing writes
-- them, and the additive overlay would hand the root cache's copy (another
-- cartridge's) to the Sinnoh dataset -- the `icons` bug's shape
-- (tools/gen4_party_icon_check.lua section 6) -- so they are not even asked.
-local GEN3_ONLY = { "gen3_frontier_speech", "gen3_trade_art" }
+local GEN3_ONLY = { "gen3_frontier_speech", "gen3_trade_art", "gen3_object_sheets" }
-- Vanilla defaults for rules exposed through the constants registry. A
-- value has to exist before a mod can patch it; each one matches the
@@ -2184,6 +2184,10 @@ function Data:load()
end
for _, name in ipairs(OPTIONAL) do
if isRequired[name] then goto continue end
+ if name == "gen3_object_sheets" and not isGen3Cache then
+ self[name] = nil
+ goto continue
+ end
if blocked[name] then
-- Deliberately not even attempted. It would very likely SUCCEED -- the
-- root cache is Red's and the overlay does not hide it -- and the result
diff --git a/src/import/Gen3ObjectSheets.lua b/src/import/Gen3ObjectSheets.lua
new file mode 100644
index 000000000..6078f4a00
--- /dev/null
+++ b/src/import/Gen3ObjectSheets.lua
@@ -0,0 +1,209 @@
+-- Preserve cartridge object sheets in image-table order alongside the native
+-- normalized actor sheets. Compact indexed bytes keep all poses without a
+-- second renderer or a persistent RGBA copy for every object on the cartridge.
+local M={SCHEMA=2}
+local dimensions={
+ [0]={{8,8},{16,16},{32,32},{64,64}},
+ [1]={{16,8},{32,8},{32,16},{64,32}},
+ [2]={{8,16},{8,32},{16,32},{32,64}},
+}
+local pair={}; for n=0,255 do pair[n]=string.char(n%16,math.floor(n/16)) end
+local blank=string.rep('\0',8)
+local function signed(n,bits) return n>=2^(bits-1) and n-2^bits or n end
+local function range(rom,at,n) return type(at)=='number' and at>=0 and at%1==0 and at+n<=rom.size end
+local function pointer(rom,at)
+ if not range(rom,at,4) then return nil end
+ return rom:pointer(at)
+end
+local function bytes(rom,at,n)
+ if not range(rom,at,n) then return nil end
+ if rom.sub then return rom:sub(at,n) end
+ local out={}; for i=0,n-1 do out[i+1]=string.char(rom:u8(at+i)) end
+ return table.concat(out)
+end
+local function palettes(rom,spec)
+ local out={}
+ for i=0,math.min(spec.paletteCount or 64,64)-1 do
+ local at=spec.paletteTable+i*8
+ if not range(rom,at,8) or rom:u32(at)==0 then break end
+ local data=pointer(rom,at); local tag=rom:u16(at+4)
+ if data and tag~=0 and range(rom,data,32) then
+ local p={}; for c=0,15 do p[c]=rom:u16(data+c*2) end
+ out[tag]=p
+ end
+ end
+ return out
+end
+local function imageStarts(rom,spec)
+ local starts,infos={},{}
+ local function add(at)
+ if not at or infos[at] or not range(rom,at,36) or rom:u16(at)~=65535 then return false end
+ local images,anims=pointer(rom,at+28),pointer(rom,at+24)
+ if not(images and anims) then return false end
+ infos[at]=true; starts[images]=true; return true
+ end
+ for id=0,spec.count-1 do add(pointer(rom,spec.gfxTable+id*4)) end
+ -- Contiguous graphics-info records also name image lists not addressed by
+ -- the public graphics table. Their boundaries still end the preceding list.
+ local seeds={}; for at in pairs(infos) do seeds[#seeds+1]=at end
+ for _,at in ipairs(seeds) do at=at+36; while add(at) do at=at+36 end end
+ return starts
+end
+local function layout(rom,at,w,h,wide)
+ if not range(rom,at,8) then return nil end
+ local count,subs=rom:u8(at),pointer(rom,at+4)
+ local stride=wide and 8 or 4
+ if count<1 or count>64 or not subs or not range(rom,subs,count*stride) then return nil end
+ local pieces={}; local minX,minY,maxX,maxY=math.huge,math.huge,-math.huge,-math.huge
+ for i=0,count-1 do
+ local off=subs+i*stride
+ local x=wide and signed(rom:u16(off),16) or signed(rom:u8(off),8)
+ local y=wide and signed(rom:u16(off+2),16) or signed(rom:u8(off+1),8)
+ local bits=rom:u16(off+(wide and 4 or 2))
+ local row=dimensions[bits%4]; local dim=row and row[math.floor(bits/4)%4+1]
+ if not dim then return nil end
+ local p={x=x,y=y,w=dim[1],h=dim[2],tile=math.floor(bits/16)%1024}
+ pieces[#pieces+1]=p
+ minX,minY=math.min(minX,x),math.min(minY,y)
+ maxX,maxY=math.max(maxX,x+p.w),math.max(maxY,y+p.h)
+ end
+ if maxX-minX~=w or maxY-minY~=h then return nil end
+ for _,p in ipairs(pieces) do p.x,p.y=p.x-minX,p.y-minY end
+ return {pieces=pieces,x=minX+math.floor(w/2),y=minY+math.floor(h/2)}
+end
+local function tiles(w,h,arrangement)
+ local rows={}
+ for ty=0,h/8-1 do
+ local row={}; rows[ty+1]=row
+ for tx=0,w/8-1 do
+ local tile=ty*(w/8)+tx
+ if arrangement then
+ tile=false
+ for _,p in ipairs(arrangement.pieces) do
+ if tx*8>=p.x and ty*8>=p.y and tx*8
=1 and spec.count<=4096,'invalid object graphics count')
+ assert(range(rom,spec.paletteTable,8),'invalid object palette table')
+ local starts,pals=imageStarts(rom,spec),palettes(rom,spec)
+ local result={schema=M.SCHEMA,count=spec.count,edition=spec.edition,romSha1=spec.romSha1,
+ reflectionProfile=spec.reflectionProfile or 'paired',avatars=avatars(rom,spec.avatarTables),objects={},unavailable={}}
+ for id=0,spec.count-1 do
+ local ok,record=pcall(function()
+ local info=pointer(rom,spec.gfxTable+id*4)
+ assert(range(rom,info,36),'object info unavailable')
+ local w,h=math.abs(rom:s16(info+8)),math.abs(rom:s16(info+10))
+ assert(w>=8 and h>=8 and w<=128 and h<=128 and w%8==0 and h%8==0,'invalid object dimensions')
+ local images=pointer(rom,info+28); assert(images,'object image table unavailable')
+ local rawSize=w*h/2; local n=0
+ while n<32 do
+ local at=images+n*8
+ if n>0 and starts[at] or not range(rom,at,8) then break end
+ local size=rom:u16(at+4)
+ if not pointer(rom,at) or size<32 or size>32768 or size%32~=0 then break end
+ n=n+1
+ end
+ n=math.max(1,n)
+ local flags=rom:u8(info+12); local inanimate=math.floor(flags/64)%2==1
+ if inanimate and w==32 and h==16 then w=16 end
+ local arrangement=layout(rom,pointer(rom,info+20),w,h,spec.wideSubsprites)
+ local tileRows=tiles(w,h,arrangement); local expected=w*h/2; local frames={}
+ for i=0,n-1 do
+ local at=images+i*8; local data=pointer(rom,at); local size=rom:u16(at+4)
+ if size<1 or size>expected*4 then size=expected end
+ -- Shared picture tables may contain differently sized stages. They
+ -- keep their table slots, but a stage belonging to another graphics
+ -- row must not read past its own allocation as this row's image.
+ local raw=size==rawSize and data and bytes(rom,data,math.max(size,expected))
+ frames[i+1]=raw and decode(raw,w,h,tileRows) or string.rep('\0',w*h)
+ end
+ local tag,reflectTag=rom:u16(info+2),rom:u16(info+4)
+ local palette=pals[tag] or {}; for c=0,15 do palette[c]=palette[c] or 0 end
+ local nativeReflect=spec.reflectionFor and spec.reflectionFor(info)
+ -- A raw reflection profile publishes the flag/palette but leaves the
+ -- optional paired-palette projection absent. Keep the independently
+ -- derived native palette under its own name in both profiles.
+ local mapped=spec.reflectionProfile~='raw' and pals[reflectTag] and nativeReflect or nil
+ return {graphicsId=id,width=w,height=h,frameCount=n,indexed=table.concat(frames),
+ paletteTag=tag,palette=palette,paletteSlot=flags%16,inanimate=inanimate,
+ reflectionPaletteTag=reflectTag,reflectionPalette=pals[reflectTag],
+ reflectionPaletteMappedTag=mapped,mappedReflectionPalette=mapped and pals[mapped] or nil,
+ nativeReflectionPaletteTag=nativeReflect,
+ drawOffX=arrangement and arrangement.x or 0,drawOffY=arrangement and arrangement.y or 0}
+ end)
+ if ok then result.objects[id]=record else result.unavailable[id]=tostring(record) end
+ if spec.progress and id%16==15 then spec.progress(id+1,spec.count) end
+ end
+ return result
+end
+local function color(word)
+ return string.char(math.floor((word%32)*255/31+.5),math.floor((math.floor(word/32)%32)*255/31+.5),math.floor((math.floor(word/1024)%32)*255/31+.5),255)
+end
+function M.rgba(record,palette,checkpoint)
+ palette=palette or record.palette
+ assert(type(record.indexed)=='string' and #record.indexed==record.width*record.height*record.frameCount,'invalid indexed object sheet')
+ local colors={[0]='\0\0\0\0'}; for i=1,15 do colors[i]=color(palette[i] or 0) end
+ local rows={}; local stride=record.width
+ for at=1,#record.indexed,stride do
+ local row={}; for x=0,stride-1 do row[x+1]=assert(colors[record.indexed:byte(at+x)],'invalid sprite palette index') end
+ rows[#rows+1]=table.concat(row)
+ if checkpoint and #rows%32==0 then checkpoint() end
+ end
+ return table.concat(rows)
+end
+return M
diff --git a/src/import/RomExtractorGen3.lua b/src/import/RomExtractorGen3.lua
index c2ce0b55e..ebb033146 100644
--- a/src/import/RomExtractorGen3.lua
+++ b/src/import/RomExtractorGen3.lua
@@ -9305,7 +9305,7 @@ function RomExtractorGen3:extractOverworldSprites()
pixels[id] = { cells = cells, colors = colors,
width = width, height = frameHeight,
- runCells = runCells }
+ runCells = runCells, originalOrder = order, originalRunOrder = runOrder }
local key = ("SPRITE_G3_%03d"):format(id)
out[key] = {
@@ -9828,6 +9828,35 @@ function RomExtractorGen3:extractOverworldSprites()
walkers = walkers, still = still, failed = failed,
sheets = drawn, substituted = substituted }
self:write("sprites", out)
+ -- Keep original picture-table order and palette words as an additive
+ -- compact cache. Existing actor sheets and their frame numbers stay intact.
+ local edition = type(self.version) == "table" and self.version.id or self.version
+ local originalSheets = require("src.import.Gen3ObjectSheets").extract(self.rom, {
+ edition = edition, romSha1 = self.manifest.romSha1,
+ gfxTable = table_, count = count, paletteTable = palTable, paletteCount = palCount,
+ wideSubsprites = edition == "ruby" or edition == "sapphire",
+ reflectionProfile = edition == "emerald" and "raw" or "paired",
+ avatarTables = self.manifest.objectAvatars or require("src.import.gba.object_avatar_tables")[edition],
+ reflectionFor = function(info) return self:reflectionPaletteFor(info) end,
+ progress = function(current,total)
+ if self.checkpoint then self.checkpoint(("Decoding original object sheets %d/%d"):format(current,total)) end
+ end,
+ })
+ for id,cell in pairs(pixels) do
+ local record = originalSheets.objects[id]
+ if record then
+ local key = ("SPRITE_G3_%03d"):format(id)
+ record.native = { key=key, width=cell.width, height=cell.height, order=cell.originalOrder, walker=out[key] and out[key].walker,
+ image=out[key] and out[key].image, frames=out[key] and math.max(out[key].frames or 1,out[key].poseFrames or 1),
+ defaultWidth=out[key] and out[key].frameWidth, defaultHeight=out[key] and out[key].frameHeight,
+ runKey=out[key.."_RUN"] and key.."_RUN" or nil, runOrder=cell.originalRunOrder,
+ runImage=out[key.."_RUN"] and out[key.."_RUN"].image,
+ runFrames=out[key.."_RUN"] and math.max(out[key.."_RUN"].frames or 1,out[key.."_RUN"].poseFrames or 1),
+ runWidth=out[key.."_RUN"] and out[key.."_RUN"].frameWidth,runHeight=out[key.."_RUN"] and out[key.."_RUN"].frameHeight,
+ reflectionKey=out[key.."_REFLECT"] and key.."_REFLECT" or nil }
+ end
+ end
+ self:write("gen3_object_sheets", originalSheets)
Logger.info("Gen3 overworld sprites: %d rows, %d that turn and walk, %d "
.. "non-walkers, %d unreadable, %d sheets composed, %d "
.. "run-time slots standing in",
diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua
index 858974555..59464de11 100644
--- a/src/import/RomImporter.lua
+++ b/src/import/RomImporter.lua
@@ -1313,6 +1313,9 @@ local function markerFor(version)
if version == "red" or version == "blue" or version == "yellow" then
revision = "gen1-trade-art-v1:"
end
+ if GameVersion.generation(version) == 3 then
+ revision = revision .. "original-object-sheets-v" .. require("src.import.Gen3ObjectSheets").SCHEMA .. ":"
+ end
return CACHE_FORMAT .. revision .. GameVersion.info(version).sha1
end
@@ -1513,6 +1516,10 @@ local REQUIRED_FILES_GEN3 = {
-- Absent, NPC.resolveSpriteDef falls through to SPRITE_RED and the whole
-- region is populated by Kanto -- which is what it was.
"data/generated/sprites.lua",
+ -- Original object picture order, independent of normalized native poses.
+ -- Older GBA caches re-import automatically through the existing readiness
+ -- check; no mod package or player save needs conversion.
+ "data/generated/gen3_object_sheets.lua",
-- THE PARTY MENU'S OWN ART, and required for the same reason as the font:
-- Data no longer blocks `icons` on a Gen 3 cache, so a cache that does not
-- carry its own resolves to RED'S and every Hoenn species wears a Kanto
diff --git a/src/import/gba/object_avatar_tables.lua b/src/import/gba/object_avatar_tables.lua
new file mode 100644
index 000000000..24dcc762c
--- /dev/null
+++ b/src/import/gba/object_avatar_tables.lua
@@ -0,0 +1,36 @@
+-- Cartridge offsets for the original avatar interface, independently
+-- verified against each supported USA Rev 0 input. No runtime simulation.
+return {
+ emerald={
+ link_rs=4814090,
+ genders=2,
+ stateNames={"NORMAL","MACH_BIKE","ACRO_BIKE","SURFING","UNDERWATER","FIELD_MOVE","FISHING","WATERING"},
+ state_flags=4814092,
+ state_flag_count=5,
+ player=4814072,
+ player_states=8,
+ rival=4814056,
+ rival_states=8,
+ link_frlg=4814088,
+ },
+ ruby={
+ genders=2,
+ player=3210324,
+ player_states=8,
+ rival=3210308,
+ rival_states=8,
+ stateNames={"NORMAL","MACH_BIKE","ACRO_BIKE","SURFING","UNDERWATER","FIELD_MOVE","FISHING","WATERING"},
+ state_flags=3210340,
+ state_flag_count=5,
+ },
+ sapphire={
+ genders=2,
+ player=3210212,
+ player_states=8,
+ rival=3210196,
+ rival_states=8,
+ stateNames={"NORMAL","MACH_BIKE","ACRO_BIKE","SURFING","UNDERWATER","FIELD_MOVE","FISHING","WATERING"},
+ state_flags=3210228,
+ state_flag_count=5,
+ },
+}
diff --git a/tests/engine/native_object_sheets.lua b/tests/engine/native_object_sheets.lua
new file mode 100644
index 000000000..c54f794cb
--- /dev/null
+++ b/tests/engine/native_object_sheets.lua
@@ -0,0 +1 @@
+dofile('tools/object-sheets/check.lua')
diff --git a/tools/object-sheets/check.lua b/tools/object-sheets/check.lua
new file mode 100644
index 000000000..a87650494
--- /dev/null
+++ b/tools/object-sheets/check.lua
@@ -0,0 +1,133 @@
+package.path='./?.lua;./?/init.lua;'..package.path
+local T=require('tests.harness')
+local Sheets=require('src.import.Gen3ObjectSheets')
+local Rom=require('src.import.RomGba')
+T.eq(package.loaded['src.core.Game'],nil,'sheet decoder is independent of live sessions')
+local function fixture(w,h,wide)
+ local memory={}
+ for i=1,8192 do memory[i]=0 end
+ local function u8(at,v) memory[at+1]=v%256 end
+ local function u16(at,v) u8(at,v);u8(at+1,math.floor(v/256)) end
+ local function u32(at,v) u16(at,v);u16(at+2,math.floor(v/65536)) end
+ local function ptr(at,v) u32(at,0x8000000+v) end
+ ptr(0x100,0x300)
+ ptr(0x200,0x280);u16(0x204,123)
+ for i=0,15 do u16(0x280+i*2,i*1057) end
+ u16(0x300,65535);u16(0x302,123);u16(0x304,65535)
+ u16(0x308,w);u16(0x30a,h);u8(0x30c,2)
+ ptr(0x318,0x500);ptr(0x31c,0x400)
+ ptr(0x400,0x600);u16(0x404,w*h/2)
+ for tile=0,w*h/64-1 do for i=0,31 do u8(0x600+tile*32+i,(tile+1)*17) end end
+ local function rom()
+ local s={};for i,v in ipairs(memory) do s[i]=string.char(v) end
+ return Rom.new(table.concat(s))
+ end
+ return {rom=rom,u8=u8,u16=u16,u32=u32,ptr=ptr,spec={gfxTable=0x100,count=1,paletteTable=0x200,paletteCount=2,wideSubsprites=wide}}
+end
+local f=fixture(16,16)
+local result=Sheets.extract(f.rom(),f.spec);local r=assert(result.objects[0])
+T.eq(result.schema,Sheets.SCHEMA);T.eq(r.width,16);T.eq(r.height,16);T.eq(r.frameCount,1)
+T.eq(r.paletteTag,123);T.eq(r.paletteSlot,2);T.eq(#r.indexed,256)
+for y=0,15 do for x=0,15 do
+ T.eq(r.indexed:byte(y*16+x+1),math.floor(y/8)*2+math.floor(x/8)+1,'tiled bytes become row-major indices')
+end end
+local rgba=Sheets.rgba(r);T.eq(#rgba,1024)
+local encoded=require('src.import.LuaWriter').encodeSplit(result)
+local decoded=assert((loadstring or load)(encoded))()
+T.same(decoded,result,'compact bytes and zero-based palettes survive the actual cache writer')
+T.eq(rgba:sub(1,4),string.char(8,8,8,255),'BGR555 uses exact 5-bit expansion')
+T.eq(rgba:sub(8*4+1,8*4+4),string.char(16,16,16,255))
+T.eq(rgba:sub(16*8*4+1,16*8*4+4),string.char(25,25,25,255))
+local blank={width=8,height=8,frameCount=1,indexed=string.rep('\0',64),palette={[0]=32767}}
+T.eq(Sheets.rgba(blank),string.rep('\0',256),'palette index zero is transparent even when colored')
+local high={width=8,height=8,frameCount=1,indexed=string.rep('\1',64),palette={[1]=65535}}
+T.eq(Sheets.rgba(high),string.rep(string.char(255,255,255,255),64),'BGR bit 15 does not affect color')
+local avatar=fixture(16,16)
+avatar.spec.avatarTables={player=0x1800,player_states=2,rival=0x1810,rival_states=1,
+ stateNames={'NORMAL','SURFING'},link_frlg=0x1820,link_rs=0x1822,state_flags=0x1830,state_flag_count=1}
+for i,v in ipairs({0,89,2,92})do avatar.u8(0x17ff+i,v)end
+avatar.u8(0x1810,5);avatar.u8(0x1811,6)
+avatar.u8(0x1820,10);avatar.u8(0x1821,11);avatar.u8(0x1822,12);avatar.u8(0x1823,13)
+avatar.u8(0x1830,0);avatar.u8(0x1831,1);avatar.u8(0x1832,89);avatar.u8(0x1833,2)
+local av=Sheets.extract(avatar.rom(),avatar.spec).avatars
+T.same(av,{player={{state='NORMAL',male=0,female=89},{state='SURFING',male=2,female=92}},
+ rival={{state='NORMAL',male=5,female=6}},linkFrlg={male=10,female=11},linkRs={male=12,female=13},
+ stateFlags={male={{gfx=0,flag=1}},female={{gfx=89,flag=2}}}},'independent avatar roles and state flags')
+avatar.spec.avatarTables.player=8192
+T.raises(function()Sheets.extract(avatar.rom(),avatar.spec)end,'outside cartridge','out-of-range avatar metadata declines')
+T.raises(function()Sheets.rgba({width=8,height=8,frameCount=1,indexed='x'})end,'invalid indexed','invalid sheet sizes decline')
+-- Original picture-table order and noncontiguous frame pointers survive.
+f.ptr(0x408,0x1400);f.u16(0x40c,128)
+for i=0,127 do f.u8(0x1400+i,0xee) end
+r=Sheets.extract(f.rom(),f.spec).objects[0]
+T.eq(r.frameCount,2);T.eq(r.indexed:sub(257),string.rep('\14',256))
+f.ptr(0x410,0x1600);f.u16(0x414,256)
+for i=0,255 do f.u8(0x1600+i,0xff) end
+r=Sheets.extract(f.rom(),f.spec).objects[0]
+T.eq(r.frameCount,3,'mixed-height picture tables retain all stage slots')
+T.eq(r.indexed:sub(513),string.rep('\0',256),'foreign-size stage does not consume adjacent art')
+f.u32(0x410,0)
+-- Another info record can name the immediately following image list.
+f.spec.count=2;f.ptr(0x104,0x340)
+f.u16(0x340,65535);f.u16(0x342,123);f.u16(0x348,16);f.u16(0x34a,16)
+f.ptr(0x358,0x500);f.ptr(0x35c,0x408)
+result=Sheets.extract(f.rom(),f.spec)
+T.eq(result.objects[0].frameCount,1,'another image-table start is a hard frame boundary')
+T.eq(result.objects[1].indexed,string.rep('\14',256))
+f.u16(0x348,7);result=Sheets.extract(f.rom(),f.spec)
+T.eq(result.objects[1],nil);T.check(result.unavailable[1]~=nil,'invalid info is reported, not replaced with unrelated art')
+-- Both cartridge layouts produce the same two-piece OAM arrangement.
+for _,wide in ipairs({false,true}) do
+ local q=fixture(32,16,wide);q.ptr(0x314,0x1000);q.u8(0x1000,2);q.ptr(0x1004,0x1100)
+ local stride=wide and 8 or 4
+ for i=0,1 do
+ local at=0x1100+i*stride
+ if wide then q.u16(at,i*16-16);q.u16(at+2,-8);q.u16(at+4,4+i*64)
+ else q.u8(at,i*16-16);q.u8(at+1,-8);q.u16(at+2,4+i*64) end
+ end
+ local s=Sheets.extract(q.rom(),q.spec).objects[0]
+ T.eq(s.drawOffX,0);T.eq(s.drawOffY,0)
+ for y=0,15 do for x=0,31 do
+ local tile=math.floor(x/16)*4+math.floor(y/8)*2+math.floor((x%16)/8)
+ T.eq(s.indexed:byte(y*32+x+1),tile+1,'subsprite tile allocation is distinct from logical row order')
+ end end
+end
+-- Cache readiness asks for the additive data only on GBA datasets.
+love=require('tests.love_stub')
+local Importer=require('src.import.RomImporter')
+local Cache=require('src.import.CacheFs')
+local read,exists,rootReport=Cache.read,Cache.exists,Cache.rootReport
+Cache.rootReport=function()return {kind='save'}end
+local function currentMarker(edition)
+ if Importer.datasetContract then return Importer.datasetContract(edition).marker end
+ -- Fixture-only access to the real format producer on older public hosts.
+ -- Do not invent a marker or expose a new production API just for this test.
+ for i=1,100 do
+ local name,fn=debug.getupvalue(Importer.readyReport,i)
+ if name=='markerFor' then return fn(edition) end
+ if name==nil then break end
+ end
+ error('native cache marker producer unavailable')
+end
+for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire','red','yellow','gold','silver','crystal'}) do
+ local marker=currentMarker(edition)
+ local present=require('src.core.GameVersion').generation(edition)==3
+ T.eq(marker:find('original-object-sheets-v'..Sheets.SCHEMA..':',1,true)~=nil,present,'schema marker applies only to GBA')
+ Cache.read=function(path)
+ if path=='rom-cache.complete' then return marker end
+ if path=='data/generated/tilesets.lua' then return 'return {TilesetPlayersHouse={},TilesetHouse={}}' end
+ end
+ Cache.exists=function(path)return path~='data/generated/gen3_object_sheets.lua' end
+ local ready=Importer.readyReport(edition)
+ T.eq(ready.ok,not present,'cache upgrade is scoped to GBA: '..edition)
+ if present then T.same(ready.missing,{'data/generated/gen3_object_sheets.lua'}) end
+ Cache.exists=function()return true end
+ T.eq(Importer.readyReport(edition).ok,true,'a completed upgrade becomes ready without a format bump')
+ if present then
+ local oldMarker=marker:gsub('original%-object%-sheets%-v'..Sheets.SCHEMA..':','original-object-sheets-v1:')
+ Cache.read=function(path)if path=='rom-cache.complete'then return oldMarker end end
+ T.eq(Importer.readyReport(edition).ok,false,'previous object schema requests automatic cache regeneration')
+ end
+end
+Cache.read,Cache.exists,Cache.rootReport=read,exists,rootReport
+T.finish('compact original object sheets')
diff --git a/tools/object-sheets/oracle-host/conf.lua b/tools/object-sheets/oracle-host/conf.lua
new file mode 100644
index 000000000..fba3762a7
--- /dev/null
+++ b/tools/object-sheets/oracle-host/conf.lua
@@ -0,0 +1,6 @@
+function love.conf(t)
+ t.identity=assert(os.getenv('POKEPORT_IDENTITY'))
+ assert(t.identity:match('^oasc%-gen2recomp%-qa%-object%-sheets%-'))
+ t.version='12.0';t.window=false
+ t.modules.graphics=false;t.modules.audio=false;t.modules.joystick=false;t.modules.physics=false
+end
diff --git a/tools/object-sheets/oracle-host/main.lua b/tools/object-sheets/oracle-host/main.lua
new file mode 100644
index 000000000..455e8fd98
--- /dev/null
+++ b/tools/object-sheets/oracle-host/main.lua
@@ -0,0 +1,157 @@
+io.stdout:setvbuf('no')
+local root=assert(os.getenv('OBJECT_SHEETS_ENGINE'))
+local mode=assert(os.getenv('OBJECT_SHEETS_MODE'))
+local code=mode=='source' and assert(os.getenv('OBJECT_SHEETS_REFERENCE'))
+ or mode=='baseline' and assert(os.getenv('OBJECT_SHEETS_BASELINE')) or root
+package.path=code..'/?.lua;'..code..'/?/init.lua;'..package.path
+function love.errorhandler(e) print(debug.traceback(tostring(e),2)); return function() return 1 end end
+local function read(path) local f=assert(io.open(path,'rb'));local s=f:read('*a');f:close();return s end
+local function hash(s) return love.data.hash('sha256',s):gsub('.',function(c)return('%02x'):format(c:byte())end) end
+local function paletteHash(p)
+ if not p then return nil end
+ local words={};for i=0,15 do local n=p[i] or 0;words[i+1]=string.char(n%256,math.floor(n/256)%256) end
+ return hash(table.concat(words))
+end
+local function canonical(v)
+ if type(v)~='table' then return type(v)..':'..tostring(v) end
+ local keys={};for k in pairs(v) do keys[#keys+1]=k end
+ table.sort(keys,function(a,b)return type(a)==type(b) and a0,'original sheet import omitted cooperative checkpoints')
+ result.cooperativeCheckpoints=checkpoints
+ assert(result.nativeSprites==baseline.editions[edition].nativeSprites,'normalized native sprite output changed')
+ assert(result.nativeArt==baseline.editions[edition].nativeArt,'normalized native sprite pixels changed')
+ local text=require('src.import.LuaWriter').encodeSplit(native)
+ local decoded=assert((loadstring or load)(text,'@generated/gen3_object_sheets.lua'))()
+ assert(hash(canonical(decoded))==hash(canonical(native)),'generated cache module did not round-trip')
+ result.cacheRoundTrip=true;result.cacheModuleBytes=#text
+ result.nativeFrameMapChecks=0;result.nativeFrameMapExceptions={}
+ end
+ end
+ for id=0,(mode=='baseline' and 0 or count)-1 do
+ local record,indexed,rgba
+ if mode=='source' then
+ record=extractor.extractOne(rom,id,pals,version,reflect)
+ if record then
+ local frames={}
+ for fi=1,record.frameCount do
+ local rows={}
+ for y=0,record.height-1 do
+ local row={};for x=0,record.width-1 do row[x+1]=string.char(record.frames[fi][y*record.width+x+1] or 0) end
+ rows[y+1]=table.concat(row)
+ end
+ frames[fi]=table.concat(rows)
+ end
+ indexed=table.concat(frames);rgba=extractor.bakeRgba(record)
+ end
+ else
+ record=native.objects[id]
+ if record then indexed=record.indexed;rgba=require('src.import.Gen3ObjectSheets').rgba(record) end
+ end
+ local key=tostring(id)
+ if record then
+ local summary={width=record.width,height=record.height,frames=record.frameCount,
+ inanimate=record.inanimate,drawOffX=record.drawOffX,drawOffY=record.drawOffY,
+ paletteTag=record.paletteTag,reflectionPaletteTag=record.reflectionPaletteTag,
+ palette=paletteHash(record.palette),reflectionPalette=paletteHash(record.reflectionPalette),mappedPalette=paletteHash(record.mappedReflectionPalette),
+ mappedReflection=record.reflectionPaletteMappedTag,indexed=hash(indexed),rgba=hash(rgba)}
+ result.objects[key]=summary;result.sheets=result.sheets+1;result.frames=result.frames+record.frameCount
+ result.pixels=result.pixels+#indexed
+ if native and record.native and expected.editions[edition].objects[key] then
+ local n=record.native
+ -- Native art floors 5-bit expansion; the original-order contract
+ -- rounds it. Compare the independently decoded indices using the
+ -- native palette rule without changing either public output.
+ local nativeColors={[0]='\0\0\0\0'}
+ for c=1,15 do
+ local rr,gg,bb=require('src.import.RomGba').bgr555(record.palette[c])
+ nativeColors[c]=string.char(rr,gg,bb,255)
+ end
+ local projected={};for p=1,#indexed do projected[p]=nativeColors[indexed:byte(p)]end
+ local nativePixels=table.concat(projected)
+ for _,projection in ipairs({{n.image,n.order,n.defaultWidth,n.defaultHeight,n.frames},
+ {n.runImage,n.runOrder,n.runWidth,n.runHeight,n.runFrames}})do
+ local path,order,w,h,frames=unpack(projection)
+ if path and order then
+ local image=assert(art[path],'mapped native image not composed: '..path)
+ if w==record.width and h==record.height and frames==#order
+ and image.width==w and image.height==h*#order then
+ for frame,original in ipairs(order)do
+ assert(original>=0 and original '..original)
+ result.nativeFrameMapChecks=result.nativeFrameMapChecks+1
+ end
+ else
+ result.nativeFrameMapExceptions[#result.nativeFrameMapExceptions+1]={id=id,path=path,
+ reason='native special-sheet geometry or stage rewrite',width=w,height=h,frames=frames}
+ end
+ end
+ end
+ end
+ if expected then
+ local prior=expected.editions[edition].objects[key]
+ if not prior then result.additionalNative[#result.additionalNative+1]=id
+ else
+ for _,field in ipairs({'width','height','frames','inanimate','drawOffX','drawOffY','paletteTag','reflectionPaletteTag','mappedReflection','palette','reflectionPalette','mappedPalette','indexed','rgba'}) do
+ if prior[field]~=summary[field] then result.differences[#result.differences+1]={id=id,field=field,source=prior[field],native=summary[field]} end
+ end
+ end
+ end
+ elseif expected and expected.editions[edition].objects[key] then result.missing[#result.missing+1]=id end
+ if id%32==31 then print('OBJECT_SHEET_PROGRESS',mode,edition,id+1,count,#result.differences) end
+ collectgarbage('step',128)
+ end
+ report.editions[edition]=result
+ local f=assert(io.open(out..'/'..mode..'.json','w'));f:write(J.encode(report));f:close()
+ print('OBJECT_SHEET_EDITION',mode,edition,result.sheets,result.frames,result.pixels,#result.differences,#result.missing)
+ end
+ report.status='pass'
+ if expected then for _,r in pairs(report.editions) do if #r.differences>0 or #r.missing>0 then report.status='different' end end end
+ local f=assert(io.open(out..'/'..mode..'.json','w'));f:write(J.encode(report));f:close()
+ love.event.quit(report.status=='pass' and 0 or 1)
+end