From 6ac093ec96d754a2dbba91dff255cb68216b069e Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 22:55:01 +0100 Subject: [PATCH 1/3] Preserve original object picture tables beside native sprite art --- docs/native-object-sheets.md | 60 ++++++++ src/core/Data.lua | 8 +- src/import/Gen3ObjectSheets.lua | 179 +++++++++++++++++++++++ src/import/RomExtractorGen3.lua | 11 ++ src/import/RomImporter.lua | 4 + tests/engine/native_object_sheets.lua | 1 + tools/object-sheets/check.lua | 114 +++++++++++++++ tools/object-sheets/oracle-host/conf.lua | 6 + tools/object-sheets/oracle-host/main.lua | 107 ++++++++++++++ 9 files changed, 488 insertions(+), 2 deletions(-) create mode 100644 docs/native-object-sheets.md create mode 100644 src/import/Gen3ObjectSheets.lua create mode 100644 tests/engine/native_object_sheets.lua create mode 100644 tools/object-sheets/check.lua create mode 100644 tools/object-sheets/oracle-host/conf.lua create mode 100644 tools/object-sheets/oracle-host/main.lua diff --git a/docs/native-object-sheets.md b/docs/native-object-sheets.md new file mode 100644 index 000000000..c5204bf59 --- /dev/null +++ b/docs/native-object-sheets.md @@ -0,0 +1,60 @@ +# 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. + +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. + +## 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 +are identical. 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..82542aeec --- /dev/null +++ b/src/import/Gen3ObjectSheets.lua @@ -0,0 +1,179 @@ +-- 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=1} +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',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) + 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) + end + return table.concat(rows) +end +return M diff --git a/src/import/RomExtractorGen3.lua b/src/import/RomExtractorGen3.lua index c2ce0b55e..a42505f93 100644 --- a/src/import/RomExtractorGen3.lua +++ b/src/import/RomExtractorGen3.lua @@ -9828,6 +9828,17 @@ 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 + self:write("gen3_object_sheets", 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", + reflectionFor = function(info) return self:reflectionPaletteFor(info) end, + progress = function(current,total) self:tick("Gen3 original object sheets",current,total) end, + })) 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..01e0f0726 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1513,6 +1513,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/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..af4cb7554 --- /dev/null +++ b/tools/object-sheets/check.lua @@ -0,0 +1,114 @@ +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,1);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') +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 + 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') +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..7dcd86859 --- /dev/null +++ b/tools/object-sheets/oracle-host/main.lua @@ -0,0 +1,107 @@ +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 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 From 335a0cf433de3f80b6728c7b5e8d5a540187370b Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 23:00:03 +0100 Subject: [PATCH 2/3] Retain cooperative importer checkpoints while decoding object sheets --- src/import/RomExtractorGen3.lua | 4 +++- tools/object-sheets/oracle-host/main.lua | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/import/RomExtractorGen3.lua b/src/import/RomExtractorGen3.lua index a42505f93..f12c77d53 100644 --- a/src/import/RomExtractorGen3.lua +++ b/src/import/RomExtractorGen3.lua @@ -9837,7 +9837,9 @@ function RomExtractorGen3:extractOverworldSprites() wideSubsprites = edition == "ruby" or edition == "sapphire", reflectionProfile = edition == "emerald" and "raw" or "paired", reflectionFor = function(info) return self:reflectionPaletteFor(info) end, - progress = function(current,total) self:tick("Gen3 original object sheets",current,total) end, + progress = function(current,total) + if self.checkpoint then self.checkpoint(("Decoding original object sheets %d/%d"):format(current,total)) end + end, })) Logger.info("Gen3 overworld sprites: %d rows, %d that turn and walk, %d " .. "non-walkers, %d unreadable, %d sheets composed, %d " diff --git a/tools/object-sheets/oracle-host/main.lua b/tools/object-sheets/oracle-host/main.lua index 7dcd86859..c93f22a64 100644 --- a/tools/object-sheets/oracle-host/main.lua +++ b/tools/object-sheets/oracle-host/main.lua @@ -43,10 +43,13 @@ function love.load() extractor=require('src.import.RomExtractorGen3').new(raw,V.info(edition),manifest) local generated={};extractor.write=function(_,name,value)generated[name]=value end extractor.objectGfxSheet=function()return nil end + local checkpoints=0;extractor.checkpoint=function()checkpoints=checkpoints+1 end extractor:extractOverworldSprites() result.nativeSprites=hash(canonical(assert(generated.sprites))) if mode=='native' then native=assert(generated.gen3_object_sheets,'actual sprite import did not emit original sheets') + assert(checkpoints>0,'original sheet import omitted cooperative checkpoints') + result.cooperativeCheckpoints=checkpoints assert(result.nativeSprites==baseline.editions[edition].nativeSprites,'normalized native sprite output changed') local text=require('src.import.LuaWriter').encodeSplit(native) local decoded=assert((loadstring or load)(text,'@generated/gen3_object_sheets.lua'))() From 27472754b086939d12cee673ca378a2a53d3d51c Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Mon, 26 Oct 2026 00:02:11 +0100 Subject: [PATCH 3/3] Preserve native frame maps and cartridge avatar states --- docs/native-object-sheets.md | 18 ++++++++- src/import/Gen3ObjectSheets.lua | 36 +++++++++++++++-- src/import/RomExtractorGen3.lua | 22 +++++++++-- src/import/RomImporter.lua | 3 ++ src/import/gba/object_avatar_tables.lua | 36 +++++++++++++++++ tools/object-sheets/check.lua | 21 +++++++++- tools/object-sheets/oracle-host/main.lua | 49 +++++++++++++++++++++++- 7 files changed, 176 insertions(+), 9 deletions(-) create mode 100644 src/import/gba/object_avatar_tables.lua diff --git a/docs/native-object-sheets.md b/docs/native-object-sheets.md index c5204bf59..7678498c7 100644 --- a/docs/native-object-sheets.md +++ b/docs/native-object-sheets.md @@ -13,6 +13,14 @@ 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 @@ -33,6 +41,9 @@ 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 @@ -46,7 +57,12 @@ 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 -are identical. Generated object modules also round-trip through the actual Lua +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. diff --git a/src/import/Gen3ObjectSheets.lua b/src/import/Gen3ObjectSheets.lua index 82542aeec..6078f4a00 100644 --- a/src/import/Gen3ObjectSheets.lua +++ b/src/import/Gen3ObjectSheets.lua @@ -1,7 +1,7 @@ -- 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=1} +local M={SCHEMA=2} local dimensions={ [0]={{8,8},{16,16},{32,32},{64,64}}, [1]={{16,8},{32,8},{32,16},{64,32}}, @@ -107,13 +107,42 @@ local function decode(raw,w,h,tileRows) end return table.concat(lines) end +local function avatars(rom,spec) + if not spec then return nil end + local out={stateFlags={male={},female={}}} + local function pair(at) + if not at then return nil end + assert(range(rom,at,2),'avatar pair outside cartridge') + return {male=rom:u8(at),female=rom:u8(at+1)} + end + for _,kind in ipairs({'player','rival'}) do + local count=spec[kind..'_states'] or 0;assert(count<=32,'avatar state limit') + if spec[kind] then + out[kind]={} + for i=0,count-1 do + local row=pair(spec[kind]+i*2);row.state=assert(spec.stateNames[i+1],'avatar state name unavailable') + out[kind][i+1]=row + end + end + end + out.linkFrlg=pair(spec.link_frlg);out.linkRs=pair(spec.link_rs) + local n=spec.state_flag_count or 0;assert(n<=32,'avatar flag limit') + for g,key in ipairs({'male','female'}) do + for i=0,n-1 do + local at=spec.state_flags+((g-1)*n+i)*2 + assert(range(rom,at,2),'avatar flags outside cartridge') + out.stateFlags[key][i+1]={gfx=rom:u8(at),flag=rom:u8(at+1)} + end + end + return out +end function M.extract(rom,spec) assert(range(rom,spec.gfxTable,spec.count*4),'invalid object graphics table') assert(spec.count>=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',objects={},unavailable={}} + 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) @@ -165,7 +194,7 @@ 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) +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 @@ -173,6 +202,7 @@ function M.rgba(record,palette) 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 diff --git a/src/import/RomExtractorGen3.lua b/src/import/RomExtractorGen3.lua index f12c77d53..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] = { @@ -9831,16 +9831,32 @@ function RomExtractorGen3:extractOverworldSprites() -- 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 - self:write("gen3_object_sheets", require("src.import.Gen3ObjectSheets").extract(self.rom, { + 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 01e0f0726..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 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/tools/object-sheets/check.lua b/tools/object-sheets/check.lua index af4cb7554..a87650494 100644 --- a/tools/object-sheets/check.lua +++ b/tools/object-sheets/check.lua @@ -26,7 +26,7 @@ local function fixture(w,h,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,1);T.eq(r.width,16);T.eq(r.height,16);T.eq(r.frameCount,1) +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') @@ -42,6 +42,19 @@ local blank={width=8,height=8,frameCount=1,indexed=string.rep('\0',64),palette={ 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) @@ -99,6 +112,7 @@ 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 @@ -109,6 +123,11 @@ for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire','red' 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/main.lua b/tools/object-sheets/oracle-host/main.lua index c93f22a64..455e8fd98 100644 --- a/tools/object-sheets/oracle-host/main.lua +++ b/tools/object-sheets/oracle-host/main.lua @@ -34,27 +34,41 @@ function love.load() local count=manifest.layout.objectEventGfxCount local result={objects={},missing={},additionalNative={},differences={},sheets=0,frames=0,pixels=0} local rom,version,extractor,pals,reflect,native + local art={} if mode=='source' then local Versions=require('src.import.gba.versions');Versions.select(sha);version=assert(Versions.lookup(sha)) rom=setmetatable({_raw=raw,size=#raw,sha1=sha},require('src.import.gba.rom')) extractor=require('src.import.gba.ow_extract');pals=extractor.loadPaletteTable(rom,version) reflect=extractor.loadReflectionMappings(rom,version.ow_reflection) + result.avatars=extractor.readAvatars(rom) else extractor=require('src.import.RomExtractorGen3').new(raw,V.info(edition),manifest) local generated={};extractor.write=function(_,name,value)generated[name]=value end - extractor.objectGfxSheet=function()return nil end + extractor.objectGfxSheet=function(_,w,h)return love.image.newImageData(w,h,'rgba8')end + extractor.saveImage=function(_,image,path) + art['assets/generated/'..path]={width=image:getWidth(),height=image:getHeight(),bytes=image:getString()} + image:release() + end local checkpoints=0;extractor.checkpoint=function()checkpoints=checkpoints+1 end extractor:extractOverworldSprites() result.nativeSprites=hash(canonical(assert(generated.sprites))) + local artHashes={};for path,image in pairs(art)do artHashes[path]=hash(image.bytes)end + result.nativeArt=hash(canonical(artHashes)) if mode=='native' then native=assert(generated.gen3_object_sheets,'actual sprite import did not emit original sheets') + assert(native.schema==require('src.import.Gen3ObjectSheets').SCHEMA,'cache schema differs') + result.schema=native.schema;result.avatars=native.avatars + assert(canonical(result.avatars)==canonical(expected.editions[edition].avatars),'avatar state tables differ') + result.avatarTablesMatch=true assert(checkpoints>0,'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 @@ -86,6 +100,39 @@ function love.load() 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