fix(switch): prefer Yellow/Blue asset bytes when mount overlay lies

Probe generated canaries after mountVersion and always readActive for
prefixed caches so sprites are not blanked by empty PhysFS stubs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Andrew Quenehen
2026-08-03 14:50:28 -03:00
parent cfe482e1fc
commit 4f54255518
5 changed files with 197 additions and 53 deletions
+47
View File
@@ -409,6 +409,32 @@ local function mountGeneratedTrees(prefix)
return mounted
end
-- True when unprefixed generated cache is actually readable (not merely a
-- directory stub PhysFS can see). Used after Blue/Yellow mounts so NX cannot
-- silently boot with a broken overlay.
local function generatedCacheVisible()
if not (love and love.filesystem) then return false end
local canaries = {
"assets/generated/fonts/font.png",
"data/generated/constants.lua",
}
for _, path in ipairs(canaries) do
local bytes = love.filesystem.read(path)
if type(bytes) == "string" and #bytes > 0 then return true end
end
return false
end
local function noteMountProbe(version, prefix, ok)
local okReq, Diag = pcall(require, "src.debug.SwitchDiagnostics")
if okReq and Diag and Diag.onEvent then
Diag.onEvent(ok and "mount_probe_ok" or "mount_probe_failed", {
version = tostring(version or ""),
prefix = tostring(prefix or ""),
})
end
end
function CacheFs.mountVersion(version)
local prefix = require("src.core.GameVersion").cachePrefix(version)
local sub = prefix:gsub("/+$", "")
@@ -432,6 +458,27 @@ function CacheFs.mountVersion(version)
-- Version-scoped generated trees → un-prefixed paths (Red prefix is "").
mountGeneratedTrees(prefix)
-- Blue/Yellow: verify the overlay actually exposes generated files. A failed
-- mount still returns true so Play can fall back through CacheFs.readActive,
-- but we retry once and leave a SwitchDiagnostics breadcrumb when enabled.
if sub ~= "" then
if not generatedCacheVisible() then
mountGeneratedTrees(prefix)
end
local ok = generatedCacheVisible()
noteMountProbe(version, prefix, ok)
if not ok then
-- Last resort: root mount alone sometimes leaves assets/ hidden behind
-- fused archive assets/; re-issue both mounts once more.
if love.filesystem.mount and love.filesystem.getInfo(sub, "directory") then
love.filesystem.mount(sub, "", false)
end
mountGeneratedTrees(prefix)
noteMountProbe(version, prefix, generatedCacheVisible())
end
end
return true
end
+15 -4
View File
@@ -43,15 +43,26 @@ function Assets.resolve(path)
return loader:derivedPath(rel) or path
end
-- When PhysFS hides Blue/Yellow prefixed trees (fused NX), load generated
-- asset bytes the same way Data:load does via CacheFs.readActive.
-- When PhysFS hides or mis-exposes Blue/Yellow prefixed trees (fused NX),
-- load generated asset bytes the same way Data:load does via CacheFs.readActive.
-- Blue/Yellow prefer versioned bytes whenever present: getInfo can succeed on a
-- broken overlay while sprites decode as blank (OBP keys white → transparent).
local function generatedFileData(resolved)
if type(resolved) ~= "string" or resolved:sub(1, #GENERATED) ~= GENERATED then
return nil
end
local CacheFs = require("src.import.CacheFs")
local prefix = require("src.core.GameVersion").cachePrefix()
if prefix ~= "" then
local bytes = CacheFs.readActive(resolved)
if type(bytes) == "string" and #bytes > 0 then
return love.filesystem.newFileData(bytes, resolved)
end
return nil
end
if exists(resolved) then return nil end
local bytes = require("src.import.CacheFs").readActive(resolved)
if type(bytes) ~= "string" then return nil end
local bytes = CacheFs.readActive(resolved)
if type(bytes) ~= "string" or #bytes == 0 then return nil end
return love.filesystem.newFileData(bytes, resolved)
end
@@ -57,6 +57,15 @@ check(redImg ~= nil, "Assets.image loads Red tileset from unprefixed path")
eq(love.filesystem.read(PNG), "red-png-bytes",
"Red still stores generated assets at the save-dir root")
-- --- Stale unprefixed path must not win over Yellow bytes (NX mount lie)
GameVersion.set("yellow")
Assets.flush()
love.filesystem.write(PNG, "") -- visible but empty → would bake blank sprites
love.filesystem.write("yellow/" .. PNG, "yellow-real-png")
local staleImg = Assets.image(PNG)
check(staleImg ~= nil, "Assets.image prefers Yellow bytes over empty unprefixed stub")
eq(staleImg.path, PNG, "stale-path fallback still names the logical generated path")
-- --- Missing generated file: no invented bytes
GameVersion.set("yellow")
Assets.flush()
+19 -1
View File
@@ -4,13 +4,16 @@ if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.harness")
local check = T.check
local eq = T.eq
local CacheFs = require("src.import.CacheFs")
local GameVersion = require("src.core.GameVersion")
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")
love.filesystem.write("blue/data/generated/constants.lua", "return {}")
love.filesystem.write("blue/assets/generated/fonts/font.png", "font-bytes")
check(CacheFs.mountVersion("blue") == true, "mountVersion(blue) returns true")
@@ -32,4 +35,19 @@ 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")
eq(love.filesystem.read("assets/generated/fonts/font.png"), "font-bytes",
"post-mount probe can read unprefixed assets/generated canary")
eq(love.filesystem.read("data/generated/constants.lua"), "return {}",
"post-mount probe can read unprefixed data/generated canary")
-- Yellow-only: same overlay contract
love.filesystem._mounts = {}
GameVersion.set("yellow")
love.filesystem.write("yellow/data/generated/constants.lua", "return {y=1}")
love.filesystem.write("yellow/assets/generated/fonts/font.png", "yellow-font")
check(CacheFs.mountVersion("yellow") == true, "mountVersion(yellow) returns true")
eq(love.filesystem.read("assets/generated/fonts/font.png"), "yellow-font",
"Yellow mount exposes fonts/font.png at the unprefixed path")
GameVersion.set("red")
T.finish()
+90 -31
View File
@@ -125,14 +125,70 @@ stub.math = {
stub.filesystem = {
write = function(name, content) files[name] = content return true end,
read = function(name) return files[name] end,
remove = function(name) files[name] = nil return true end,
newFileData = function(contents, name)
return { _fileData = true, contents = contents, name = name or "" }
end,
createDirectory = function() return true end,
-- directories are implied by key prefixes ("mods/x/manifest.json")
getInfo = function(name, filter)
-- 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(archive)
local mounts = stub.filesystem._mounts
for i = #mounts, 1, -1 do
if mounts[i].archive == archive then
table.remove(mounts, i)
return true
end
end
return false
end,
getSaveDirectory = function() return "/tmp/pokeport-stub-save" end,
isFused = function() return false end,
}
-- Resolve a PhysFS path through recorded mounts (prepend first, newest wins).
local function resolveViaMounts(name)
local mounts = stub.filesystem._mounts
for i = #mounts, 1, -1 do
local m = mounts[i]
if not m.append then
local mp = m.mountpoint or ""
local key
if mp == "" then
key = m.archive .. "/" .. name
elseif name == mp then
key = m.archive
elseif name:sub(1, #mp + 1) == mp .. "/" then
local rel = name:sub(#mp + 2)
key = m.archive .. "/" .. rel
end
if key then
if files[key] then return key, "file" end
local prefix = key .. "/"
for k in pairs(files) do
if k:sub(1, #prefix) == prefix then return key, "directory" end
end
end
end
end
return nil
end
function stub.filesystem.read(name)
if files[name] then return files[name] end
local key = resolveViaMounts(name)
if key and files[key] then return files[key] end
return nil
end
function stub.filesystem.getInfo(name, filter)
if files[name] then
if filter and filter ~= "file" then return nil end
return { type = "file" }
@@ -144,52 +200,55 @@ stub.filesystem = {
return { type = "directory" }
end
end
local key, kind = resolveViaMounts(name)
if key and kind then
if filter and filter ~= kind then return nil end
return { type = kind }
end
return nil
end,
load = function(name)
if not files[name] then return nil, "no file" end
return load(files[name], name)
end,
getDirectoryItems = function(name)
end
stub.filesystem.load = function(name)
local data = stub.filesystem.read(name)
if not data then return nil, "no file" end
return load(data, name)
end
stub.filesystem.getDirectoryItems = function(name)
local seen, items = {}, {}
name = name or ""
-- "" / "/" = save-dir root (RomImporter Android ROM scan)
if name == "" or name == "/" then
for key in pairs(files) do
local child = key:match("^[^/]+")
local function addChild(child)
if child and not seen[child] then
seen[child] = true
items[#items + 1] = child
end
end
-- "" / "/" = save-dir root (RomImporter Android ROM scan)
if name == "" or name == "/" then
for key in pairs(files) do
addChild(key:match("^[^/]+"))
end
else
local prefix = name .. "/"
for key in pairs(files) do
if key:sub(1, #prefix) == prefix then
local child = key:sub(#prefix + 1):match("^[^/]+")
if child and not seen[child] then
seen[child] = true
items[#items + 1] = child
addChild(key:sub(#prefix + 1):match("^[^/]+"))
end
end
-- Also surface children exposed via mounts.
local key = resolveViaMounts(name)
if key then
local mprefix = key .. "/"
for k in pairs(files) do
if k:sub(1, #mprefix) == mprefix then
addChild(k:sub(#mprefix + 1):match("^[^/]+"))
end
end
end
end
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,
}
end
-- table-backed SoundData so ChipAudio's offline render seam
-- (_renderMusicForTest) runs headless; modkit bounce writes WAVs from it