From 4f5425551841e8960666a7e2ccf3ed9fe74157e4 Mon Sep 17 00:00:00 2001 From: Andrew Quenehen Date: Mon, 3 Aug 2026 14:50:28 -0300 Subject: [PATCH] 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 --- src/import/CacheFs.lua | 47 ++++++ src/render/Assets.lua | 19 ++- tests/engine/assets_version_fallback_test.lua | 9 + tests/engine/cache_fs_blue_mount_test.lua | 20 ++- tests/love_stub.lua | 155 ++++++++++++------ 5 files changed, 197 insertions(+), 53 deletions(-) diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua index 1f19dc66..e5057a20 100644 --- a/src/import/CacheFs.lua +++ b/src/import/CacheFs.lua @@ -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 diff --git a/src/render/Assets.lua b/src/render/Assets.lua index 79ee9115..e0f59960 100644 --- a/src/render/Assets.lua +++ b/src/render/Assets.lua @@ -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 diff --git a/tests/engine/assets_version_fallback_test.lua b/tests/engine/assets_version_fallback_test.lua index b0be3f6d..fc0098b0 100644 --- a/tests/engine/assets_version_fallback_test.lua +++ b/tests/engine/assets_version_fallback_test.lua @@ -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() diff --git a/tests/engine/cache_fs_blue_mount_test.lua b/tests/engine/cache_fs_blue_mount_test.lua index 7fa0189f..f0a81e8a 100644 --- a/tests/engine/cache_fs_blue_mount_test.lua +++ b/tests/engine/cache_fs_blue_mount_test.lua @@ -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() diff --git a/tests/love_stub.lua b/tests/love_stub.lua index 7f79ba3a..f95c33c5 100644 --- a/tests/love_stub.lua +++ b/tests/love_stub.lua @@ -125,58 +125,11 @@ 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) - if files[name] then - if filter and filter ~= "file" then return nil end - return { type = "file" } - end - local prefix = name .. "/" - for key in pairs(files) do - if key:sub(1, #prefix) == prefix then - if filter and filter ~= "directory" then return nil end - return { type = "directory" } - end - 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) - 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("^[^/]+") - if child and not seen[child] then - seen[child] = true - items[#items + 1] = child - end - 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 - 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) @@ -186,11 +139,117 @@ stub.filesystem = { } return true end, - unmount = function() 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" } + end + local prefix = name .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + if filter and filter ~= "directory" then return nil end + 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 + +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 "" + 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 + 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 + -- table-backed SoundData so ChipAudio's offline render seam -- (_renderMusicForTest) runs headless; modkit bounce writes WAVs from it local SoundData = {}