Files
DramaticShapeVoxelMod/tests/stadium_extract_test.lua
DramaticShape c07aed2449 find the colour table relative to the mod, not the cwd
ShinyPalette loaded data/shiny_colors.lua by guessing cwd-relative paths
when V.data was absent, which is the headless case. Run from the project
root, none of the guesses hit -- so the extraction built all 151 species,
recoloured none of them, and reported PASS. The packs were correct; there
simply were no shiny variants in them, and nothing in the output said so.

Two fixes, because either alone leaves the trap open:

  V.path is now consulted first, so the file is found relative to the MOD
  the way every other resource is. The extract test's stub sets it to
  whatever --mod it was given.

  A count of zero recoloured is now a failure. That is precisely what a
  missing colour table looks like from the outside, and a test that passes
  on it is not testing the feature. It was caught by noticing the number
  change between two runs that differed only in where they were started
  from -- which is too thin a thread to hang it on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:14:16 -04:00

176 lines
6.6 KiB
Lua

-- The Lua ROM extractor, against the Python packer that is its oracle.
--
-- luajit mods/DramaticShapeVoxelMod/tests/stadium_extract_test.lua \
-- [--rom=PATH] [--oracle=DIR] [--only=25,6] [--out=DIR] [--mod=DIR]
--
-- Run from the PROJECT ROOT. Defaults: the ROM under
-- model_extract/baseroms/, the oracle in assets/stadium (whatever
-- tools/stadium_pack.py last wrote there).
--
-- --mod points at the mod directory whose lib/ is under test. It exists for
-- worktrees: the ROM and the packs are both gitignored, so a worktree has
-- neither, and without this the test would load the SHARED checkout's
-- modules while claiming to test the branch's.
--
-- ------- what this is for
--
-- lib/StadiumRom, StadiumFragment, StadiumFx and StadiumBuild are a port of
-- roughly fifteen hundred lines of dense numeric Python -- an F3DEX2
-- interpreter, six texture codecs, a bit-packed animation sampler, a noise
-- generator and a binary writer. Reading a port of that twice does not
-- establish that it is right. Producing the same thirty-four megabytes,
-- byte for byte, does.
--
-- It is also the guard on the two of them DRIFTING. The packer and the
-- extractor have to keep agreeing about the format forever, and a change to
-- one that is not made to the other shows up here as a differing offset
-- rather than as a Pokemon that renders as noise three months later.
--
-- Headless: no LOVE, no graphics. Everything these four modules do is
-- arithmetic on strings.
local args = {}
for _, a in ipairs({ ... }) do
local k, v = a:match("^%-%-([%w_]+)=(.*)$")
if k then args[k] = v else args[a:gsub("^%-%-", "")] = true end
end
local MOD = args.mod or "mods/DramaticShapeVoxelMod"
local ROM = args.rom or (MOD .. "/model_extract/baseroms/us/baserom.z64")
local ORACLE = args.oracle or (MOD .. "/assets/stadium")
-- ------- the mod namespace, enough of it to load four modules
local loaded = {}
local V = {}
function V.require(name)
if loaded[name] == nil then
local chunk = assert(loadfile(MOD .. "/lib/" .. name .. ".lua"))
loaded[name] = chunk(V)
end
return loaded[name]
end
V.mod = { log = { warn = function() end, info = function() end } }
-- the mod's own directory, so a module that loads a data file finds it
-- relative to the MOD rather than to wherever this was run from
V.path = MOD
local StadiumRom = V.require("StadiumRom")
local StadiumBuild = V.require("StadiumBuild")
-- ------- run
local function readFile(path)
local fp = io.open(path, "rb")
if not fp then return nil end
local data = fp:read("*a")
fp:close()
return data
end
local romBytes = readFile(ROM)
if not romBytes then
io.stderr:write("no ROM at " .. ROM .. "\n")
os.exit(2)
end
local rom, err = StadiumRom.open(romBytes)
if not rom then
io.stderr:write("could not open ROM: " .. tostring(err) .. "\n")
os.exit(2)
end
local only = nil
if args.only then
only = {}
for n in args.only:gmatch("%d+") do only[tonumber(n)] = true end
end
local checked, matched, missing, failed = 0, 0, 0, 0
local shinyOk, shinyMissing, shinyBad = 0, 0, 0
local firstBad = nil
local t0 = os.clock()
for fileno = 0, StadiumRom.N_POKEMON - 1 do
local ok, res = pcall(StadiumBuild.species, rom, fileno)
if not ok or not res then
-- res carries the message on the error path
io.write(("file %d: EXTRACT FAILED: %s\n"):format(fileno, tostring(res)))
failed = failed + 1
elseif not only or only[res.species] then
checked = checked + 1
if args.out then
local fp = io.open(("%s/%03d.dsm"):format(args.out, res.species), "wb")
if fp then fp:write(res.bytes) fp:close() end
-- the shiny variant too, so the pair can be diffed out of process
if res.shinyBytes then
local sp = io.open(("%s/%03ds.dsm"):format(args.out, res.species), "wb")
if sp then sp:write(res.shinyBytes) sp:close() end
end
end
-- The shiny pack is the same DSM3 with recoloured texels, so it must be
-- exactly as long and must actually differ. A species that produced none
-- is counted rather than failed: it ships without a recolour and the
-- runtime falls back to its normal model.
if not res.shinyBytes then
shinyMissing = shinyMissing + 1
elseif #res.shinyBytes ~= #res.bytes then
shinyBad = shinyBad + 1
io.write(("species %d: shiny pack is %d bytes, normal is %d\n")
:format(res.species, #res.shinyBytes, #res.bytes))
elseif res.shinyBytes == res.bytes then
shinyBad = shinyBad + 1
io.write(("species %d: shiny pack is identical to normal\n")
:format(res.species))
else
shinyOk = shinyOk + 1
end
local want = readFile(("%s/%03d.dsm"):format(ORACLE, res.species))
if not want then
missing = missing + 1
elseif want == res.bytes then
matched = matched + 1
else
-- where, exactly: the first differing byte localises a format mistake
-- far faster than "the file is wrong" does
local at = nil
local n = math.min(#want, #res.bytes)
for i = 1, n do
if want:sub(i, i) ~= res.bytes:sub(i, i) then at = i break end
end
io.write(("%03d.dsm DIFFERS: %d bytes vs %d, first at %s\n")
:format(res.species, #res.bytes, #want,
at and ("offset " .. at) or "(one is a prefix)"))
if not firstBad then firstBad = res.species end
end
end
end
io.write(("\n%d checked, %d identical, %d differ, %d oracle files missing, "
.. "%d extractions failed (%.1fs)\n")
:format(checked, matched, checked - matched - missing, missing,
failed, os.clock() - t0))
io.write(("shiny: %d recoloured, %d without a variant, %d malformed\n")
:format(shinyOk, shinyMissing, shinyBad))
-- NONE recoloured is a failure, not a quiet zero. It is what a missing or
-- unfindable data/shiny_colors.lua looks like, and the first version of this
-- test reported PASS through exactly that: 151 species built, every one of
-- them without a shiny variant, and nothing in the output that read as
-- wrong. A count of zero is now as loud as a malformed pack.
if checked > 0 and shinyOk == 0 then
io.write("NO SPECIES RECOLOURED -- data/shiny_colors.lua was not found\n")
shinyBad = shinyBad + 1
end
-- The oracle diff is the load-bearing assertion and is unchanged: the shiny
-- pass must not have moved a single byte of the normal packs. The shiny
-- counters are additional, and a malformed variant fails the run -- a pack
-- of the wrong length would be read as a corrupt model at runtime.
if matched == checked and failed == 0 and missing == 0 and shinyBad == 0 then
io.write("PASS -- the Lua extractor reproduces the packer exactly\n")
os.exit(0)
end
io.write("FAIL\n")
os.exit(1)