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