fix(switch): unhide fused save-dir generated cache on Play

PhysFS does not merge archive data/ with save-dir data/generated, so
fused NX Play crashed after import. Prepend-mount generated trees,
fall back to CacheFs.read in Data:load, keep multiline lua-error logs,
and skip Boot.run when network is unvalidated. T24 stays open.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Andrew Quenehen
2026-08-01 04:55:08 -03:00
parent 6fb5602cb0
commit b1ad7c7254
6 changed files with 101 additions and 5 deletions
+28
View File
@@ -76,3 +76,31 @@ T16 hardware gate: **closed**.
| Full console reboot persistence | **pass** (operator 2026-08-01) | | Full console reboot persistence | **pass** (operator 2026-08-01) |
T19 hardware gate: **closed**. No stuck input, duplicate audio, or crash reported. T19 hardware gate: **closed**. No stuck input, duplicate audio, or crash reported.
---
## T24 — fused NRO alone + NRO-only update — PARTIAL / FAIL (Play)
| Field | Value |
| ----- | ----- |
| Commit tested | `6fb5602` |
| Artifact | `dist/switch/gen1recomp-6fb5602-switch.nro` |
| SHA-256 | `b019e2e82c7fe6ec3cf4339e1bc71e8752c8140b6242028bb0b662fcf20daac2` |
| Deploy | isolated folder, **no** adjacent `game.love` |
| MTP round-trip | skipped |
| Boot fused | **pass** |
| ROM import (inbox) | **pass** |
| Play after import | **fail** — app closed immediately |
| NRO-only replace / save survive | **not tested** |
### lua-error.log (operator)
Two events ~1 min apart; body was fully `<redacted>` because `redactString` treated newlines in stack traces as binary. Fix: allow TAB/LF/CR in diagnostics (`SwitchDiagnostics`).
### Suspected root cause (fix in flight)
Fused `game.love` contains a `data/` tree (scripts). PhysFS does not merge that with save-dir `data/generated/` after import → `Data:load` / `require("data.generated.*")` fails on Play while launcher `isReady` still sees CacheFs files. Loose mode search order differed enough that Play worked earlier.
Mitigations: `CacheFs.mountVersion` prepend-mounts `data/generated` + `assets/generated`; `Data.loadModule` falls back to `CacheFs.read`+`loadstring`; Boot.run skips on NX (`networkValidated == false`).
**T24 remains OPEN** until fused Play re-verify + NRO-only save test.
+13 -1
View File
@@ -196,7 +196,19 @@ local function loadModule(dir, name)
if not chunk then return false, err end if not chunk then return false, err end
return pcall(chunk) return pcall(chunk)
end end
return pcall(require, "data.generated." .. name) local ok, mod = pcall(require, "data.generated." .. name)
if ok then return true, mod end
-- Fused PhysFS may hide save-dir data/generated behind the archive's
-- data/ tree even after mountVersion; load bytes explicitly as fallback.
local CacheFs = require("src.import.CacheFs")
local path = "data/generated/" .. name .. ".lua"
local bytes = CacheFs.read(path)
if type(bytes) == "string" then
local chunk, err = loadstring(bytes, "@" .. path)
if not chunk then return false, err or mod end
return pcall(chunk)
end
return false, mod
end end
function Data:load() function Data:load()
+6 -1
View File
@@ -23,10 +23,15 @@ end
local function redactString(s) local function redactString(s)
if type(s) ~= "string" then return s end if type(s) ~= "string" then return s end
-- Keep printable ASCII + TAB/LF/CR so Lua stack traces remain readable.
-- Reject NULs and other C0 controls, and high bytes (ROM/binary dumps).
for i = 1, #s do for i = 1, #s do
local b = s:byte(i) local b = s:byte(i)
if b < 32 or b > 126 then return "<redacted>" end if b == 0 then return "<redacted>" end
if b < 32 and b ~= 9 and b ~= 10 and b ~= 13 then return "<redacted>" end
if b > 126 then return "<redacted>" end
end end
if #s > 8192 then return s:sub(1, 8192) .. "...<truncated>" end
return s return s
end end
+39 -3
View File
@@ -366,9 +366,40 @@ end
-- *prepended* so they win over any Red copy at the root and over the game -- *prepended* so they win over any Red copy at the root and over the game
-- source. Called once at boot, before Game:load (main.lua). Returns true -- source. Called once at boot, before Game:load (main.lua). Returns true
-- when nothing was needed or the mount succeeded. -- when nothing was needed or the mount succeeded.
--
-- Fused builds also ship a `data/` tree (scripts, palettes) inside game.love.
-- PhysFS does not merge directories across archives: that `data/` can hide
-- `data/generated/` written to the save directory after ROM import. Loose
-- play often still works (search order / mount layout differs); fused NX
-- Play-after-import then fails Data:load with a missing-module error.
-- Explicitly prepend-mount the generated subtrees so they win.
local function mountGeneratedTrees(prefix)
prefix = prefix or ""
if not (love and love.filesystem and love.filesystem.mount) then
return false
end
local mounted = false
local pairs_ = {
{ prefix .. "data/generated", "data/generated" },
{ prefix .. "assets/generated", "assets/generated" },
}
for _, item in ipairs(pairs_) do
local src, dest = item[1], item[2]
if love.filesystem.getInfo(src, "directory") then
if love.filesystem.mount(src, dest, false) then
mounted = true
end
end
end
return mounted
end
function CacheFs.mountVersion(version) function CacheFs.mountVersion(version)
local prefix = require("src.core.GameVersion").cachePrefix(version) local prefix = require("src.core.GameVersion").cachePrefix(version)
if prefix == "" then return true end -- Red: already at the root if prefix == "" then
mountGeneratedTrees("")
return true
end
local sub = prefix:gsub("/+$", "") -- "blue/" / "yellow/" -> bare dir local sub = prefix:gsub("/+$", "") -- "blue/" / "yellow/" -> bare dir
-- The cache root is the portable game folder when active, else LÖVE's OS -- The cache root is the portable game folder when active, else LÖVE's OS
-- save directory (where love.filesystem wrote blue/... or yellow/...). -- save directory (where love.filesystem wrote blue/... or yellow/...).
@@ -377,11 +408,16 @@ function CacheFs.mountVersion(version)
base = love.filesystem.getSaveDirectory() base = love.filesystem.getSaveDirectory()
end end
if not base then return false end if not base then return false end
if mountReadable(base .. SEP .. sub, false) then return true end if mountReadable(base .. SEP .. sub, false) then
mountGeneratedTrees("")
return true
end
-- Fallback when FFI/PHYSFS_mount is unavailable: LÖVE can mount a folder -- Fallback when FFI/PHYSFS_mount is unavailable: LÖVE can mount a folder
-- that lives in the save directory by name (prepended: appendToPath=false). -- that lives in the save directory by name (prepended: appendToPath=false).
if love.filesystem.mount then if love.filesystem.mount then
return love.filesystem.mount(sub, "", false) local ok = love.filesystem.mount(sub, "", false)
mountGeneratedTrees("")
return ok
end end
return false return false
end end
+6
View File
@@ -250,6 +250,12 @@ function Boot.run(args)
if not (love.filesystem.isFused and love.filesystem.isFused()) then if not (love.filesystem.isFused and love.filesystem.isFused()) then
return false return false
end end
-- Switch (and any host without validated network): never probe payloads.
local okp, Platform = pcall(require, "src.core.Platform")
if okp and Platform and Platform.networkValidated
and not Platform.networkValidated() then
return false
end
-- The chainloaded love.load calls Boot.run again; the flag makes it a no-op. -- The chainloaded love.load calls Boot.run again; the flag makes it a no-op.
if _G.POKEPORT_PAYLOAD_MOUNTED then return false end if _G.POKEPORT_PAYLOAD_MOUNTED then return false end
+9
View File
@@ -61,4 +61,13 @@ check(errLog:find("probe failure", 1, true) ~= nil, "lua-error.log records messa
check(errLog:find("<redacted>", 1, true) ~= nil, "lua-error.log strips ROM bytes") check(errLog:find("<redacted>", 1, true) ~= nil, "lua-error.log strips ROM bytes")
check(not errLog:find(romErr, 1, true), "lua-error.log omits raw ROM bytes") check(not errLog:find(romErr, 1, true), "lua-error.log omits raw ROM bytes")
-- Stack-trace style messages (newlines) must remain readable — not wholesale
-- "<redacted>" (fused Play triage regression).
SwitchDiagnostics.logLuaError("missing module 'data/generated/maps.lua'.\nImport again.\n(detail)")
errLog = love.filesystem.read("lua-error.log") or ""
check(errLog:find("missing module", 1, true) ~= nil,
"lua-error.log keeps printable multiline error text")
check(errLog:find("Import again", 1, true) ~= nil,
"lua-error.log preserves lines after newline")
T.finish() T.finish()