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