From 12ff4dba2eff6f4805d3b7c2ac9d5499fde8baaf Mon Sep 17 00:00:00 2001 From: Andrew Quenehen Date: Mon, 3 Aug 2026 16:14:33 -0300 Subject: [PATCH] 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 --- docs/switch-development.md | 2 +- src/core/NxAssetOverlay.lua | 68 +++++++++++-------- tests/engine/assets_version_fallback_test.lua | 27 ++++++++ tests/engine/nx_generated_guard_test.lua | 6 ++ 4 files changed, 74 insertions(+), 29 deletions(-) diff --git a/docs/switch-development.md b/docs/switch-development.md index 47a3d3ab..8e00e072 100644 --- a/docs/switch-development.md +++ b/docs/switch-development.md @@ -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. -**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. diff --git a/src/core/NxAssetOverlay.lua b/src/core/NxAssetOverlay.lua index 0b035d08..f14a593e 100644 --- a/src/core/NxAssetOverlay.lua +++ b/src/core/NxAssetOverlay.lua @@ -1,14 +1,18 @@ -- NX-only asset overlay: fused love-nx cannot reliably mount -- blue|yellow/assets/generated onto the un-prefixed assets/generated, so -- instead of teaching every call site about versioned caches, this module --- wraps the love loading entry points ONCE at boot: any string path under --- assets/generated/ that does not resolve falls back to the active --- version's prefixed copy (yellow|blue/assets/generated/...). +-- wraps EVERY read-side love entry point that accepts a filesystem path +-- once at boot: any string path under assets/generated/ that does not +-- 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 -- install it, so their mountVersion overlay stays the single mechanism and --- their loaders keep stock behavior. Writes are deliberately NOT wrapped: --- the importer must keep targeting the versioned tree explicitly. +-- their loaders keep stock behavior. Write-side functions (write, remove, +-- createDirectory, mount, ...) are deliberately NOT wrapped: the importer +-- must keep targeting the versioned tree explicitly. -- -- Two intentional exceptions stay outside this module: -- * the chip-audio worker (src/core/chip_worker.lua) is a separate Lua @@ -48,6 +52,22 @@ local function wrapLoader(fn) 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() return originals ~= nil end @@ -55,40 +75,32 @@ end function NxAssetOverlay.install() if originals then return end if not (love and love.filesystem) then return end - originals = { - read = love.filesystem.read, - getInfo = love.filesystem.getInfo, - newImage = love.graphics and love.graphics.newImage, - newImageData = love.image and love.image.newImageData, - newSource = love.audio and love.audio.newSource, - } - love.filesystem.read = wrapLoader(originals.read) + originals = {} + for _, spec in ipairs(WRAP_SPEC) do + local ns, name = spec[1], spec[2] + local fn = love[ns] and love[ns][name] + if fn then + originals[ns .. "." .. name] = fn + love[ns][name] = wrapLoader(fn) + end + end + originals.getInfo = love.filesystem.getInfo love.filesystem.getInfo = function(path, ...) local alt = versioned(path) if alt then return originals.getInfo(alt, ...) end return originals.getInfo(path, ...) 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 -- Tests restore the stock loaders between cases; the game never uninstalls. function NxAssetOverlay.uninstall() if not originals then return end - love.filesystem.read = originals.read - love.filesystem.getInfo = originals.getInfo - if originals.newImage then love.graphics.newImage = originals.newImage end - if originals.newImageData then - love.image.newImageData = originals.newImageData + for _, spec in ipairs(WRAP_SPEC) do + local ns, name = spec[1], spec[2] + local key = ns .. "." .. name + if originals[key] then love[ns][name] = originals[key] end end - if originals.newSource then love.audio.newSource = originals.newSource end + love.filesystem.getInfo = originals.getInfo originals = nil end diff --git a/tests/engine/assets_version_fallback_test.lua b/tests/engine/assets_version_fallback_test.lua index bb925539..333d5af9 100644 --- a/tests/engine/assets_version_fallback_test.lua +++ b/tests/engine/assets_version_fallback_test.lua @@ -39,8 +39,21 @@ eq(Assets.resolve(PNG), PNG, "resolve is the identity without a mod loader (overlay owns NX fallback)") -- --- 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() 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) 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, "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.flush() local aimg = Assets.image(PNG) @@ -134,6 +160,7 @@ eq(slimDesktop.programPrefix, nil, clearPath("yellow/" .. PNG) clearPath("yellow/" .. PROG) +clearPath("yellow/" .. seed_chunk) love.system = savedSystem Platform._resetForTests() diff --git a/tests/engine/nx_generated_guard_test.lua b/tests/engine/nx_generated_guard_test.lua index 0e025c2b..2780e395 100644 --- a/tests/engine/nx_generated_guard_test.lua +++ b/tests/engine/nx_generated_guard_test.lua @@ -13,7 +13,13 @@ local FORBIDDEN = { 'love%.graphics%.newImage%(%s*"assets/generated', 'love%.image%.newImageData%(%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%.load%(%s*"assets/generated', + 'love%.filesystem%.lines%(%s*"assets/generated', + 'love%.filesystem%.newFileData%(%s*"assets/generated', 'love%.filesystem%.getInfo%(%s*"assets/generated', }