From 1915654a50272c725b30bf21340c3a028c98935c Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Tue, 4 Aug 2026 12:32:23 -0400 Subject: [PATCH] file picker update --- lib/StadiumRomPick.lua | 239 ++++++++++++++++++ tests/stadium_anim_qa.lua | 496 ++++++++++++++++++++++++++++++++++++++ tests/stadium_import.lua | 131 ++++++++++ 3 files changed, 866 insertions(+) create mode 100644 lib/StadiumRomPick.lua create mode 100644 tests/stadium_anim_qa.lua create mode 100644 tests/stadium_import.lua diff --git a/lib/StadiumRomPick.lua b/lib/StadiumRomPick.lua new file mode 100644 index 0000000..068221a --- /dev/null +++ b/lib/StadiumRomPick.lua @@ -0,0 +1,239 @@ +-- STADIUM battles: importing the ROM, instead of being told where to put it. +-- +-- The mod ships no Pokemon Stadium models and cannot -- they are that game's +-- data -- so the player supplies the cartridge. The original instruction for +-- that was "make a folder called baseroms next to the game and drop the file +-- in it", which is a fine sentence to write and a poor thing to ask. It needs +-- a folder the player has to create, in a place that is different on every +-- platform and is inside an unwritable archive on a packaged build, and it +-- fails SILENTLY: the two STADIUM rungs are simply not on the row, and +-- nothing on screen says why. +-- +-- So this opens a file picker instead, from a row on the OPTIONS menu, and +-- the folder keeps working for anyone who prefers it (StadiumInstall). +-- +-- ------- the picker is the host's, not LOVE's +-- +-- LOVE 11.5 has no file dialog. love.window.showFileDialog arrived in 12 and +-- love.system.pickFile is a native bridge this project ships for mobile +-- rather than part of LOVE at all. What every desktop OS does have is a +-- dialog reachable from a shell, so that is what is used here -- osascript on +-- macOS, PowerShell's OpenFileDialog on Windows, zenity then kdialog on +-- Linux. +-- +-- This is deliberately the SAME four commands the engine's own ROM importer +-- uses for the Game Boy cartridge (src/import/RomImporter.lua's chooseRom), +-- down to writing the Windows pick as UTF-8 -- the console's OEM codepage +-- mangles a non-ASCII path into something that crashes the next text draw. +-- Being a second copy of that is worth it: a mod cannot call into the +-- importer's private helpers, and the alternative is asking the engine to +-- grow a seam for one caller. +-- +-- The dialog BLOCKS. io.popen waits for the player to choose, and the game is +-- frozen for as long as it is up. That is what the engine's importer does +-- too, it is what a modal dialog means, and the frame it freezes on is an +-- options menu. +-- +-- ------- and the ROM is not kept +-- +-- The picked file is read, built from, and forgotten -- nothing is copied +-- anywhere. A Stadium cartridge is 32 MB and the models built out of it are +-- 34, so keeping both would double the cost of a feature for a file that has +-- no further use: the packs are what the game reads afterwards, and the +-- marker records the ROM's md5 so a swapped cartridge is still noticed. +-- +-- The one thing that costs is a format bump, which invalidates the packs and +-- leaves nothing to rebuild from. That is what the row still being there is +-- for -- it reads READY, and pressing it imports again. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local StadiumInstall = V.require("StadiumInstall") + +local StadiumRomPick = {} + +StadiumRomPick.LABEL = "STADIUM ROM" +StadiumRomPick.ID = "DRAMATIC_SHAPE:stadiumRom" + +local PROMPT = "Choose your Pokemon Stadium ROM" + +-- ------- the host, at arm's length +-- +-- Everything below is read through pcall and a presence test. The mod loader +-- hands a mod the real `io` and `os` today, but a mod that TAKES that for +-- granted is one that stops loading the day a sandbox arrives -- and this is +-- a convenience on top of a folder scan that works without any of it. + +local function haveShell() + local ok, popen = pcall(function() return io and io.popen end) + return (ok and popen) and true or false +end + +local function haveFiles() + local ok, open = pcall(function() return io and io.open end) + return (ok and open) and true or false +end + +local function osName() + local ok, name = pcall(function() return love.system.getOS() end) + return ok and name or nil +end + +-- Run a command and return its trimmed stdout, or nil for anything that did +-- not produce a line -- a cancelled dialog, a missing zenity, a shell that +-- is not there. +local function commandOutput(cmd) + if not haveShell() then return nil end + local ok, pipe = pcall(io.popen, cmd) + if not (ok and pipe) then return nil end + local okRead, out = pcall(pipe.read, pipe, "*a") + pcall(pipe.close, pipe) + if not (okRead and type(out) == "string") then return nil end + out = out:gsub("^%s+", ""):gsub("%s+$", "") + return (out ~= "") and out or nil +end + +-- ------- can this machine open one at all +-- +-- Desktop only, and honestly so. On ANDROID the picker is a native bridge +-- (love.system.pickFile) whose kind -> filename mapping is a fixed list of +-- three in the engine's own C++, and an unrecognised kind falls through to +-- `picked_rom.gb` -- which is the file the engine's Game Boy importer is +-- watching. Calling it for a 32 MB N64 ROM would hand that to the wrong +-- importer, so it is not called. +-- +-- Android does not need it as badly, either: conf.lua points the save +-- directory at the app's external-files folder, so `baseroms/` there is +-- reachable over USB or any file manager with no root and no permission +-- prompt, which is the flow the engine's own comment describes for +-- picker-less builds. +function StadiumRomPick.available() + if not (haveShell() and haveFiles()) then return false end + local p = osName() + return p == "Windows" or p == "OS X" or p == "Linux" +end + +-- Open the dialog. Returns the chosen absolute path, or nil when the player +-- cancelled or no dialog could be opened. +function StadiumRomPick.choose() + local p = osName() + if p == "OS X" then + return commandOutput( + ([[osascript -e 'POSIX path of (choose file with prompt "%s" of type ]] + .. [[{"z64", "n64", "v64"})' 2>/dev/null]]):format(PROMPT)) + elseif p == "Windows" then + local script = table.concat({ + "Add-Type -AssemblyName System.Windows.Forms;", + "$d=New-Object System.Windows.Forms.OpenFileDialog;", + "$d.Title='" .. PROMPT .. "';", + "$d.Filter='Nintendo 64 ROM (*.z64;*.n64;*.v64)|*.z64;*.n64;*.v64" + .. "|All files (*.*)|*.*';", + -- as UTF-8: the console's OEM codepage would mangle a non-ASCII path + -- and crash the next text draw that showed it + "if($d.ShowDialog() -eq 'OK'){[Console]::OutputEncoding=" + .. "[Text.Encoding]::UTF8; [Console]::Write($d.FileName)}", + }) + return commandOutput( + 'powershell -NoProfile -STA -Command "' .. script .. '"') + elseif p == "Linux" then + local path = commandOutput( + ([[zenity --file-selection --title="%s" ]] + .. [[--file-filter="Nintendo 64 ROM | *.z64 *.n64 *.v64" 2>/dev/null]]) + :format(PROMPT)) + if path then return path end + -- zenity is absent on plenty of installs (and on most handheld Linux + -- distributions); KDE's own dialog is the usual second answer + return commandOutput( + [[kdialog --getopenfilename "$HOME" "*.z64 *.n64 *.v64|]] + .. [[Nintendo 64 ROM" 2>/dev/null]]) + end + return nil +end + +-- Read an ABSOLUTE path, which love.filesystem cannot: it only sees inside +-- the physfs mount, and a picked file is anywhere on the disk. Returns the +-- bytes, or nil plus a reason short enough to fit the loading screen. +function StadiumRomPick.read(path) + if not haveFiles() then return nil, "no file access" end + local ok, fp = pcall(io.open, path, "rb") + if not (ok and fp) then return nil, "could not open that file" end + local okRead, bytes = pcall(fp.read, fp, "*a") + pcall(fp.close, fp) + if not (okRead and type(bytes) == "string" and #bytes > 0) then + return nil, "could not read that file" + end + return bytes +end + +-- ------- the whole flow, from one keypress +-- +-- Pick, read, start the build, and put the loading screen up over whatever +-- asked -- which is the OPTIONS menu, so the row is there again underneath +-- when the build finishes and now reads READY. +-- +-- A CANCELLED dialog is not a failure and says nothing: the player opened a +-- file browser and changed their mind, and a mod that made an announcement +-- about that would be the second most annoying thing on the menu. +-- +-- Everything else lands on the loading screen's own failure state, because it +-- is the one surface in this mode with room for a sentence -- and because a +-- player who has just chosen the wrong file is owed a reason and not a row +-- that quietly goes on saying IMPORT. +function StadiumRomPick.import(game) + if StadiumInstall.status.state == "building" then return false end + local path = StadiumRomPick.choose() + if not path then return false end + + local StadiumScreen = V.require("StadiumScreen") + local function fail(why) + StadiumInstall.status.state = "failed" + StadiumInstall.status.error = why + if game and game.stack then + game.stack:push(StadiumScreen.new(game, true)) + end + return false + end + + local bytes, err = StadiumRomPick.read(path) + if not bytes then return fail(err or "could not read that file") end + + local ok, beginErr = StadiumInstall.beginFrom(bytes, path) + if not ok then return fail(tostring(beginErr)) end + if game and game.stack then + game.stack:push(StadiumScreen.new(game, true)) + end + return true +end + +-- ------- the row +-- +-- An ACTION rather than a value, which is why it is not a ModSetting: there +-- is no rung to store, nothing for the mod manager's page to persist, and +-- nothing to restore on the next boot. What it shows is a STATE -- the models +-- are there or they are not -- and what it does is the only thing it can do. +-- +-- Still offered once they ARE there, reading READY. Pressing it imports +-- again, which is how a player swaps to a different revision, and how they +-- rebuild after a format bump has invalidated the packs and left nothing on +-- disk to rebuild from (see the header: the ROM is not kept). +-- +-- nil where no dialog can be opened, which takes the row off the menu +-- entirely rather than offering a button that cannot do anything. +function StadiumRomPick.row() + if not StadiumRomPick.available() then return nil end + return { + id = StadiumRomPick.ID, + label = StadiumRomPick.LABEL, + value = function() + if StadiumInstall.status.state == "building" then return "BUILDING" end + return StadiumInstall.available() and "READY" or "IMPORT" + end, + step = function(game) + pcall(StadiumRomPick.import, game) + return true + end, + } +end + +return StadiumRomPick diff --git a/tests/stadium_anim_qa.lua b/tests/stadium_anim_qa.lua new file mode 100644 index 0000000..659191d --- /dev/null +++ b/tests/stadium_anim_qa.lua @@ -0,0 +1,496 @@ +-- STADIUM battles: every Pokemon, every animation, every frame. +-- +-- luajit mods/DramaticShapeVoxelMod/tests/stadium_anim_qa.lua \ +-- [--packs=DIR] [--dex=N[,N...]] [--step=0.5] [--quiet] +-- +-- Run from the PROJECT ROOT. +-- +-- ------- what this is for +-- +-- A battle asks a species for one of its animations and then poses, skins, +-- re-textures and draws it sixty times a second. Nothing in that chain is +-- exercised by the pack probe, which reads the format and walks a bind pose, +-- and nothing in it is exercised by the shot drivers, which show one species +-- in one animation at a time. So the failures that only some species have -- +-- a texture index nothing maps, an animation that throws a bone into orbit, +-- a track that indexes off its own end -- have had no way of being found +-- except by a player calling that Pokemon out. +-- +-- This is that sweep, headless: the REAL StadiumPack, the REAL StadiumRig +-- and the REAL StadiumMon over stubs for the three things a graphics context +-- provides (a mesh, an image, the draw call). Every species, every animation +-- it carries, every frame of it, plus the battle state machine over every +-- one of the 165 move slots. +-- +-- ------- the stubs are not lenient +-- +-- The fake mesh and the fake image behave like LOVE's do in the one way that +-- matters: an object that has been released THROWS when it is used, with +-- LOVE's own message. That is deliberate -- a released texture reaching a +-- draw call is a real failure mode of this mode (see the LRU note in +-- StadiumPack), and a stub that quietly accepted one would hide exactly the +-- class of bug this sweep exists to find. + +local args = {} +local only = nil +for _, a in ipairs({ ... }) do + local k, v = a:match("^%-%-([%w_]+)=(.*)$") + if k then args[k] = v elseif a == "--quiet" then args.quiet = "1" end +end +if args.dex then + only = {} + for n in args.dex:gmatch("%d+") do only[#only + 1] = tonumber(n) end +end + +local MOD = "mods/DramaticShapeVoxelMod" + +-- Where the .dsm packs are. The mod builds them into LOVE's save directory +-- on first run (StadiumInstall), which is where a developer machine actually +-- has them; a checkout that has run tools/stadium_pack.py has them in the +-- mod instead. Both are tried, and --packs overrides. +local PACK_DIRS = {} +local function lookIn(dir) + if dir and dir ~= "" then PACK_DIRS[#PACK_DIRS + 1] = dir end +end +lookIn(args.packs) +lookIn(os.getenv("APPDATA") + and os.getenv("APPDATA") .. "/LOVE/pokemon-love2d/dramatic_shape/stadium") +lookIn(os.getenv("HOME") + and os.getenv("HOME") + .. "/.local/share/love/pokemon-love2d/dramatic_shape/stadium") +lookIn(MOD .. "/assets/stadium") + +-- How far apart the sampled frames are, in the animation's own 30 Hz frames. +-- A half frame rather than a whole one because the rig INTERPOLATES between +-- entries, and the blend has guards of its own that only run when k > 0 -- +-- sampling on whole frames alone would never execute them. +local STEP = tonumber(args.step or "0.5") + +-- How much taller than its own bind pose a posed model may stand before it +-- is called broken. Generous on purpose: a Pokemon that rears up, a wing +-- that opens, a Gyarados that lunges are all genuinely several times their +-- standing height. What this catches is the other thing -- a bone thrown +-- hundreds of units off the body, which comes out in the dozens. +local EXPLODE = 6.0 + +-- ------- the harness + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local packDir = nil +for _, dir in ipairs(PACK_DIRS) do + if dir and dir ~= "" then + local fp = io.open(dir .. "/001.dsm", "rb") + if fp then fp:close() packDir = dir break end + end +end +if not packDir then + print("no .dsm packs found -- pass --packs=DIR") + print("looked in:") + for _, dir in ipairs(PACK_DIRS) do + if dir and dir ~= "" then print(" " .. dir) end + end + os.exit(1) +end + +local function readFile(path) + local fp = io.open(path, "rb") + if not fp then return nil end + local bytes = fp:read("*a") + fp:close() + return bytes +end + +-- ------- the three things a graphics context provides +-- +-- A released object throws, exactly as LOVE's does. See the header. + +local RELEASED = "Cannot use object after it has been released." + +local function checkLive(obj) + if obj and obj.released then error(RELEASED, 0) end +end + +local function newFakeImage(w, h) + local img = { w = w, h = h, released = false } + function img:setFilter() checkLive(self) end + function img:setWrap() checkLive(self) end + function img:release() self.released = true return true end + function img:type() return "Image" end + return img +end + +local function newFakeMesh(format, rows) + local mesh = { released = false, verts = rows } + function mesh:setVertexMap() checkLive(self) end + function mesh:setVertices(v) checkLive(self) self.verts = v end + function mesh:setTexture(t) checkLive(self) checkLive(t) self.tex = t end + function mesh:release() self.released = true return true end + function mesh:type() return "Mesh" end + return mesh +end + +love = { + filesystem = { + getInfo = function(rel) + -- the mod asks for "dramatic_shape/stadium/NNN.dsm" relative to the + -- save directory; the sweep serves that out of packDir + local name = rel:match("([^/]+)$") + local fp = io.open(packDir .. "/" .. name, "rb") + if not fp then return nil end + fp:close() + return { type = "file" } + end, + read = function(rel) + local name = rel:match("([^/]+)$") + return readFile(packDir .. "/" .. name) + end, + }, + graphics = { + newMesh = function(format, rows, mode, usage) return newFakeMesh(format, rows) end, + newImage = function(data) return newFakeImage(data.w, data.h) end, + }, + image = { + newImageData = function(w, h, fmt, bytes) return { w = w, h = h } end, + }, +} + +local V = {} +local modules = {} +V.mod = { + log = { + warn = function(_, fmt, ...) print("[warn] " .. fmt:format(...)) end, + info = function(_, fmt, ...) print("[info] " .. fmt:format(...)) end, + }, + read = function(_, rel) return readFile(MOD .. "/" .. rel) end, +} +function V.require(name) + if modules[name] then return modules[name] end + local chunk = assert(loadfile(MOD .. "/lib/" .. name .. ".lua")) + modules[name] = chunk(V) + return modules[name] +end + +-- Voxel3D, to the extent the rig touches it. `draw` mirrors the real one's +-- first act -- binding the texture to the mesh -- because that is the line a +-- released texture dies on (Voxel3D.draw), and the whole point of the stub +-- is that it dies there here too. +local drawn, skipped = 0, 0 +modules.Voxel3D = { + FORMAT = {}, + seams = function() end, + glass = function() end, + blend = function() end, + draw = function(mesh, texture, model, pull, sunModel) + if not mesh then return end + if texture then mesh:setTexture(texture) end + drawn = drawn + 1 + end, +} +-- the shadow pass, same shape +local shadowMap = { draw = function(mesh, texture, model) + if texture then mesh:setTexture(texture) end +end } + +local Pack = V.require("StadiumPack") +local Rig = V.require("StadiumRig") +local Mon = V.require("StadiumMon") + +-- ------- findings + +local findings = {} -- kind -> { {dex=, anim=, frame=, detail=} ... } +local kinds = {} -- kind order, first seen first + +local function report(kind, dex, anim, frame, detail) + if not findings[kind] then + findings[kind] = {} + kinds[#kinds + 1] = kind + end + local list = findings[kind] + list[#list + 1] = { dex = dex, anim = anim, frame = frame, detail = detail } +end + +-- ------- one animation, frame by frame + +local function bboxOf(rig) + local lo, hi = math.huge, -math.huge + local bad = false + for _, part in ipairs(rig.parts) do + local rows = part.rows + for k = 1, part.prim.vertCount do + local row = rows[k] + local x, y, z = row[1], row[2], row[3] + -- NaN is the only value not equal to itself; infinity survives that + -- test and has to be asked about separately + if x ~= x or y ~= y or z ~= z + or x == math.huge or x == -math.huge + or y == math.huge or y == -math.huge + or z == math.huge or z == -math.huge then + bad = true + else + if y < lo then lo = y end + if y > hi then hi = y end + end + end + end + if lo > hi then return 0, bad end + return hi - lo, bad +end + +-- A species' worth of work. Returns the number of frames stepped. +local function sweepSpecies(dex) + local model = Pack.load(dex) + if not model then + -- say WHY, or a sweep that ran out of file handles reads as 108 broken + -- Pokemon + local _, err = io.open(("%s/%03d.dsm"):format(packDir, dex), "rb") + report("pack did not load", dex, nil, nil, + ("StadiumPack.load returned nil (io.open says: %s)") + :format(tostring(err))) + return 0 + end + + local rig = Rig.new(model) + if not rig then + report("rig would not build", dex, nil, nil, + ("%d prims, %d bones"):format(model.primCount, model.boneCount)) + return 0 + end + + -- the bind pose, as the yardstick every posed frame is measured against + rig:pose(nil, 0, false) + rig:skin(0) + local bind = bboxOf(rig) + if not (bind > 0) then bind = 1 end + + local steps = 0 + for index, anim in ipairs(model.anims) do + local name = anim.name or ("#" .. index) + local frames = anim.frames or 1 + if frames < 1 then + report("animation has no frames", dex, name, nil, + ("frames=%d"):format(frames)) + end + -- the loop start has to be inside the animation or the wrap arithmetic + -- lands outside the track arrays + local loop = anim.loopStart or 0 + if loop < 0 or loop >= frames then + report("loopStart out of range", dex, name, nil, + ("loopStart=%d of %d frames"):format(loop, frames)) + end + if anim.aux and not (model.auxAnims and model.auxAnims[anim.aux]) then + report("aux animation missing", dex, name, nil, + ("anim.aux=%s, %d aux animations") + :format(tostring(anim.aux), model.auxCount or 0)) + end + + -- Both ways round: a standby loop WRAPS at the far end and a faint HOLDS + -- on its last frame, and the two take different branches of the pose + -- walk. Sampled a little past the end on purpose -- that is where a + -- battle actually leaves them. + for _, wrap in ipairs({ true, false }) do + local f = 0 + while f < frames + 2 do + local ok, err = pcall(function() + rig:pose(index, f, wrap) + rig:skin(0.5) + rig:textures(anim.aux) + rig:draw({ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }, 0) + end) + steps = steps + 1 + if not ok then + report("threw while playing", dex, name, f, tostring(err)) + break + end + local h, bad = bboxOf(rig) + if bad then + report("posed vertex is NaN or infinite", dex, name, f, "") + elseif h > bind * EXPLODE then + report("pose flies apart", dex, name, f, + ("%.0f units tall against a %.0f-unit bind pose (%.1fx)") + :format(h, bind, h / bind)) + end + -- every piece of the Pokemon has to have a texture to be drawn with; + -- one that resolves to nothing is a limb that is simply not there + for i, part in ipairs(rig.parts) do + if not part.texture then + report("part has no texture", dex, name, f, + ("prim %d wants texture %s of %d") + :format(i, tostring(part.prim.tex), model.texCount or 0)) + end + end + f = f + STEP + end + end + end + + -- ------- and the same species as a BATTLE drives it + -- + -- The sweep above plays the animations; this plays the STATE MACHINE, which + -- is what a fight actually touches: the context slots behind idle, the + -- entrance, the two reactions and the collapse, and then all 165 move slots + -- -- because a move's animation is looked up by move id in a table the + -- species carries, and an index in that table that points nowhere is a + -- crash on the frame that move is used. + local mon = Mon.new("player") + mon.rig, mon.model, mon.species = rig, model, dex + mon.state, mon.anim, mon.time = nil, nil, 0 + + for _, state in ipairs({ "idle", "entrance", "hit", "flinch", "faint" }) do + local ok, err = pcall(function() + mon:play(state) + -- a whole second of it at 60 Hz, which is what the fight does + for _ = 1, 60 do + mon:update(1 / 60) + mon:build() + end + end) + if not ok then + report("threw while playing", dex, state, nil, tostring(err)) + end + end + + for moveIndex = 1, Pack.N_MOVES do + local slot = model.moveAnim[moveIndex] + if slot and slot ~= Pack.NONE then + if not model.anims[slot + 1] then + report("move points at an animation that is not there", dex, + ("move %d"):format(moveIndex), nil, + ("anim index %d of %d"):format(slot + 1, model.animCount or 0)) + else + local aux = model.moveAux[moveIndex] + if aux and aux >= 0 and not (model.auxAnims and model.auxAnims[aux + 1]) then + report("move points at an aux that is not there", dex, + ("move %d"):format(moveIndex), nil, + ("aux index %d of %d"):format(aux + 1, model.auxCount or 0)) + end + local ok, err = pcall(function() + mon:attack(moveIndex) + for _ = 1, 30 do + mon:update(1 / 60) + mon:build() + end + end) + if not ok then + report("threw while playing", dex, ("move %d"):format(moveIndex), + nil, tostring(err)) + end + end + end + end + + rig:release() + return steps +end + +-- ------- the sweep + +local list = only +if not list then + list = {} + for dex = 1, 151 do list[dex] = dex end +end + +local started = os.clock() +local steps = 0 +local staticPose = {} +for _, dex in ipairs(list) do + local ok, err = pcall(function() steps = steps + sweepSpecies(dex) end) + if not ok then + report("the sweep itself threw", dex, nil, nil, tostring(err)) + end + local m = Pack.load(dex) + if m and m.staticPose then staticPose[#staticPose + 1] = dex end + if not args.quiet and dex % 10 == 0 then + io.write((" ... %d/%d %.0f MB\n") + :format(dex, #list, collectgarbage("count") / 1024)) + io.flush() + end +end + +-- ------- the LRU, which is the one failure a frame sweep cannot reach +-- +-- A model is evicted by SPECIES COUNT, not by whether anything is still +-- standing on it -- so a battle that has seen five species while two of them +-- are on the field releases the textures of a Pokemon that is still being +-- drawn. That is not something playing one species' animations can produce; +-- it takes a sixth load. Reproduced here directly. +local function sweepEviction() + local held = {} + -- two Pokemon out, as a battle has + for _, dex in ipairs({ 1, 4 }) do + local model = Pack.load(dex) + local rig = model and Rig.new(model) + if rig then held[#held + 1] = { dex = dex, model = model, rig = rig } end + end + if #held < 2 then return end + for _, h in ipairs(held) do + h.rig:pose(1, 0, true) + h.rig:skin(0) + h.rig:textures(nil) + end + -- and then the fight sees more species than the cache keeps + for _, dex in ipairs({ 7, 10, 13, 16, 19, 25 }) do Pack.load(dex) end + for _, h in ipairs(held) do + local ok, err = pcall(function() + h.rig:textures(nil) + h.rig:draw({ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }, 0) + end) + if not ok then + report("threw after the pack cache evicted a model still on the field", + h.dex, "idle", nil, tostring(err)) + end + end + for _, h in ipairs(held) do h.rig:release() end +end +sweepEviction() + +local elapsed = os.clock() - started + +-- ------- what it found + +print("") +print(("stadium animation QA: %d species, %d posed frames, %.1fs") + :format(#list, steps, elapsed)) +print(("packs: %s"):format(packDir)) +if #staticPose > 0 then + print(("staticPose (declined by the packer, never drawn): %s") + :format(table.concat(staticPose, " "))) +end +print("") + +local total = 0 +for _, kind in ipairs(kinds) do total = total + #findings[kind] end + +if total == 0 then + print("no findings") + os.exit(0) +end + +for _, kind in ipairs(kinds) do + local list2 = findings[kind] + print(("---- %s (%d)"):format(kind, #list2)) + -- one line per species per kind, with the first example and a count, so a + -- fault that spans every frame of an animation reads as one fault + local seen, order = {}, {} + for _, f in ipairs(list2) do + local key = ("%03d|%s"):format(f.dex or 0, tostring(f.anim)) + if not seen[key] then + seen[key] = { n = 0, f = f } + order[#order + 1] = key + end + seen[key].n = seen[key].n + 1 + end + for _, key in ipairs(order) do + local e = seen[key] + print((" %03d %-14s %s%s%s") + :format(e.f.dex or 0, tostring(e.f.anim), + e.f.frame and ("frame %.1f: "):format(e.f.frame) or "", + e.detail or e.f.detail or "", + e.n > 1 and (" (x%d)"):format(e.n) or "")) + end + print("") +end + +print(("%d findings in %d kinds"):format(total, #kinds)) +os.exit(total > 0 and 1 or 0) diff --git a/tests/stadium_import.lua b/tests/stadium_import.lua new file mode 100644 index 0000000..eed2f11 --- /dev/null +++ b/tests/stadium_import.lua @@ -0,0 +1,131 @@ +-- Driver: import a Stadium ROM through the OPTIONS row, with the modal file +-- dialog stubbed out. +-- +-- Everything except the dialog itself is the real path: the row is built the +-- way the OPTIONS menu builds it, its step() is pressed, StadiumRomPick reads +-- the file with io.open (love.filesystem cannot see an absolute path), +-- StadiumInstall builds from those bytes, and the loading screen goes up over +-- whatever asked. A modal dialog cannot be driven, so choose() is replaced +-- with the answer a player would have given it. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or ".scratchpad" + local lib = game.mods.exports.DRAMATIC_SHAPE.lib + local Pick = lib.require("StadiumRomPick") + local Install = lib.require("StadiumInstall") + local Pack = lib.require("StadiumPack") + local Battles = lib.require("OverworldBattle") + + U.teleport(game, "ROUTE_1", 5, 8, "down") + U.wait(30) + + -- FIRST let the automatic first-run build finish. A ROM is sitting in + -- baseroms/ in this checkout, so StadiumScreen.maybePush has already pushed + -- a loading screen and started one -- and import() refuses to start a + -- second while that runs, so every step below would silently no-op against + -- it. maybePush asks ONCE per boot, so once this is drained it stays + -- drained and the purge below cannot be undone behind our backs. + local drain = 0 + while (Install.pending() or Install.status.state == "building") + and drain < 5400 do + U.wait(10); drain = drain + 10 + end + while game.stack:top() and game.stack:top() ~= game.overworld do + U.wait(10) + end + U.log(("drained the automatic first-run build in %d frames"):format(drain)) + + -- back to a machine that has never built them, whatever earlier runs left + for _, name in ipairs(love.filesystem.getDirectoryItems(Pack.CACHE_DIR)) do + love.filesystem.remove(Pack.CACHE_DIR .. "/" .. name) + end + Install.forget() + Pack.forget() + + local function settle(what) + local waited = 0 + while Install.status.state == "building" and waited < 5400 do + U.wait(10) + waited = waited + 10 + end + U.log(("%s: %d frames (%.1fs) -- state=%s error=%s") + :format(what, waited, waited / 60, tostring(Install.status.state), + tostring(Install.status.error))) + return waited + end + + U.log(("picker available on this platform: %s"):format( + tostring(Pick.available()))) + U.log(("BEFORE: models available = %s, 3D-BTL offers %d rungs") + :format(tostring(Install.available()), Battles.setting:rungs())) + + local row = Pick.row() + if not row then U.log("no import row on this platform") return end + U.log(("row reads: %s = %s"):format(row.label, row.value())) + + -- ------- the wrong file first, because a refusal must not leave a mess + U.log("-- picking a file that is not a Stadium ROM --") + Pick.choose = function() return "/not/a/real/path.z64" end + local realRead = Pick.read + Pick.read = function() return ("\x80\x37\x12\x40"):rep(4096) end + row.step(game) + settle("wrong file") + U.shot(game, ("%s/import_1_refused.png"):format(DIR)) + U.log((" still not installed: %s, row still reads %s") + :format(tostring(not Install.available()), row.value())) + U.wait(300) -- let the failure notice time out and pop itself + + -- ------- and now the real one + U.log("-- picking the real ROM --") + Pick.read = realRead + local abs = love.filesystem.getWorkingDirectory() .. "/baseroms/baserom.z64" + local picked = false + Pick.choose = function() picked = true return abs end + Install.status.state = "idle" + row.step(game) + U.log((" choose() called: %s, state = %s, loading screen on top: %s") + :format(tostring(picked), tostring(Install.status.state), + tostring(game.stack:top() ~= game.overworld))) + U.wait(30) + U.shot(game, ("%s/import_2_building.png"):format(DIR)) + settle("real ROM") + U.wait(120) + + U.log(("AFTER: models available = %s, 3D-BTL offers %d rungs, row reads %s") + :format(tostring(Install.available()), Battles.setting:rungs(), + row.value())) + Battles.setting:setValue("stadium", game) + U.log((" and 3D-BTL can now be set to STADIUM A: %s") + :format(tostring(Battles.setting:get() == "stadium"))) + -- and the ROM was NOT copied anywhere: the only baserom physfs can see is + -- the one in the game folder this checkout already had, never a copy in + -- the save directory (which is where a mod's writes land) + local where = love.filesystem.getRealDirectory("baseroms/baserom.z64") + U.log((" the only ROM on the read path is the game folder's own: %s (%s)") + :format(tostring(where == love.filesystem.getWorkingDirectory()), + tostring(where))) + U.log((" the save directory has no baseroms folder: %s") + :format(tostring(love.filesystem.getInfo( + love.filesystem.getSaveDirectory() .. "/baseroms") == nil))) + + -- ------- and the wrong file, now that a GOOD set is installed + -- + -- The serious case. The marker is the only thing that makes 151 files on + -- disk count as installed, so a refusal that writes one anyway would + -- UNINSTALL a working set -- and the player would have pressed a row called + -- STADIUM ROM, picked the wrong file, and lost the models they already had. + U.log("-- picking the wrong file again, with models already installed --") + Pick.read = function() return ("€7@"):rep(4096) end + Install.status.state = "idle" + row.step(game) + settle("wrong file over a good install") + U.wait(300) + Install.forget() + Pack.forget() + U.log((" the good install SURVIVED: %s, row reads %s, 3D-BTL offers %d rungs") + :format(tostring(Install.available()), row.value(), + Battles.setting:rungs())) + U.log((" and a real model still loads: %s") + :format(tostring(Pack.load(25) ~= nil))) + U.log("done -- " .. DIR) +end