From b1ad7c72544fa5e03a46a577c427cee04a2a0162 Mon Sep 17 00:00:00 2001 From: Andrew Quenehen Date: Sat, 1 Aug 2026 04:55:08 -0300 Subject: [PATCH] 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 --- docs/switch-hardware-evidence.md | 28 ++++++++++++++++ src/core/Data.lua | 14 +++++++- src/debug/SwitchDiagnostics.lua | 7 +++- src/import/CacheFs.lua | 42 ++++++++++++++++++++++-- src/update/Boot.lua | 6 ++++ tests/engine/switch_diagnostics_test.lua | 9 +++++ 6 files changed, 101 insertions(+), 5 deletions(-) diff --git a/docs/switch-hardware-evidence.md b/docs/switch-hardware-evidence.md index 07b6f137..54471162 100644 --- a/docs/switch-hardware-evidence.md +++ b/docs/switch-hardware-evidence.md @@ -76,3 +76,31 @@ T16 hardware gate: **closed**. | Full console reboot persistence | **pass** (operator 2026-08-01) | 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 `` 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. diff --git a/src/core/Data.lua b/src/core/Data.lua index 13c0a1dd..8c622c94 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -196,7 +196,19 @@ local function loadModule(dir, name) if not chunk then return false, err end return pcall(chunk) 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 function Data:load() diff --git a/src/debug/SwitchDiagnostics.lua b/src/debug/SwitchDiagnostics.lua index 2499bff5..b5b34794 100644 --- a/src/debug/SwitchDiagnostics.lua +++ b/src/debug/SwitchDiagnostics.lua @@ -23,10 +23,15 @@ end local function redactString(s) 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 local b = s:byte(i) - if b < 32 or b > 126 then return "" end + if b == 0 then return "" end + if b < 32 and b ~= 9 and b ~= 10 and b ~= 13 then return "" end + if b > 126 then return "" end end + if #s > 8192 then return s:sub(1, 8192) .. "..." end return s end diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua index 5cca3b27..27fae0e7 100644 --- a/src/import/CacheFs.lua +++ b/src/import/CacheFs.lua @@ -366,9 +366,40 @@ end -- *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 -- 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) 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 -- The cache root is the portable game folder when active, else LÖVE's OS -- save directory (where love.filesystem wrote blue/... or yellow/...). @@ -377,11 +408,16 @@ function CacheFs.mountVersion(version) base = love.filesystem.getSaveDirectory() 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 -- that lives in the save directory by name (prepended: appendToPath=false). if love.filesystem.mount then - return love.filesystem.mount(sub, "", false) + local ok = love.filesystem.mount(sub, "", false) + mountGeneratedTrees("") + return ok end return false end diff --git a/src/update/Boot.lua b/src/update/Boot.lua index 069fbe43..b3936ad4 100644 --- a/src/update/Boot.lua +++ b/src/update/Boot.lua @@ -250,6 +250,12 @@ function Boot.run(args) if not (love.filesystem.isFused and love.filesystem.isFused()) then return false 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. if _G.POKEPORT_PAYLOAD_MOUNTED then return false end diff --git a/tests/engine/switch_diagnostics_test.lua b/tests/engine/switch_diagnostics_test.lua index ca49dc59..58b3c173 100644 --- a/tests/engine/switch_diagnostics_test.lua +++ b/tests/engine/switch_diagnostics_test.lua @@ -61,4 +61,13 @@ check(errLog:find("probe failure", 1, true) ~= nil, "lua-error.log records messa check(errLog:find("", 1, true) ~= nil, "lua-error.log strips 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 +-- "" (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()