mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 08:01:09 +02:00
file picker update
This commit is contained in:
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user