DramaticShapeVoxelMod archival mirror · read-only
tests/stadium_extract_test.lua

stadium_extract_test.lua

tests/stadium_extract_test.lua6.6 KB1006442bb2142b8a
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175
-- 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)