mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
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:
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user