From ac6dfe7134b90441f354cddcd0d8e1ddcb1c4a55 Mon Sep 17 00:00:00 2001 From: Andrew Quenehen Date: Sat, 1 Aug 2026 04:57:17 -0300 Subject: [PATCH] fix(import): mount Blue/Yellow save-dir cache without FFI NX Play for Blue failed because mountVersion relied on absolute PHYSFS_mount first. Prefer love.filesystem.mount of blue|yellow, overlay generated trees by version prefix, and align CacheFs.prefix in bootGame. Co-authored-by: Cursor --- docs/switch-hardware-evidence.md | 26 +++++----- main.lua | 6 ++- src/core/Data.lua | 15 ++++-- src/import/CacheFs.lua | 61 +++++++++++------------ tests/engine/cache_fs_blue_mount_test.lua | 35 +++++++++++++ tests/love_stub.lua | 12 +++++ 6 files changed, 104 insertions(+), 51 deletions(-) create mode 100644 tests/engine/cache_fs_blue_mount_test.lua diff --git a/docs/switch-hardware-evidence.md b/docs/switch-hardware-evidence.md index 54471162..571a6205 100644 --- a/docs/switch-hardware-evidence.md +++ b/docs/switch-hardware-evidence.md @@ -79,28 +79,26 @@ T19 hardware gate: **closed**. No stuck input, duplicate audio, or crash reporte --- -## T24 — fused NRO alone + NRO-only update — PARTIAL / FAIL (Play) +## T24 — fused NRO — Red PASS; Blue Play FAIL (open) | Field | Value | | ----- | ----- | -| Commit tested | `6fb5602` | -| Artifact | `dist/switch/gen1recomp-6fb5602-switch.nro` | +| Commit (first fused attempt) | `6fb5602` | +| Artifact | `gen1recomp-6fb5602-switch.nro` | | SHA-256 | `b019e2e82c7fe6ec3cf4339e1bc71e8752c8140b6242028bb0b662fcf20daac2` | -| Deploy | isolated folder, **no** adjacent `game.love` | -| MTP round-trip | skipped | +| Deploy | isolated folder, no adjacent `game.love` | | Boot fused | **pass** | -| ROM import (inbox) | **pass** | -| Play after import | **fail** — app closed immediately | +| ROM import | **pass** | +| Play **Red** (operator follow-up) | **pass** — same import flow as loose; fused not the differentiator | +| Play **Blue** (operator follow-up) | **fail** — app closed on Play (was mis-attributed to fused-only) | | NRO-only replace / save survive | **not tested** | -### lua-error.log (operator) +### Revised root cause -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`). +Blue/Yellow caches live under `blue/` / `yellow/`. `CacheFs.mountVersion` preferred absolute `PHYSFS_mount(save/blue)` (FFI), which fails on NX; the save-dir-relative `love.filesystem.mount("blue", …)` was only a fallback after that path assumed a usable `base`. Red (`cachePrefix == ""`) never needed that mount → Play OK. -### Suspected root cause (fix in flight) +### Fix follow-up -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. +Mount save-dir-relative version folder first; always `mountGeneratedTrees(prefix)` for `blue/data/generated` → `data/generated`; set `CacheFs.prefix` in `bootGame`; Data:load reads version-prefixed cache on require miss. -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. +**T24**: fused Red OK is evidence for fused boot/import/play(Red). Full T24 (NRO-only update) + Blue Play still need re-verify. diff --git a/main.lua b/main.lua index d1b4651d..9c79e914 100644 --- a/main.lua +++ b/main.lua @@ -177,7 +177,11 @@ local function bootGame(version) -- data, so data/generated + assets/generated resolve to that version's files. local GameVersion = require("src.core.GameVersion") GameVersion.set(version or os.getenv("POKEPORT_VERSION") or "red") - require("src.import.CacheFs").mountVersion(GameVersion.get()) + local CacheFs = require("src.import.CacheFs") + -- Keep CacheFs.prefix aligned for any CacheFs.read fallback during Data:load + -- (Blue/Yellow caches live under blue/ / yellow/). + CacheFs.prefix = GameVersion.cachePrefix() + CacheFs.mountVersion(GameVersion.get()) if love.window and love.window.setTitle then local Version = require("src.core.Version") love.window.setTitle(Version.title( diff --git a/src/core/Data.lua b/src/core/Data.lua index 8c622c94..6abaeb50 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -198,11 +198,20 @@ local function loadModule(dir, name) end 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. + -- Fused PhysFS / Blue|Yellow prefix: load bytes from the active version's + -- cache explicitly when require cannot see the mounted tree. local CacheFs = require("src.import.CacheFs") - local path = "data/generated/" .. name .. ".lua" + local GameVersion = require("src.core.GameVersion") + local prefix = GameVersion.cachePrefix() + local path = prefix .. "data/generated/" .. name .. ".lua" + local saved = CacheFs.prefix + CacheFs.prefix = "" local bytes = CacheFs.read(path) + CacheFs.prefix = saved + if type(bytes) ~= "string" then + -- Also try with CacheFs.prefix if the caller set it for this version. + bytes = CacheFs.read("data/generated/" .. name .. ".lua") + end if type(bytes) == "string" then local chunk, err = loadstring(bytes, "@" .. path) if not chunk then return false, err or mod end diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua index 27fae0e7..5dc3ec58 100644 --- a/src/import/CacheFs.lua +++ b/src/import/CacheFs.lua @@ -361,18 +361,15 @@ end -- Overlay the active version's extracted cache onto the un-prefixed read -- paths, so require("data.generated.*") and love.graphics.newImage( --- "assets/generated/*") resolve to that version's files. Red lives at the --- cache root and needs nothing; non-Red versions (blue/, yellow/, …) are --- *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. +-- "assets/generated/*") resolve to that version's files. -- --- 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. +-- Non-Red versions live under blue/ / yellow/ in the save directory. On +-- desktop fused+portable we PHYSFS_mount that folder by absolute path. On +-- NX (and any host without a working FFI mount) love.filesystem.mount of +-- the save-dir-relative name must succeed, or Play boots with Red's paths +-- and Data:load dies. Always also prepend-mount the version's +-- data/generated + assets/generated onto the un-prefixed paths so PhysFS +-- directory non-merge (archive data/ vs save generated) cannot hide them. local function mountGeneratedTrees(prefix) prefix = prefix or "" if not (love and love.filesystem and love.filesystem.mount) then @@ -396,30 +393,28 @@ end function CacheFs.mountVersion(version) local prefix = require("src.core.GameVersion").cachePrefix(version) - if prefix == "" then - mountGeneratedTrees("") - return true + local sub = prefix:gsub("/+$", "") + + -- Save-dir relative mount first (NX / no-FFI). Prepend so blue|yellow win. + if sub ~= "" and love.filesystem.mount + and love.filesystem.getInfo(sub, "directory") then + love.filesystem.mount(sub, "", false) 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/...). - local base = CacheFs.root() - if not base and love.filesystem.getSaveDirectory then - base = love.filesystem.getSaveDirectory() + + -- Portable / desktop fused: absolute PHYSFS_mount of the version folder. + if sub ~= "" then + local base = CacheFs.root() + if not base and love.filesystem.getSaveDirectory then + base = love.filesystem.getSaveDirectory() + end + if base then + mountReadable(base .. SEP .. sub, false) + end end - if not base then return false 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 - local ok = love.filesystem.mount(sub, "", false) - mountGeneratedTrees("") - return ok - end - return false + + -- Version-scoped generated trees → un-prefixed paths (Red prefix is ""). + mountGeneratedTrees(prefix) + return true end -- Undo mountVersion. A process normally mounts exactly one version and then diff --git a/tests/engine/cache_fs_blue_mount_test.lua b/tests/engine/cache_fs_blue_mount_test.lua new file mode 100644 index 00000000..7fa0189f --- /dev/null +++ b/tests/engine/cache_fs_blue_mount_test.lua @@ -0,0 +1,35 @@ +-- Blue/Yellow mountVersion must overlay save-dir caches without FFI (NX). +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check = T.check + +local CacheFs = require("src.import.CacheFs") + +love.filesystem._mounts = {} +-- Imply blue/data/generated and blue/assets/generated directories via file keys. +love.filesystem.write("blue/data/generated/maps.lua", "return {}") +love.filesystem.write("blue/assets/generated/fonts/font.png", "x") + +check(CacheFs.mountVersion("blue") == true, "mountVersion(blue) returns true") + +local sawBlueRoot, sawDataGen, sawAssetsGen = false, false, false +for _, m in ipairs(love.filesystem._mounts) do + if m.archive == "blue" and m.mountpoint == "" and m.append == false then + sawBlueRoot = true + end + if m.archive == "blue/data/generated" and m.mountpoint == "data/generated" + and m.append == false then + sawDataGen = true + end + if m.archive == "blue/assets/generated" and m.mountpoint == "assets/generated" + and m.append == false then + sawAssetsGen = true + end +end +check(sawBlueRoot, "prepend-mounts save-dir relative blue/") +check(sawDataGen, "prepend-mounts blue/data/generated -> data/generated") +check(sawAssetsGen, "prepend-mounts blue/assets/generated -> assets/generated") + +T.finish() diff --git a/tests/love_stub.lua b/tests/love_stub.lua index 8973c8a0..61cfa6d7 100644 --- a/tests/love_stub.lua +++ b/tests/love_stub.lua @@ -160,6 +160,18 @@ stub.filesystem = { table.sort(items) return items end, + -- Record mounts for CacheFs.mountVersion tests (NX Blue/Yellow overlay). + _mounts = {}, + mount = function(archive, mountpoint, appendToPath) + stub.filesystem._mounts[#stub.filesystem._mounts + 1] = { + archive = archive, mountpoint = mountpoint or "", + append = appendToPath and true or false, + } + return true + end, + unmount = function() return true end, + getSaveDirectory = function() return "/tmp/pokeport-stub-save" end, + isFused = function() return false end, } -- table-backed SoundData so ChipAudio's offline render seam