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
+28 -33
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/...).
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
+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