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 <cursoragent@cursor.com>
This commit is contained in:
Andrew Quenehen
2026-08-01 04:57:17 -03:00
parent b1ad7c7254
commit ac6dfe7134
6 changed files with 104 additions and 51 deletions
+12 -14
View File
@@ -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 `<redacted>` 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.
+5 -1
View File
@@ -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(
+12 -3
View File
@@ -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
+24 -29
View File
@@ -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/...).
-- 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 not base then return false end
if mountReadable(base .. SEP .. sub, false) then
mountGeneratedTrees("")
if base then
mountReadable(base .. SEP .. sub, false)
end
end
-- Version-scoped generated trees → un-prefixed paths (Red prefix is "").
mountGeneratedTrees(prefix)
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
end
-- Undo mountVersion. A process normally mounts exactly one version and then
+35
View File
@@ -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()
+12
View File
@@ -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