From ca10a7b86019e04ee07d9d91db0ff8cc3995a4ec Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Tue, 4 Aug 2026 17:40:59 -0400 Subject: [PATCH] update tests/lib --- lib/StadiumInstall.lua | 14 +++++ lib/StadiumRomPick.lua | 102 ++++++++++++++++++++++++++------ lib/StadiumScreen.lua | 107 ++++++++++++++++++++++++---------- main.lua | 9 +++ tests/dramatic_shape_test.lua | 38 +++++++----- 5 files changed, 209 insertions(+), 61 deletions(-) diff --git a/lib/StadiumInstall.lua b/lib/StadiumInstall.lua index 3c25c9b..a72e6d1 100644 --- a/lib/StadiumInstall.lua +++ b/lib/StadiumInstall.lua @@ -117,6 +117,20 @@ function StadiumInstall.romHint() return base .. "/" .. StadiumInstall.ROM_DIR end +-- The same thing with a FILENAME on the end, which is what a player actually +-- needs: a folder alone leaves them guessing what to call the file, and the +-- guess is not obviously "baserom.z64". +-- +-- Taken from the head of NAMED rather than retyped, so the name shown is by +-- construction the first name looked for. It is not the ONLY one that works +-- -- `.n64` and `.v64` are accepted, and so is any other name carrying one +-- of those extensions -- but an instruction that names one file is one a +-- player can follow, and an instruction that lists every possibility is one +-- they have to interpret. +function StadiumInstall.romHintFile() + return StadiumInstall.romHint() .. "/" .. (NAMED[1]:match("[^/]+$") or "") +end + -- ------- the marker local function readMarker() diff --git a/lib/StadiumRomPick.lua b/lib/StadiumRomPick.lua index 6eea946..a99d583 100644 --- a/lib/StadiumRomPick.lua +++ b/lib/StadiumRomPick.lua @@ -96,26 +96,42 @@ local function commandOutput(cmd) return (out ~= "") and out or nil end --- ------- can this machine open one at all +-- ------- can this machine open a DIALOG -- --- 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. +-- Desktop only, and honestly so. -- --- 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() +-- 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`. That is not +-- merely the wrong name -- it is the file the engine's Game Boy importer is +-- watching, and reading that code settles it: the importer's size test only +-- SKIPS a 1 MB file it has already imported, so a 32 MB N64 ROM landing +-- there falls straight through to `love.filesystem.remove` and +-- `startData` -- deleted, and then reported to the player as a broken Game +-- Boy ROM. So the bridge is not called until it learns the kind, which is a +-- two-line change in System.cpp and an APK rebuild (see README). +-- +-- Android is not stuck without it: 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. What Android +-- lacked was being TOLD that -- the row vanished, and the folder's absolute +-- path was only ever written to a console no phone shows. That is what the +-- note below is for. +function StadiumRomPick.canDialog() if not (haveShell() and haveFiles()) then return false end local p = osName() return p == "Windows" or p == "OS X" or p == "Linux" end +-- Kept as the old name for callers that only wanted "is there a dialog". +StadiumRomPick.available = StadiumRomPick.canDialog + +-- Where a SAF pick would land if the native bridge grows a Stadium kind. +-- Watched unconditionally (see poll): on a build that never writes it this +-- costs one getInfo a frame, and on one that does the mod needs no further +-- change to use it. +StadiumRomPick.PICKED = "picked_stadium.z64" + -- Open the dialog. Returns the chosen absolute path, or nil when the player -- cancelled or no dialog could be opened. function StadiumRomPick.choose() @@ -184,10 +200,22 @@ end -- that quietly goes on saying IMPORT. function StadiumRomPick.import(game) if StadiumInstall.status.state == "building" then return false end + local StadiumScreen = V.require("StadiumScreen") + + -- No dialog on this platform: say where the file goes, on screen, because + -- that is the whole of what the player is missing and the console is not + -- somewhere they can read it. + if not StadiumRomPick.canDialog() then + if game and game.stack then + game.stack:push(StadiumScreen.newNote(game, "STADIUM ROM", + "PUT STADIUM US 1.0 HERE:", + StadiumInstall.romHintFile())) + end + 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 @@ -223,13 +251,16 @@ end -- 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" + if StadiumInstall.available() then return "READY" end + -- WHERE, not IMPORT, where pressing it can only tell you the folder: + -- a row that says IMPORT and then does not import is a worse row than + -- one that says what it actually does + return StadiumRomPick.canDialog() and "IMPORT" or "WHERE?" end, step = function(game) pcall(StadiumRomPick.import, game) @@ -238,4 +269,41 @@ function StadiumRomPick.row() } end +-- ------- a pick that landed while we were not looking +-- +-- The desktop dialog BLOCKS, so `import` above can read the answer on the +-- next line. A SAF pick cannot work that way: it is a separate activity, +-- Android is free to destroy the game while it is up, and the file appears +-- some frames later -- so the only way to notice one is to look for it. +-- +-- Nothing writes this filename today (see canDialog). It is watched anyway so +-- that teaching the native bridge one more kind is the whole of the Android +-- picker work, with no second change needed here. +-- +-- Consumed and DELETED either way: a 32 MB file left in the save directory +-- would be imported again on the next boot, and kept forever if the import +-- failed. +function StadiumRomPick.poll(game) + local f = love and love.filesystem + if not (f and f.getInfo) then return false end + if StadiumInstall.status.state == "building" then return false end + local ok, info = pcall(f.getInfo, StadiumRomPick.PICKED, "file") + if not (ok and info) then return false end + + local okRead, bytes = pcall(f.read, StadiumRomPick.PICKED) + pcall(f.remove, StadiumRomPick.PICKED) + if not (okRead and type(bytes) == "string") then return false end + + local StadiumScreen = V.require("StadiumScreen") + local started, err = StadiumInstall.beginFrom(bytes, StadiumRomPick.PICKED) + if not started then + StadiumInstall.status.state = "failed" + StadiumInstall.status.error = tostring(err) + end + if game and game.stack then + game.stack:push(StadiumScreen.new(game, true)) + end + return true +end + return StadiumRomPick diff --git a/lib/StadiumScreen.lua b/lib/StadiumScreen.lua index f9bdbea..23b8202 100644 --- a/lib/StadiumScreen.lua +++ b/lib/StadiumScreen.lua @@ -70,31 +70,37 @@ end -- overflow tidily off one side, it clips off BOTH and loses its first word as -- well as its last ("that is not a Pokemon Stadium ROM" came out as "at is -- not a Pokemon"). +-- +-- Fixed, and it stays fixed: shrinking the text to fit more in was tried and +-- the font will not take it. These are 1-bit 8x8 bitmaps, so a fractional +-- downscale drops whole pixel rows out of every glyph -- at 0.75 the last +-- line of an Android save path came out as mush. Long strings get more LINES +-- instead (see the note layout in draw). local COLS = 20 --- Break a reason into lines that fit, on word boundaries, and never more than --- `limit` of them -- the plate has room for two and a message nobody can read --- the end of is not improved by adding a third. -local function wrapped(str, limit) +-- Break a string into lines that fit, on word boundaries, and never more than +-- `limit` of them. +local function wrapped(str, limit, cols) limit = limit or 2 + cols = cols or COLS local lines, line = {}, nil local function push(text) if #lines < limit then lines[#lines + 1] = text end end for word in tostring(str):gmatch("%S+") do local try = line and (line .. " " .. word) or word - if #try <= COLS then + if #try <= cols then line = try else if line then push(line) end -- A word longer than the line is BROKEN ACROSS lines rather than cut. -- It is always a path, and a path is the one thing here that has to be - -- readable in full -- an absolute Android save directory runs to about - -- seventy characters and has no spaces in it at all, so truncating at + -- readable in full -- an absolute Android save directory runs to + -- ninety-odd characters with no spaces in it at all, so truncating at -- twenty told the player almost nothing. - while #word > COLS do - push(word:sub(1, COLS)) - word = word:sub(COLS + 1) + while #word > cols do + push(word:sub(1, cols)) + word = word:sub(cols + 1) end line = word end @@ -149,8 +155,9 @@ end -- -- Same state shape and the same plate as the build screen, so there is one -- look and one set of stack manners rather than two. -function StadiumScreen.newNote(game, title, body) - return setmetatable({ game = game, note = { title = title, body = body } }, +function StadiumScreen.newNote(game, title, lead, body) + return setmetatable({ game = game, + note = { title = title, lead = lead, body = body } }, StadiumScreen) end @@ -168,8 +175,38 @@ function StadiumScreen:enter() end end +-- The buttons that dismiss a note. Every face button and START, because the +-- prompt says ANY and a player who has to hunt for the right one on a phone +-- has been lied to. +local DISMISS = { "a", "b", "start", "select" } + function StadiumScreen:update() - if self.note then return end -- a note waits for a key, not a clock + -- ------- a note is dismissed by a BUTTON, not by a key + -- + -- `onKeyPressed` is the keyboard, and a phone has none: the touch overlay + -- feeds the engine's Input as virtual buttons (Input.overlayPressed), so a + -- state that only listens for keys cannot be closed by touch at all. That + -- stranded a player on this screen with no way off it -- the one screen in + -- the mod whose entire job is to tell somebody something and then get out + -- of the way. + -- + -- Polled here rather than handled as an event because `wasPressed` is the + -- edge test the engine's own battle screens use, and it is fed by the + -- keyboard, the gamepad AND the overlay through one path. + if self.note then + local input = self.game and self.game.input + if input and input.wasPressed then + for _, btn in ipairs(DISMISS) do + if input:wasPressed(btn) then + if self.game.stack and self.game.stack:top() == self then + self.game.stack:pop() + end + return + end + end + end + return + end local status = StadiumInstall.status if status.state == "building" then if not StadiumInstall.step() then @@ -206,8 +243,9 @@ local function pop(self) end function StadiumScreen:onKeyPressed(key) - -- a note is dismissed by ANY key: it is telling the player something, and - -- making them guess which button acknowledges it would be its own joke + -- A note takes any key too. This is the KEYBOARD path and it is not the + -- one that matters on a phone -- see update, which polls the engine's + -- Input so the touch overlay's virtual buttons work as well. if self.note then pop(self) return true end if key == "escape" or key == "x" or key == "backspace" then StadiumInstall.cancel() @@ -225,12 +263,20 @@ function StadiumScreen:draw() love.graphics.rectangle("fill", 0, 0, W, H) if self.note then - centred(self.note.title, 20) - -- six lines is the plate's room; an absolute path on Android runs to - -- about seventy characters, which is four of them - local lines = wrapped(self.note.body, 6) - for i, line in ipairs(lines) do centred(line, 44 + (i - 1) * 10) end - centred("PRESS ANY KEY", 124) + centred(self.note.title, 12) + -- The sentence is kept SHORT so the path can have the rest of the plate + -- at full size. Shrinking the path was tried first and does not survive + -- the font: these are 1-bit 8x8 bitmaps, so a fractional downscale drops + -- whole pixel rows out of every glyph and the last line came out as + -- mush. Nine rows of twenty characters is 180, which is longer than any + -- real save path, so nothing has to be shrunk to fit. + local lead = wrapped(self.note.lead or "", 2) + for i, line in ipairs(lead) do centred(line, 30 + (i - 1) * 10) end + local y = 30 + #lead * 10 + 6 + for i, line in ipairs(wrapped(self.note.body or "", 9)) do + centred(line, y + (i - 1) * 9) + end + centred("PRESS ANY KEY", 130) love.graphics.setColor(1, 1, 1, 1) return end @@ -319,16 +365,17 @@ function StadiumScreen.maybePush() -- no restart. The folder is still said, once, for the platforms with no -- dialog (Android, a handheld Linux with neither zenity nor kdialog) -- and for anyone who would rather drop a file than click through one. + -- The STADIUM ROM row is on the OPTIONS menu on every platform now, so + -- point at it rather than reciting a path here: where a file dialog can + -- be opened it opens one, and where it cannot it shows this same folder + -- on screen -- which is the part a phone could not otherwise find out. local okPick, pick = pcall(V.require, "StadiumRomPick") - local canPick = okPick and pick and pick.available() - V.mod.log:info("stadium: no Pokemon Stadium ROM found, so the STADIUM " - .. "battle rungs are off. %s", - canPick - and ("Import one from OPTIONS -> " .. pick.LABEL - .. ", or put a .z64/.n64/.v64 in " - .. StadiumInstall.romHint() .. " and restart.") - or ("Put one (.z64/.n64/.v64) in " - .. StadiumInstall.romHint() .. " and restart.")) + local label = (okPick and pick and pick.LABEL) or "STADIUM ROM" + local how = (okPick and pick and pick.canDialog()) + and "opens a file picker" or "says where to put one" + V.mod.log:info("stadium: no Pokemon Stadium (US) 1.0 ROM found, so the " + .. "STADIUM battle rungs are off. OPTIONS -> %s %s; the " + .. "folder is %s", label, how, StadiumInstall.romHint()) end return false end diff --git a/main.lua b/main.lua index 8cf1045..4714165 100644 --- a/main.lua +++ b/main.lua @@ -208,6 +208,11 @@ mod.content.render_pipelines:register("voxel", { -- world, so it is never fighting the engine's own launcher for the -- screen. pcall(function() V.require("StadiumScreen").maybePush() end) + -- and a ROM the system file picker dropped in the save directory while + -- we were not the top activity (Android; see StadiumRomPick.poll) + pcall(function() + V.require("StadiumRomPick").poll(require("src.core.Game")) + end) -- The horde, on the same always-running tick and for the same reason: -- it owns no pass of the frame, it is a MODE over the overworld, and -- it has to keep thinking while a warp's wipe covers the screen (the @@ -802,6 +807,10 @@ mod.hooks:wrap("ui.options.rows", function(next, game, rows) -- to restore on the next boot, so it is appended here rather than living in -- SETTINGS. nil on a platform with no file dialog, which takes it off the -- menu rather than offering a button that cannot do anything. + -- On EVERY platform. Where there is no file dialog it says WHERE? and + -- shows the folder to put the cartridge in, which is the one thing a + -- player on a phone could not otherwise find out -- the row used to vanish + -- there, which reads as the feature being missing rather than manual. local okPick, importRow = pcall(function() return V.require("StadiumRomPick").row() end) diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 04a4522..4614afe 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -400,7 +400,12 @@ end Pipelines.setLevel("voxel", 2) local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end, { data = Data }, { { id = "text_speed" } }) -T.eq(#hookedRows, 9, "the options hook added a row per setting") +-- one per setting, plus the STADIUM ROM action row -- which is not a setting +-- (nothing to store, nothing for the mod manager to persist) and is offered +-- on every platform, saying WHERE? rather than IMPORT where there is no file +-- dialog to open +T.eq(#hookedRows, 10, "the options hook added a row per setting, plus the " + .. "STADIUM ROM action row") local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[4] local battles, backRow, daytime = hookedRows[5], hookedRows[6], hookedRows[7] -- the AA row is hookedRows[8]; it is read in its own block below, because @@ -1184,22 +1189,27 @@ Battles.setting:setGate(BATTLE_ROW_GATE) local Install = run.loader.exports.DRAMATIC_SHAPE.lib.require("StadiumInstall") - T.check(type(Pick.available()) == "boolean", + T.check(type(Pick.canDialog()) == "boolean", "the picker reports whether this platform has a file dialog at all") + -- The row is offered on EVERY platform. It used to be dropped where no + -- dialog could be opened, which on Android read as the feature being + -- missing rather than manual -- and the folder path it needed to show was + -- only ever written to a console a phone does not have. local row = Pick.row() - if not Pick.available() then - T.eq(row, nil, - "with no dialog available there is no row -- a button that cannot open " - .. "anything is worse than the folder instruction it would replace") - else - T.check(row ~= nil, "and where there is one, there is a row") - T.eq(row.label, "STADIUM ROM", "which says what it is for") - T.check(type(row.step) == "function", "and does something when pressed") - -- the value is the STATE, so a player can see whether it worked - T.eq(row.value(), Install.available() and "READY" or "IMPORT", - "reading READY once the models are installed and IMPORT before that") - end + T.check(row ~= nil, "the row is offered whatever the platform can do") + T.eq(row.label, "STADIUM ROM", "and says what it is for") + T.check(type(row.step) == "function", "and does something when pressed") + T.eq(row.value(), + Install.available() and "READY" or (Pick.canDialog() and "IMPORT" or "WHERE?"), + "reading READY once installed, IMPORT where a dialog can be opened, and " + .. "WHERE? where pressing it can only name the folder -- a row that said " + .. "IMPORT and then did not import would be the worse lie") + + -- and the drop folder it would name is an absolute path, which the note + -- screen has to be able to show in full + T.check(type(Install.romHint()) == "string" and #Install.romHint() > 0, + "there is a folder to name") -- A ROM that carries no models must be refused BEFORE anything is written. -- An empty build otherwise completes with nothing attempted and therefore