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 <cursoragent@cursor.com>
This commit is contained in:
Andrew Quenehen
2026-08-03 15:42:55 -03:00
parent 17243a7d8b
commit 67e6b1fb04
15 changed files with 262 additions and 175 deletions
+2
View File
@@ -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).
+7
View File
@@ -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).
+2 -3
View File
@@ -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,
+4 -7
View File
@@ -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
+95
View File
@@ -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
-7
View File
@@ -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
+6 -12
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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")
+1 -1
View File
@@ -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
+4 -5
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+74 -136
View File
@@ -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/<path>
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()
+63
View File
@@ -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()