fix(switch): load Yellow/Blue assets when PhysFS mount hides them

Mirror Data:load's versioned CacheFs read in Assets so Yellow-only NX
Play survives intro without needing a Red root cache mask.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Andrew Quenehen
2026-08-03 14:20:53 -03:00
parent dd5d8afd82
commit d5886e69aa
5 changed files with 181 additions and 18 deletions
+3 -11
View File
@@ -209,18 +209,10 @@ local function loadModule(dir, name)
-- cache explicitly when require cannot see the mounted tree.
local CacheFs = require("src.import.CacheFs")
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
local path = "data/generated/" .. name .. ".lua"
local bytes = CacheFs.readActive(path)
if type(bytes) == "string" then
local chunk, err = loadstring(bytes, "@" .. path)
local chunk, err = loadstring(bytes, "@" .. GameVersion.cachePrefix() .. path)
if not chunk then return false, err or mod end
return pcall(chunk)
end
+18
View File
@@ -294,6 +294,24 @@ function CacheFs.read(rel)
return love.filesystem.read(rel)
end
-- Read cache-relative `rel` for the active GameVersion when PhysFS may hide
-- prefixed Blue/Yellow trees (fused NX mount hole). Same order Data:load
-- already used: active version prefix with CacheFs.prefix cleared, then
-- `rel` under the caller's CacheFs.prefix. Returns the bytes or nil.
function CacheFs.readActive(rel)
local GameVersion = require("src.core.GameVersion")
local prefix = GameVersion.cachePrefix()
local saved = CacheFs.prefix
CacheFs.prefix = ""
local bytes = CacheFs.read(prefix .. rel)
CacheFs.prefix = saved
if type(bytes) ~= "string" then
bytes = CacheFs.read(rel)
end
if type(bytes) == "string" then return bytes end
return nil
end
-- does cache-relative `rel` exist as a file?
function CacheFs.exists(rel)
rel = withPrefix(rel)
+24 -2
View File
@@ -43,11 +43,28 @@ 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.
local function generatedFileData(resolved)
if type(resolved) ~= "string" or resolved:sub(1, #GENERATED) ~= GENERATED then
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
return love.filesystem.newFileData(bytes, resolved)
end
function Assets.image(path)
local resolved = Assets.resolve(path)
local image = cache[resolved]
if not image then
image = love.graphics.newImage(resolved)
local fileData = generatedFileData(resolved)
if fileData then
image = love.graphics.newImage(fileData)
else
image = love.graphics.newImage(resolved)
end
cache[resolved] = image
end
return image
@@ -56,7 +73,12 @@ end
-- pixel-level reads (tile-shift variants, the spinner strip blit) resolve
-- the same way but stay uncached: the caller keeps the derived product
function Assets.imageData(path)
return love.image.newImageData(Assets.resolve(path))
local resolved = Assets.resolve(path)
local fileData = generatedFileData(resolved)
if fileData then
return love.image.newImageData(fileData)
end
return love.image.newImageData(resolved)
end
function Assets.register(invalidate)
@@ -0,0 +1,90 @@
-- Yellow/Blue-only NX: PhysFS may hide prefixed assets/generated trees.
-- Assets must fall back through CacheFs.readActive like Data:load does.
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 eq = T.eq
local GameVersion = require("src.core.GameVersion")
local CacheFs = require("src.import.CacheFs")
local Assets = require("src.render.Assets")
local PNG = "assets/generated/tilesets/reds_house.png"
local savedPrefix = CacheFs.prefix
local savedVersion = GameVersion.get()
local function clearPath(path)
love.filesystem.remove(path)
end
-- --- readActive: Yellow-prefixed bytes without unprefixed PhysFS visibility
GameVersion.set("yellow")
CacheFs.prefix = ""
love.filesystem.write("yellow/" .. PNG, "yellow-png-bytes")
clearPath(PNG)
eq(CacheFs.readActive(PNG), "yellow-png-bytes",
"readActive finds yellow/<rel> when unprefixed path is missing")
-- --- Assets.image fallback (Yellow-only mount hole)
Assets.flush()
local img = Assets.image(PNG)
check(img ~= nil, "Assets.image loads Yellow tileset via readActive fallback")
eq(img.path, PNG, "fallback Image keeps the logical generated path name")
-- --- Assets.imageData fallback
local id = Assets.imageData(PNG)
check(id ~= nil, "Assets.imageData loads Yellow tileset via readActive fallback")
eq(id.path, PNG, "fallback ImageData keeps the logical generated path name")
-- --- Blue prefix
GameVersion.set("blue")
Assets.flush()
love.filesystem.write("blue/" .. PNG, "blue-png-bytes")
clearPath(PNG)
clearPath("yellow/" .. PNG)
local blueImg = Assets.image(PNG)
check(blueImg ~= nil, "Assets.image loads Blue tileset via readActive fallback")
-- --- Red primary path (unprefixed file visible → no fallback needed)
GameVersion.set("red")
Assets.flush()
love.filesystem.write(PNG, "red-png-bytes")
clearPath("blue/" .. PNG)
local redImg = Assets.image(PNG)
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")
-- --- Missing generated file: no invented bytes
GameVersion.set("yellow")
Assets.flush()
clearPath(PNG)
clearPath("yellow/" .. PNG)
clearPath("blue/" .. PNG)
eq(CacheFs.readActive(PNG), nil,
"readActive returns nil when the file is absent in every tree")
-- --- Non-generated paths never hit the versioned asset tree
love.filesystem.write("yellow/" .. PNG, "should-not-leak")
eq(CacheFs.readActive("assets/launcher/missing_chip.png"), nil,
"readActive does not remap unrelated paths onto yellow generated assets")
-- --- readActive with CacheFs.prefix set (Data:load second try)
GameVersion.set("yellow")
CacheFs.prefix = "yellow/"
love.filesystem.write("yellow/data/generated/maps.lua", "return { ok = true }")
local luaBytes = CacheFs.readActive("data/generated/maps.lua")
check(type(luaBytes) == "string" and luaBytes:find("ok", 1, true),
"readActive still finds yellow/data/generated when CacheFs.prefix is set")
CacheFs.prefix = savedPrefix
GameVersion.set(savedVersion)
Assets.flush()
clearPath(PNG)
clearPath("yellow/" .. PNG)
clearPath("blue/" .. PNG)
clearPath("yellow/data/generated/maps.lua")
T.finish()
+46 -5
View File
@@ -40,8 +40,15 @@ local gstate = { shader = nil, canvas = nil, blend = "alpha",
local gstack = {}
stub.graphics = {
newImage = function(path)
local w, h = pngSize(path)
newImage = function(pathOrData)
local path = pathOrData
if type(pathOrData) == "table" and pathOrData._fileData then
path = pathOrData.name or "<filedata>"
elseif type(pathOrData) == "table" and pathOrData.path then
path = pathOrData.path
end
local w, h = 8, 8
if type(path) == "string" then w, h = pngSize(path) end
return setmetatable({ w = w, h = h, path = path }, Image)
end,
newQuad = function(x, y, w, h) return { x = x, y = y, w = w, h = h } end,
@@ -120,12 +127,22 @@ 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)
if files[name] then return { type = "file" } end
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 return { type = "directory" } end
if key:sub(1, #prefix) == prefix then
if filter and filter ~= "directory" then return nil end
return { type = "directory" }
end
end
return nil
end,
@@ -215,6 +232,30 @@ stub.mouse = {
stub.timer = { getTime = function() return 0 end }
-- Minimal image module so Assets.imageData can decode FileData fallbacks
-- headless (full pixel stubs live in tests/mod_graphics_tests.lua).
local ImageData = {}
ImageData.__index = ImageData
function ImageData:getWidth() return self.w end
function ImageData:getHeight() return self.h end
function ImageData:getDimensions() return self.w, self.h end
function ImageData:getPixel() return 0, 0, 0, 1 end
function ImageData:setPixel() end
function ImageData:mapPixel() end
function ImageData:encode() return { getString = function() return "" end } end
stub.image = {
newImageData = function(a, b)
if type(a) == "table" and a._fileData then
return setmetatable({ w = 8, h = 8, path = a.name, source = a }, ImageData)
end
if type(a) == "string" then
return setmetatable({ w = 8, h = 8, path = a }, ImageData)
end
return setmetatable({ w = a or 8, h = b or 8 }, ImageData)
end,
}
-- Desktop / headless: full-window safe area (matches LÖVE's fallback).
stub.window = {
getSafeArea = function()