mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 16:31:07 +02:00
a3fb18a589
Gen 1 has no shininess of its own, but it has the four DVs Gen 2 reads to decide it -- and the engine already ships that reading (Stats.isShiny, its own comment calling it "the RBY virtual shiny", allowlisted for mods precisely so an indicator mod can call it). Nothing new is stored on a Pokemon and nothing migrates: every save already contains the answer, and this starts drawing it. Random DVs land on the pattern 1 in 8192, which is the classic rate and the default the odds dial ships at. Deriving rather than storing is what makes it survive a save, a box, a trade and an evolution with no second copy of the truth to drift. mon.shiny is a cache written from the DVs, never read as the source. The roll goes in Pokemon.new -- every wild, gift, starter and traded mon is built there, and it is before the battle bakes its sprite, which battle.started is already too late for. It draws from the mod's own random stream so installing this does not shift the sequence damage rolls and encounter slots come out of. Trainers stay ordinary by themselves: the engine pins their DVs, as the real games do. The models are genuinely recoloured, as part of the extraction. Each species is decoded once, packed as usual, then recoloured and packed again as NNNs.dsm. The colours are Stadium's own HSL slide (hue in degrees, saturation and lightness on a -8..+8 scale at 12.5% a step); five species carry an explicit colour table instead, because Stadium gives them a real alternate texture that no single slide reproduces -- Jigglypuff's body must stay pink while its irises rotate to green. Extraction is the right moment because StadiumFx's generated frames are still marked there and the packer drops the marker: it is the last point a flame is distinguishable from a hide. A shiny Charizard has a shiny hide and an ordinary fire. The normal packs are written BEFORE the recolour, so they come out byte-identical and stadium_extract_test still diffs all 151 against the Python oracle unchanged -- no format change, no DSM4, no second implementation to keep in step. REV goes to 3 so an existing cache rebuilds. Flat art is tinted instead, because the engine bakes a species palette into a cache with no notion of which individual is drawn. The tint comes from that species' own slide rather than a generic gold. A multiply can only darken, so species whose shiny is lighter read quieter there than on the model; the status page's star is the mode-proof mark. Tests: 58 assertions in tests/shiny_test.lua, including the colour transform against 640 real colour pairs lifted from the verified texture set, the DV model, the read side, and the end-to-end through the engine's own constructor. stadium_extract_test gains --mod (worktrees have neither the ROM nor the packs, both gitignored) and now also checks that every shiny pack is the same length as its twin and actually differs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
163 lines
6.0 KiB
Lua
163 lines
6.0 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 } }
|
|
|
|
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))
|
|
|
|
-- 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)
|