fix(switch): wrap the whole read-side love API surface in the NX overlay

The overlay only wrapped the five loaders the boot path needed, leaving a
silent-failure hole: any future state (or current code like Sound.lua's
widenMono, which re-reads the pika-cry WAV via love.sound.newSoundData
with the caller's bare path) could load a generated asset through an
unwrapped API and silently degrade on hardware.

NxAssetOverlay now wraps every read-side love function that accepts a
filesystem path (filesystem.read/load/lines/newFileData/getInfo,
graphics.newImage/newFont, image.newImageData, audio.newSource,
sound.newSoundData, font.newFontData), so new states and mods fall inside
the Blue/Yellow fallback with zero per-call-site work.  Write-side
functions stay stock, proven by identity assertions in the fallback
suite.  The static guard's forbidden-literal list covers the same APIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Andrew Quenehen
2026-08-03 16:14:33 -03:00
parent 98c08e4b22
commit 12ff4dba2e
4 changed files with 74 additions and 29 deletions
+1 -1
View File
@@ -434,7 +434,7 @@ Community mod zip install smoke (MODS inbox + Play): NXMOD-12 in [switch-hardwar
**NX asset probe (always on Play):** every Switch Play writes `nx-asset-probe.log` in the save directory (`pokemon-love2d/`). It lists whether `assets/generated/…` vs `yellow|blue/assets/generated/…` exist, what `Assets.resolve` returns, and whether `newImage` / `newImageData` open — for Yellow/Blue blank-sprite triage. No ROM bytes. **NX asset probe (always on Play):** every Switch Play writes `nx-asset-probe.log` in the save directory (`pokemon-love2d/`). It lists whether `assets/generated/…` vs `yellow|blue/assets/generated/…` exist, what `Assets.resolve` returns, and whether `newImage` / `newImageData` open — for Yellow/Blue blank-sprite triage. No ROM bytes.
**Blue/Yellow cache overlay (NX):** fused love-nx cannot reliably mount `yellow|blue/assets/generated` onto the un-prefixed path, so `src/core/NxAssetOverlay.lua` wraps the love loaders (`newImage`, `newImageData`, `newSource`, `filesystem.read`, `filesystem.getInfo`) once at boot — only when `Platform.isNX()`. Any `assets/generated/*` read that misses falls back to the versioned `yellow|blue/` copy. Core code must NOT call love loaders on literal `assets/generated` paths (enforced by `tests/engine/nx_generated_guard_test.lua`); the chip-audio worker is a separate Lua state and gets the prefix explicitly via `audio.programPrefix` from `ChipAudio.slimAudio`. **Blue/Yellow cache overlay (NX):** fused love-nx cannot reliably mount `yellow|blue/assets/generated` onto the un-prefixed path, so `src/core/NxAssetOverlay.lua` wraps EVERY read-side love API that accepts a filesystem path (`filesystem.read/load/lines/newFileData/getInfo`, `graphics.newImage/newFont`, `image.newImageData`, `audio.newSource`, `sound.newSoundData`, `font.newFontData`) once at boot — only when `Platform.isNX()`. Covering the whole read surface (not just the loaders the boot needs today) keeps future states and mods inside the fallback automatically; write-side functions stay stock. Core code must NOT call love loaders on literal `assets/generated` paths (enforced by `tests/engine/nx_generated_guard_test.lua`); the chip-audio worker is a separate Lua state and gets the prefix explicitly via `audio.programPrefix` from `ChipAudio.slimAudio`.
**Hardware re-test:** T16 **pass** @ `2699c9a` (naming A=confirm / B=cancel). T19 **pass** (quit/reopen, suspend×10, reboot) — operator 2026-08-01. **Hardware re-test:** T16 **pass** @ `2699c9a` (naming A=confirm / B=cancel). T19 **pass** (quit/reopen, suspend×10, reboot) — operator 2026-08-01.
+40 -28
View File
@@ -1,14 +1,18 @@
-- NX-only asset overlay: fused love-nx cannot reliably mount -- NX-only asset overlay: fused love-nx cannot reliably mount
-- blue|yellow/assets/generated onto the un-prefixed assets/generated, so -- blue|yellow/assets/generated onto the un-prefixed assets/generated, so
-- instead of teaching every call site about versioned caches, this module -- instead of teaching every call site about versioned caches, this module
-- wraps the love loading entry points ONCE at boot: any string path under -- wraps EVERY read-side love entry point that accepts a filesystem path
-- assets/generated/ that does not resolve falls back to the active -- once at boot: any string path under assets/generated/ that does not
-- version's prefixed copy (yellow|blue/assets/generated/...). -- resolve falls back to the active version's prefixed copy
-- (yellow|blue/assets/generated/...). Covering the whole read surface --
-- not just the loaders we happened to need -- is what keeps future states
-- and mods inside the fallback without anyone updating this file.
-- --
-- main.lua installs it only when Platform.isNX(); desktop/Android/iOS never -- main.lua installs it only when Platform.isNX(); desktop/Android/iOS never
-- install it, so their mountVersion overlay stays the single mechanism and -- install it, so their mountVersion overlay stays the single mechanism and
-- their loaders keep stock behavior. Writes are deliberately NOT wrapped: -- their loaders keep stock behavior. Write-side functions (write, remove,
-- the importer must keep targeting the versioned tree explicitly. -- createDirectory, mount, ...) are deliberately NOT wrapped: the importer
-- must keep targeting the versioned tree explicitly.
-- --
-- Two intentional exceptions stay outside this module: -- Two intentional exceptions stay outside this module:
-- * the chip-audio worker (src/core/chip_worker.lua) is a separate Lua -- * the chip-audio worker (src/core/chip_worker.lua) is a separate Lua
@@ -48,6 +52,22 @@ local function wrapLoader(fn)
end end
end end
-- Every read-side love function that can take an assets/generated path.
-- getInfo is wrapped separately (it must return the versioned file's info,
-- not just forward a rewritten argument list).
local WRAP_SPEC = {
{ "filesystem", "read" },
{ "filesystem", "load" },
{ "filesystem", "lines" },
{ "filesystem", "newFileData" },
{ "graphics", "newImage" },
{ "graphics", "newFont" },
{ "image", "newImageData" },
{ "audio", "newSource" },
{ "sound", "newSoundData" },
{ "font", "newFontData" },
}
function NxAssetOverlay.isInstalled() function NxAssetOverlay.isInstalled()
return originals ~= nil return originals ~= nil
end end
@@ -55,40 +75,32 @@ end
function NxAssetOverlay.install() function NxAssetOverlay.install()
if originals then return end if originals then return end
if not (love and love.filesystem) then return end if not (love and love.filesystem) then return end
originals = { originals = {}
read = love.filesystem.read, for _, spec in ipairs(WRAP_SPEC) do
getInfo = love.filesystem.getInfo, local ns, name = spec[1], spec[2]
newImage = love.graphics and love.graphics.newImage, local fn = love[ns] and love[ns][name]
newImageData = love.image and love.image.newImageData, if fn then
newSource = love.audio and love.audio.newSource, originals[ns .. "." .. name] = fn
} love[ns][name] = wrapLoader(fn)
love.filesystem.read = wrapLoader(originals.read) end
end
originals.getInfo = love.filesystem.getInfo
love.filesystem.getInfo = function(path, ...) love.filesystem.getInfo = function(path, ...)
local alt = versioned(path) local alt = versioned(path)
if alt then return originals.getInfo(alt, ...) end if alt then return originals.getInfo(alt, ...) end
return originals.getInfo(path, ...) return originals.getInfo(path, ...)
end end
if originals.newImage then
love.graphics.newImage = wrapLoader(originals.newImage)
end
if originals.newImageData then
love.image.newImageData = wrapLoader(originals.newImageData)
end
if originals.newSource then
love.audio.newSource = wrapLoader(originals.newSource)
end
end end
-- Tests restore the stock loaders between cases; the game never uninstalls. -- Tests restore the stock loaders between cases; the game never uninstalls.
function NxAssetOverlay.uninstall() function NxAssetOverlay.uninstall()
if not originals then return end if not originals then return end
love.filesystem.read = originals.read for _, spec in ipairs(WRAP_SPEC) do
love.filesystem.getInfo = originals.getInfo local ns, name = spec[1], spec[2]
if originals.newImage then love.graphics.newImage = originals.newImage end local key = ns .. "." .. name
if originals.newImageData then if originals[key] then love[ns][name] = originals[key] end
love.image.newImageData = originals.newImageData
end end
if originals.newSource then love.audio.newSource = originals.newSource end love.filesystem.getInfo = originals.getInfo
originals = nil originals = nil
end end
@@ -39,8 +39,21 @@ eq(Assets.resolve(PNG), PNG,
"resolve is the identity without a mod loader (overlay owns NX fallback)") "resolve is the identity without a mod loader (overlay owns NX fallback)")
-- --- Overlay installed: every loader falls back to the versioned path -- --- Overlay installed: every loader falls back to the versioned path
-- Write-side functions must NEVER be wrapped (the importer targets the
-- versioned tree explicitly); capture references to prove identity.
local rawWrite = love.filesystem.write
local rawRemove = love.filesystem.remove
local rawGetInfo = love.filesystem.getInfo
seed_chunk = "assets/generated/boot_chunk.lua"
love.filesystem.write("yellow/" .. seed_chunk, "return 42")
Overlay.install() Overlay.install()
check(Overlay.isInstalled(), "overlay installs") check(Overlay.isInstalled(), "overlay installs")
check(love.filesystem.write == rawWrite,
"install leaves filesystem.write stock (writes never wrapped)")
check(love.filesystem.remove == rawRemove,
"install leaves filesystem.remove stock")
check(love.filesystem.getInfo ~= rawGetInfo, "install wraps getInfo")
local img = love.graphics.newImage(PNG) local img = love.graphics.newImage(PNG)
eq(img.path, "yellow/" .. PNG, "wrapped newImage receives the yellow/ path") eq(img.path, "yellow/" .. PNG, "wrapped newImage receives the yellow/ path")
@@ -54,6 +67,19 @@ eq(love.filesystem.read(PNG), "yellow-png-bytes",
check(love.filesystem.getInfo(PNG) ~= nil, check(love.filesystem.getInfo(PNG) ~= nil,
"wrapped getInfo sees the versioned file at the un-prefixed path") "wrapped getInfo sees the versioned file at the un-prefixed path")
-- The whole read surface, not just image/audio loaders: a future state
-- using any of these APIs with a generated path stays inside the fallback.
local chunk = love.filesystem.load(seed_chunk)
eq(type(chunk) == "function" and chunk() or nil, 42,
"wrapped filesystem.load resolves the versioned chunk")
local sd = love.sound.newSoundData(PNG)
eq(sd.samples, "yellow/" .. PNG,
"wrapped newSoundData receives the yellow/ path (widenMono's re-read)")
local fnt = love.graphics.newFont(14)
check(fnt ~= nil, "wrapped newFont ignores non-path arguments")
-- Assets.image/imageData benefit transparently (no call-site changes) -- Assets.image/imageData benefit transparently (no call-site changes)
Assets.flush() Assets.flush()
local aimg = Assets.image(PNG) local aimg = Assets.image(PNG)
@@ -134,6 +160,7 @@ eq(slimDesktop.programPrefix, nil,
clearPath("yellow/" .. PNG) clearPath("yellow/" .. PNG)
clearPath("yellow/" .. PROG) clearPath("yellow/" .. PROG)
clearPath("yellow/" .. seed_chunk)
love.system = savedSystem love.system = savedSystem
Platform._resetForTests() Platform._resetForTests()
+6
View File
@@ -13,7 +13,13 @@ local FORBIDDEN = {
'love%.graphics%.newImage%(%s*"assets/generated', 'love%.graphics%.newImage%(%s*"assets/generated',
'love%.image%.newImageData%(%s*"assets/generated', 'love%.image%.newImageData%(%s*"assets/generated',
'love%.audio%.newSource%(%s*"assets/generated', 'love%.audio%.newSource%(%s*"assets/generated',
'love%.sound%.newSoundData%(%s*"assets/generated',
'love%.graphics%.newFont%(%s*"assets/generated',
'love%.font%.newFontData%(%s*"assets/generated',
'love%.filesystem%.read%(%s*"assets/generated', 'love%.filesystem%.read%(%s*"assets/generated',
'love%.filesystem%.load%(%s*"assets/generated',
'love%.filesystem%.lines%(%s*"assets/generated',
'love%.filesystem%.newFileData%(%s*"assets/generated',
'love%.filesystem%.getInfo%(%s*"assets/generated', 'love%.filesystem%.getInfo%(%s*"assets/generated',
} }