From 67e6b1fb0436dc3158c1cd54e642d46683820846 Mon Sep 17 00:00:00 2001 From: Andrew Quenehen Date: Mon, 3 Aug 2026 15:42:55 -0300 Subject: [PATCH] fix(switch): centralize the NX asset fallback in a boot-time loader overlay The scattered per-call-site prefix rewrites were a parallel track that any future newImage("assets/generated/...") would silently bypass. Replace them with NxAssetOverlay: installed once from love.load on NX only, it wraps newImage / newImageData / newSource / filesystem.read / getInfo so a missing assets/generated path falls back to the active version's blue|yellow copy. Call sites return to plain love loader calls, and Assets.resolve goes back to being the platform-free mod-override point. Two deliberate exceptions remain: the chip-audio worker (separate Lua state) keeps receiving the prefix explicitly via audio.programPrefix, and data/generated module loads keep using CacheFs.readActive. A new guard test (tests/engine/nx_generated_guard_test.lua) fails CI on any direct love loader call with a literal assets/generated path, so the class of bug cannot regress by accident. scripts/test.sh --quick is green across all tiers. Co-authored-by: Cursor --- docs/switch-development.md | 2 + main.lua | 7 + src/battle/BattleState.lua | 5 +- src/core/ChipSynth.lua | 11 +- src/core/NxAssetOverlay.lua | 95 ++++++++ src/core/Sound.lua | 7 - src/render/Assets.lua | 18 +- src/ui/IntroMovie.lua | 2 +- src/ui/SurfingMinigame.lua | 2 +- src/ui/TitleState.lua | 2 +- src/ui/TownMap.lua | 9 +- src/ui/TradeAnim.lua | 2 +- src/ui/YellowIntro.lua | 2 +- tests/engine/assets_version_fallback_test.lua | 210 ++++++------------ tests/engine/nx_generated_guard_test.lua | 63 ++++++ 15 files changed, 262 insertions(+), 175 deletions(-) create mode 100644 src/core/NxAssetOverlay.lua create mode 100644 tests/engine/nx_generated_guard_test.lua diff --git a/docs/switch-development.md b/docs/switch-development.md index b288d43b..47a3d3ab 100644 --- a/docs/switch-development.md +++ b/docs/switch-development.md @@ -434,6 +434,8 @@ 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`. + **Hardware re-test:** T16 **pass** @ `2699c9a` (naming A=confirm / B=cancel). T19 **pass** (quit/reopen, suspend×10, reboot) — operator 2026-08-01. **Suspend/resume audio:** after resume, chip music is stopped to avoid duplicate streams; confirm on hardware during P0-09/10 (T19). diff --git a/main.lua b/main.lua index 77290f26..969d7ca4 100644 --- a/main.lua +++ b/main.lua @@ -220,6 +220,13 @@ function love.load(args) -- of each flashing their own cmd.exe window (#606). No-op elsewhere. require("src.core.HostShell").hideHostConsole() + -- NX fused mounts are unreliable for the blue|yellow cache overlay: wrap + -- the love loaders once so every generated-asset read falls back to the + -- versioned save-dir copy. Never installed on desktop/Android/iOS. + if require("src.core.Platform").isNX() then + require("src.core.NxAssetOverlay").install() + end + -- Self-updater boot shell: a fused build may mount and chainload a newer -- downloaded payload here. True means it took over, so we must stop. A -- dev / source checkout no-ops (see src/update/Boot.lua). diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index e2f01d28..fb9456c3 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -4511,7 +4511,7 @@ end local ballQuads function BattleState:drawBallRow(party, x, y, dx) if ballQuads == nil then - local ok, img = pcall(love.graphics.newImage, require("src.render.Assets").resolve("assets/generated/battle/balls.png")) + local ok, img = pcall(love.graphics.newImage, "assets/generated/battle/balls.png") if ok then ballQuads = { img = img } for i = 0, 3 do @@ -4587,8 +4587,7 @@ local substDoll function BattleState:drawSubstituteDoll(battler) if substDoll == nil then local ok, img = pcall(love.graphics.newImage, - require("src.render.Assets").resolve( - "assets/generated/sprites/monster.png")) + "assets/generated/sprites/monster.png") if ok then local w, h = img:getDimensions() substDoll = { img = img, diff --git a/src/core/ChipSynth.lua b/src/core/ChipSynth.lua index 0eceaabb..dfbc5cc1 100644 --- a/src/core/ChipSynth.lua +++ b/src/core/ChipSynth.lua @@ -124,14 +124,11 @@ local function loadBanks(data) return cachedBanks end local raw, readError - -- NX-only: Blue/Yellow live under a versioned save-dir prefix. The main - -- thread resolves it before sending audio to the worker; the sync path - -- resolves it here so desktop keeps the mountVersion overlay behavior. + -- The chip worker runs in a separate Lua state without the NX overlay; + -- ChipAudio hands it the versioned cache prefix explicitly. On the main + -- thread the NX overlay (or desktop mountVersion) makes the plain read + -- resolve, so no platform branching belongs here. local prefix = audio.programPrefix - if not prefix and require("src.core.Platform").isNX() then - local gv = require("src.core.GameVersion").cachePrefix() - if gv ~= "" then prefix = gv end - end if prefix and prefix ~= "" then raw, readError = love.filesystem.read(prefix .. audio.programFile) end diff --git a/src/core/NxAssetOverlay.lua b/src/core/NxAssetOverlay.lua new file mode 100644 index 00000000..0b035d08 --- /dev/null +++ b/src/core/NxAssetOverlay.lua @@ -0,0 +1,95 @@ +-- 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/...). +-- +-- 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. +-- +-- Two intentional exceptions stay outside this module: +-- * the chip-audio worker (src/core/chip_worker.lua) is a separate Lua +-- state without these wrappers; ChipAudio.slimAudio hands it the prefix +-- explicitly as audio.programPrefix. +-- * data/generated module loads go through CacheFs.readActive, which +-- already implements the same fallback for require bytes. + +local GameVersion = require("src.core.GameVersion") + +local GENERATED = "assets/generated/" + +local NxAssetOverlay = {} + +local originals -- raw love functions, non-nil while installed + +-- Resolve `path` to the versioned copy when the un-prefixed file is missing +-- and the active version (Blue/Yellow) carries it. Returns nil when the +-- caller's path should be used untouched (non-generated path, Red, the real +-- file exists, or no versioned copy). +local function versioned(path) + if type(path) ~= "string" then return nil end + if path:sub(1, #GENERATED) ~= GENERATED then return nil end + local prefix = GameVersion.cachePrefix() + if prefix == "" then return nil end + if originals.getInfo(path) then return nil end + local candidate = prefix .. path + if originals.getInfo(candidate) then return candidate end + return nil +end + +local function wrapLoader(fn) + return function(path, ...) + local alt = versioned(path) + if alt then return fn(alt, ...) end + return fn(path, ...) + end +end + +function NxAssetOverlay.isInstalled() + return originals ~= nil +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) + 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 + end + if originals.newSource then love.audio.newSource = originals.newSource end + originals = nil +end + +return NxAssetOverlay diff --git a/src/core/Sound.lua b/src/core/Sound.lua index 46cdc138..2c5deb14 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -269,13 +269,6 @@ function Sound.playPikaCry(data, n) if src == false then return nil end if not src then local path = ("assets/generated/audio/pika_cries/cry_%02d.wav"):format(n) - -- NX-only: Blue/Yellow live under a versioned save-dir prefix. - if require("src.core.Platform").isNX() then - local prefix = require("src.core.GameVersion").cachePrefix() - if prefix ~= "" and love.filesystem.getInfo(prefix .. path) then - path = prefix .. path - end - end local ok, s = pcall(love.audio.newSource, path, "static") if not ok or not s then cache[key] = false diff --git a/src/render/Assets.lua b/src/render/Assets.lua index 804cd481..afcc577b 100644 --- a/src/render/Assets.lua +++ b/src/render/Assets.lua @@ -4,10 +4,9 @@ -- its own file without editing a single record, and one flush() drops -- every downstream cache for dev-mode hot reload. -- --- No loader installed means resolve() is the identity on desktop/mobile. --- On NX only, Blue/Yellow also rewrite assets/generated/* to the real --- save-dir path (yellow|blue/assets/generated/...) because fused love-nx --- often cannot mount that tree onto the unprefixed PhysFS path. +-- No loader installed means resolve() is the identity. The NX Blue/Yellow +-- versioned-cache fallback lives in src/core/NxAssetOverlay.lua (installed +-- once at boot on NX only), not here, so this module stays platform-free. local Assets = {} @@ -47,14 +46,9 @@ function Assets.resolve(path) if derived then return derived end end - -- Switch-only: desktop/Android keep mountVersion as the sole overlay. - if require("src.core.Platform").isNX() then - local prefix = require("src.core.GameVersion").cachePrefix() - if prefix ~= "" then - local versioned = prefix .. path - if exists(versioned) then return versioned end - end - end + -- NX Blue/Yellow: no rewrite here -- NxAssetOverlay (installed once at + -- boot on NX only) covers every loader globally, so this module stays + -- the mod-override choke point it always was. return path end diff --git a/src/ui/IntroMovie.lua b/src/ui/IntroMovie.lua index 3445a3c4..15f14be2 100644 --- a/src/ui/IntroMovie.lua +++ b/src/ui/IntroMovie.lua @@ -136,7 +136,7 @@ local FIGHT_SCRIPT = { local function tryImage(path) if not path then return nil end - local ok, img = pcall(love.graphics.newImage, require("src.render.Assets").resolve(path)) + local ok, img = pcall(love.graphics.newImage, path) return ok and img or nil end diff --git a/src/ui/SurfingMinigame.lua b/src/ui/SurfingMinigame.lua index c596c540..96659568 100644 --- a/src/ui/SurfingMinigame.lua +++ b/src/ui/SurfingMinigame.lua @@ -92,7 +92,7 @@ function SurfingMinigame.new(game, onDone) self.banner = nil -- {quad, frames}: GOOD!/YEAH-/Oh no.. local function sheet(path) - local ok, img = pcall(love.graphics.newImage, require("src.render.Assets").resolve(path)) + local ok, img = pcall(love.graphics.newImage, path) return ok and img or nil end self.bg = sheet("assets/generated/minigame/surf_1a.png") diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index 2a71845e..9f293f42 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -100,7 +100,7 @@ local CYCLE_FRAMES = 240 -- the original waits ~4s between picks local function tryImage(path) if not path then return nil end - local ok, img = pcall(love.graphics.newImage, require("src.render.Assets").resolve(path)) + local ok, img = pcall(love.graphics.newImage, path) return ok and img or nil end diff --git a/src/ui/TownMap.lua b/src/ui/TownMap.lua index 324dfabf..59048c56 100644 --- a/src/ui/TownMap.lua +++ b/src/ui/TownMap.lua @@ -104,7 +104,7 @@ local function loadBackground(game) local tm = (game.data.field or {}).townMap or {} local bg = tm.background if not (bg and bg.map and bg.tiles) then return nil end - local ok, img = pcall(love.graphics.newImage, require("src.render.Assets").resolve(bg.tiles.path)) + local ok, img = pcall(love.graphics.newImage, bg.tiles.path) if not ok then return nil end local quads = {} local iw, ih = img:getDimensions() @@ -115,7 +115,7 @@ local function loadBackground(game) end local cursor if bg.cursor then - local okc, c = pcall(love.graphics.newImage, require("src.render.Assets").resolve(bg.cursor.path)) + local okc, c = pcall(love.graphics.newImage, bg.cursor.path) cursor = okc and c or nil end return { img = img, quads = quads, map = bg.map, cursor = cursor } @@ -192,9 +192,8 @@ function TownMap.new(game, opts) -- field.townMap.nest lifts the icon path out of the engine local nest = ((game.data.field or {}).townMap or {}).nest local ok, img = pcall(love.graphics.newImage, - require("src.render.Assets").resolve( - (nest and nest.path) - or "assets/generated/townmap/nest.png")) + (nest and nest.path) + or "assets/generated/townmap/nest.png") self.nestIcon = ok and img or nil end if opts.fly then diff --git a/src/ui/TradeAnim.lua b/src/ui/TradeAnim.lua index db96f049..423c2d0f 100644 --- a/src/ui/TradeAnim.lua +++ b/src/ui/TradeAnim.lua @@ -29,7 +29,7 @@ local DEFAULT_ART = { local function tryImage(path) if not path then return nil end - local ok, img = pcall(love.graphics.newImage, require("src.render.Assets").resolve(path)) + local ok, img = pcall(love.graphics.newImage, path) return ok and img or nil end diff --git a/src/ui/YellowIntro.lua b/src/ui/YellowIntro.lua index 30e5cd80..0cfc1446 100644 --- a/src/ui/YellowIntro.lua +++ b/src/ui/YellowIntro.lua @@ -220,7 +220,7 @@ local function bobOffset(phase) end local function tryImage(path) - local ok, img = pcall(love.graphics.newImage, require("src.render.Assets").resolve(path)) + local ok, img = pcall(love.graphics.newImage, path) return ok and img or nil end diff --git a/tests/engine/assets_version_fallback_test.lua b/tests/engine/assets_version_fallback_test.lua index acab7914..bb925539 100644 --- a/tests/engine/assets_version_fallback_test.lua +++ b/tests/engine/assets_version_fallback_test.lua @@ -1,5 +1,8 @@ --- NX fused often cannot mount blue|yellow onto assets/generated. Assets.resolve --- rewrites to the real save-dir path on NX only; desktop/Android stay unchanged. +-- NxAssetOverlay: fused love-nx often cannot mount blue|yellow onto +-- assets/generated, so on NX the love loaders are wrapped once at boot and +-- fall back to the versioned save-dir path. Desktop/Android never install +-- the overlay; the chip worker gets the prefix explicitly via the audio +-- payload. Self-contained: luajit tests/engine/assets_version_fallback_test.lua package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end @@ -9,11 +12,10 @@ local eq = T.eq local GameVersion = require("src.core.GameVersion") local Platform = require("src.core.Platform") -local CacheFs = require("src.import.CacheFs") local Assets = require("src.render.Assets") +local Overlay = require("src.core.NxAssetOverlay") local PNG = "assets/generated/tilesets/reds_house.png" -local savedPrefix = CacheFs.prefix local savedVersion = GameVersion.get() local savedSystem = love.system @@ -28,92 +30,77 @@ local function setOS(osName) Platform._resetForTests() end --- --- Desktop: no rewrite even when yellow/ exists -setOS("OS X") +-- --- Assets.resolve stays platform-free: no rewrite even for NX Yellow +setOS("NX") GameVersion.set("yellow") -CacheFs.prefix = "" love.filesystem.write("yellow/" .. PNG, "yellow-png-bytes") clearPath(PNG) eq(Assets.resolve(PNG), PNG, - "desktop resolve leaves generated paths unprefixed (mount owns overlay)") + "resolve is the identity without a mod loader (overlay owns NX fallback)") --- --- NX: rewrite to yellow/ -setOS("NX") +-- --- Overlay installed: every loader falls back to the versioned path +Overlay.install() +check(Overlay.isInstalled(), "overlay installs") + +local img = love.graphics.newImage(PNG) +eq(img.path, "yellow/" .. PNG, "wrapped newImage receives the yellow/ path") + +local id = love.image.newImageData(PNG) +eq(id.path, "yellow/" .. PNG, "wrapped newImageData receives the yellow/ path") + +eq(love.filesystem.read(PNG), "yellow-png-bytes", + "wrapped filesystem.read returns the versioned bytes") + +check(love.filesystem.getInfo(PNG) ~= nil, + "wrapped getInfo sees the versioned file at the un-prefixed path") + +-- Assets.image/imageData benefit transparently (no call-site changes) Assets.flush() -eq(Assets.resolve(PNG), "yellow/" .. PNG, - "NX resolve maps generated path to yellow/ when unprefixed is missing") +local aimg = Assets.image(PNG) +eq(aimg.path, "yellow/" .. PNG, "Assets.image loads via the overlay") -local img = Assets.image(PNG) -check(img ~= nil, "NX Assets.image opens the yellow/ save-dir path") -eq(img.path, "yellow/" .. PNG, "NX newImage receives the versioned path") +-- Non-string arguments pass through untouched +local fromData = love.graphics.newImage(id) +check(fromData ~= nil, "newImage(ImageData) is not rewritten") -local id = Assets.imageData(PNG) -check(id ~= nil, "NX Assets.imageData opens the yellow/ save-dir path") -eq(id.path, "yellow/" .. PNG, "NX newImageData receives the versioned path") +-- Non-generated paths pass through untouched +local launcher = love.graphics.newImage("assets/launcher/gear.png") +eq(launcher.path, "assets/launcher/gear.png", + "overlay leaves non-generated paths alone") --- --- NX Blue -GameVersion.set("blue") -Assets.flush() -love.filesystem.write("blue/" .. PNG, "blue-png-bytes") +-- The real un-prefixed file wins when it exists +love.filesystem.write(PNG, "root-png-bytes") +eq(love.filesystem.read(PNG), "root-png-bytes", + "overlay prefers the real un-prefixed file over the versioned copy") clearPath(PNG) + +-- Blue gets the same treatment +GameVersion.set("blue") +love.filesystem.write("blue/" .. PNG, "blue-png-bytes") clearPath("yellow/" .. PNG) -eq(Assets.resolve(PNG), "blue/" .. PNG, "NX resolve maps generated path to blue/") -check(Assets.image(PNG) ~= nil, "NX Assets.image opens the blue/ save-dir path") +eq(love.filesystem.read(PNG), "blue-png-bytes", + "overlay maps generated reads to blue/ for Blue") --- --- NX Red stays unprefixed +-- Red has no prefix: nothing is rewritten GameVersion.set("red") -Assets.flush() -love.filesystem.write(PNG, "red-png-bytes") clearPath("blue/" .. PNG) -eq(Assets.resolve(PNG), PNG, "NX Red resolve keeps the unprefixed path") -check(Assets.image(PNG) ~= nil, "NX Assets.image loads Red from the save-dir root") +eq(love.filesystem.read(PNG), nil, "Red keeps the stock miss behavior") --- --- NX prefers versioned file over empty unprefixed stub +-- Uninstall restores the stock loaders byte for byte GameVersion.set("yellow") -Assets.flush() -love.filesystem.write(PNG, "") -love.filesystem.write("yellow/" .. PNG, "yellow-real-png") -eq(Assets.resolve(PNG), "yellow/" .. PNG, - "NX resolve prefers yellow/ even when an empty unprefixed stub exists") +love.filesystem.write("yellow/" .. PNG, "yellow-png-bytes") +Overlay.uninstall() +check(not Overlay.isInstalled(), "overlay uninstalls") +eq(love.filesystem.read(PNG), nil, + "after uninstall the stock loader no longer sees the versioned path") --- --- Android: same as desktop (no rewrite) -setOS("Android") -Assets.flush() -eq(Assets.resolve(PNG), PNG, - "Android resolve leaves generated paths unprefixed") - --- --- Non-generated paths untouched -eq(Assets.resolve("assets/launcher/gear.png"), "assets/launcher/gear.png", - "resolve leaves non-generated paths alone") - --- --- readActive still works for Data:load (all platforms) -setOS("NX") -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") - --- ChipSynth.loadBanks: NX prefers the versioned prefix; desktop untouched -setOS("NX") -GameVersion.set("yellow") -local PROG = "assets/generated/audio/programs.bin" -local PROG_BYTES = string.rep("\0", 0x4000 * 2) -love.filesystem.write("yellow/" .. PROG, PROG_BYTES) -clearPath(PROG) +-- --- ChipSynth honors audio.programPrefix (the worker exception) local ChipSynth = require("src.core.ChipSynth") ChipSynth.invalidateBanks() -local progData = { audio = { programFile = PROG, bankOrder = { 1, 2 } } } -local okB, banks = pcall(ChipSynth._loadBanksForTest, progData) -check(okB and banks ~= nil, "NX loadBanks reads yellow/programs.bin") -if okB and banks then - eq(banks[1], PROG_BYTES:sub(1, 0x4000), "loadBanks returns the bank 1 bytes") -end - --- ChipSynth honors an explicit programPrefix (worker path; worker has no --- GameVersion state, so the prefix must arrive via the audio payload) -ChipSynth.invalidateBanks() +local PROG = "assets/generated/audio/programs.bin" +local PROG_BYTES = string.rep("\0", 0x4000 * 2) +clearPath(PROG) +love.filesystem.write("yellow/" .. PROG, PROG_BYTES) local workerData = { audio = { programFile = PROG, programPrefix = "yellow/", @@ -123,83 +110,34 @@ local okW, wbanks = pcall(ChipSynth._loadBanksForTest, workerData) check(okW and wbanks ~= nil, "loadBanks uses audio.programPrefix when set") if okW and wbanks then eq(wbanks[1], PROG_BYTES:sub(1, 0x4000), - "programPrefix loads the same bank 1 bytes") + "programPrefix loads the bank 1 bytes from the versioned file") end --- Blue gets the same treatment +-- Without programPrefix the sync path relies on the overlay/mount: with the +-- overlay uninstalled (this test process), the plain read misses. ChipSynth.invalidateBanks() -GameVersion.set("blue") -love.filesystem.write("blue/" .. PROG, PROG_BYTES) -clearPath("yellow/" .. PROG) -local okBl, bbanks = pcall(ChipSynth._loadBanksForTest, progData) -check(okBl and bbanks ~= nil, "NX loadBanks reads blue/programs.bin") -clearPath("blue/" .. PROG) -GameVersion.set("yellow") -love.filesystem.write("yellow/" .. PROG, PROG_BYTES) +local plainData = { audio = { programFile = PROG, bankOrder = { 1, 2 } } } +local okP = pcall(ChipSynth._loadBanksForTest, plainData) +check(not okP, "without programPrefix or overlay, programs.bin is a clean miss") --- ChipAudio.slimAudio hands the NX prefix to the worker -local ChipAudio = require("src.core.ChipAudio") -local slim = ChipAudio._slimAudioForTest - and ChipAudio._slimAudioForTest(progData) - or nil -if slim then - eq(slim.programPrefix, "yellow/", - "slimAudio passes the NX cache prefix to the worker") -end - --- Sound.playPikaCry: NX rewrites the pika-cry path before newSource +-- --- ChipAudio.slimAudio hands the NX prefix to the worker payload setOS("NX") GameVersion.set("yellow") -local Sound = require("src.core.Sound") -local CRY = "assets/generated/audio/pika_cries/cry_01.wav" -love.filesystem.write("yellow/" .. CRY, "RIFF\x24\x00\x00\x00WAVEfmt ") -clearPath(CRY) -local lastNewSource -local savedAudio = love.audio -love.audio = { - newSource = function(path, mode) - lastNewSource = path - return setmetatable({ - stop = function() end, - play = function() end, - setVolume = function() end, - }, { __index = function() return function() end end }) - end, -} -Sound.invalidate("pikacry:1") -local cryData = { audio = { pikaCries = 1 } } -local src = Sound.playPikaCry(cryData, 1) -love.audio = savedAudio -eq(lastNewSource, "yellow/" .. CRY, "NX playPikaCry loads yellow/pika_cries") - --- TitleState/YellowIntro/IntroMovie use Assets.resolve (NX prefix); a static --- source check keeps them from regressing to raw newImage(path). -local function srcHasResolve(path) - local f = io.open(path, "r") - if not f then return false end - local body = f:read("*a") - f:close() - return body:find("Assets%.resolve", 1, false) ~= nil - or body:find('require%("src%.render%.Assets"%)%.resolve', 1, false) ~= nil -end -check(srcHasResolve("src/ui/TitleState.lua"), - "TitleState loads art via Assets.resolve") -check(srcHasResolve("src/ui/YellowIntro.lua"), - "YellowIntro loads art via Assets.resolve") -check(srcHasResolve("src/ui/IntroMovie.lua"), - "IntroMovie loads art via Assets.resolve") +local ChipAudio = require("src.core.ChipAudio") +local slim = ChipAudio._slimAudioForTest(plainData) +eq(slim.programPrefix, "yellow/", + "slimAudio passes the NX cache prefix to the worker") +setOS("OS X") +local slimDesktop = ChipAudio._slimAudioForTest(plainData) +eq(slimDesktop.programPrefix, nil, + "desktop worker payloads carry no prefix (mount owns the overlay)") +clearPath("yellow/" .. PNG) clearPath("yellow/" .. PROG) -clearPath("yellow/" .. CRY) love.system = savedSystem Platform._resetForTests() -CacheFs.prefix = savedPrefix GameVersion.set(savedVersion) Assets.flush() -clearPath(PNG) -clearPath("yellow/" .. PNG) -clearPath("blue/" .. PNG) -clearPath("yellow/data/generated/maps.lua") T.finish() diff --git a/tests/engine/nx_generated_guard_test.lua b/tests/engine/nx_generated_guard_test.lua new file mode 100644 index 00000000..0e025c2b --- /dev/null +++ b/tests/engine/nx_generated_guard_test.lua @@ -0,0 +1,63 @@ +-- Guard: core code must not call love loaders directly on literal +-- assets/generated paths. Centralized loading (Assets / the NX overlay) +-- is what keeps mod overrides and the Blue/Yellow NX fallback working; a +-- raw literal load silently bypasses both. This scans every src/*.lua and +-- fails on new violations so the class of bug cannot regress by accident. +-- Self-contained: luajit tests/engine/nx_generated_guard_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check + +local FORBIDDEN = { + 'love%.graphics%.newImage%(%s*"assets/generated', + 'love%.image%.newImageData%(%s*"assets/generated', + 'love%.audio%.newSource%(%s*"assets/generated', + 'love%.filesystem%.read%(%s*"assets/generated', + 'love%.filesystem%.getInfo%(%s*"assets/generated', +} + +-- Files that legitimately reference generated literals but never load them +-- directly (writers, mount setup, the NX probe, mod source roots) are not +-- matched by the patterns above, so no allowlist is needed. + +local function listLuaFiles(dir, out) + out = out or {} + local p = io.popen('find "' .. dir .. '" -name "*.lua" -type f') + if not p then return out end + for line in p:lines() do + out[#out + 1] = line + end + p:close() + return out +end + +local violations = {} +for _, file in ipairs(listLuaFiles("src")) do + local f = io.open(file, "r") + if f then + local body = f:read("*a") + f:close() + for _, pat in ipairs(FORBIDDEN) do + if body:find(pat) then + violations[#violations + 1] = file .. " matches " .. pat + end + end + end +end + +check(#violations == 0, + "no direct love loader call on literal assets/generated paths" + .. (#violations > 0 and (":\n " .. table.concat(violations, "\n ")) or "")) + +-- The NX overlay module itself must exist and stay NX-gated at install time. +local f = io.open("main.lua", "r") +local mainSrc = f and f:read("*a") or "" +if f then f:close() end +check(mainSrc:find("NxAssetOverlay", 1, true) ~= nil, + "main.lua installs NxAssetOverlay") +check(mainSrc:find("isNX", 1, true) ~= nil + and mainSrc:find('require("src.core.NxAssetOverlay").install()', 1, true) ~= nil, + "the overlay install stays gated on Platform.isNX()") + +T.finish()