mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
366 lines
15 KiB
Lua
366 lines
15 KiB
Lua
-- NX Yellow boot, headless: the runtime complement to
|
|
-- tests/engine/nx_generated_guard_test.lua. The guard only sees literal
|
|
-- loader calls; this suite drives the REAL boot states (TitleState,
|
|
-- YellowIntro/IntroMovie, Sound.playPikaCry) against a broken-mount NX
|
|
-- filesystem where generated art exists ONLY under the versioned save-dir
|
|
-- prefix (yellow|blue/), and records every path that reaches the raw love
|
|
-- loaders AFTER NxAssetOverlay's rewrite. Any data-driven or formatted
|
|
-- assets/generated path the overlay misses shows up here as a bare path
|
|
-- the recorder saw. Self-contained:
|
|
-- luajit tests/engine/nx_yellow_boot_test.lua
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
if not _G.love then _G.love = require("tests.love_stub") end
|
|
|
|
local T = require("tests.harness")
|
|
local check = T.check
|
|
local eq = T.eq
|
|
|
|
local GameVersion = require("src.core.GameVersion")
|
|
local Platform = require("src.core.Platform")
|
|
local Overlay = require("src.core.NxAssetOverlay")
|
|
local TitleState = require("src.ui.TitleState")
|
|
local YellowIntro = require("src.ui.YellowIntro")
|
|
local Sound = require("src.core.Sound")
|
|
|
|
local savedVersion = GameVersion.get()
|
|
|
|
local GENERATED = "assets/generated/"
|
|
|
|
-- --- fixtures ----------------------------------------------------------
|
|
-- Seeded ONLY under the versioned prefix, mirroring the fused love-nx bug
|
|
-- where yellow|blue/assets/generated is never mounted over assets/generated.
|
|
local seeded = {}
|
|
local function seed(path, bytes)
|
|
love.filesystem.write(path, bytes or "fake-asset-bytes")
|
|
seeded[#seeded + 1] = path
|
|
end
|
|
|
|
local Y_TITLE = {
|
|
"pikachu.png", "pika_bubble.png", "eyes_half.png", "eyes_closed.png",
|
|
"player.png", "copyright.png", "gamefreak_inc.png", "nine.png",
|
|
"yellow_version.png",
|
|
}
|
|
for _, name in ipairs(Y_TITLE) do
|
|
seed("yellow/assets/generated/title/" .. name)
|
|
end
|
|
local Y_INTRO = {
|
|
"yellow_intro_1.png", "yellow_intro_2.png", "clouds.png",
|
|
-- data-driven manifest entries (the paths a literal scan cannot see)
|
|
"gf_logo.png", "gf_text.png", "big_star.png",
|
|
"falling_star.png", "falling_star_blink.png", "studio_logo.png",
|
|
"gengar_1.png", "gengar_2.png", "gengar_3.png",
|
|
"nidorino_1.png", "nidorino_2.png", "nidorino_3.png",
|
|
}
|
|
for _, name in ipairs(Y_INTRO) do
|
|
seed("yellow/assets/generated/intro/" .. name)
|
|
end
|
|
seed("yellow/assets/generated/audio/pika_cries/cry_01.wav", "RIFF-fake-wav")
|
|
|
|
-- --- recorder, installed BEFORE Overlay.install so it sees the final
|
|
-- resolved path (the overlay wraps whatever is in place at install time)
|
|
local recorded = {}
|
|
local function record(kind, path)
|
|
if type(path) == "string" then
|
|
recorded[#recorded + 1] = { kind = kind, path = path }
|
|
end
|
|
end
|
|
|
|
local rawNewImage = love.graphics.newImage
|
|
love.graphics.newImage = function(path, ...)
|
|
record("image", path)
|
|
return rawNewImage(path, ...)
|
|
end
|
|
|
|
local rawRead = love.filesystem.read
|
|
love.filesystem.read = function(path, ...)
|
|
record("read", path)
|
|
return rawRead(path, ...)
|
|
end
|
|
|
|
-- widenMono re-reads the caller's bare path via newSoundData; without this
|
|
-- recorder the boot suite only saw newSource and could not catch a missing
|
|
-- sound.newSoundData wrap (the silent hole that motivated the full-surface
|
|
-- overlay). Installed before Overlay.install so the overlay wraps us.
|
|
local rawNewSoundData = love.sound.newSoundData
|
|
love.sound.newSoundData = function(samples, ...)
|
|
record("sounddata", samples)
|
|
return rawNewSoundData(samples, ...)
|
|
end
|
|
|
|
local Source = {}
|
|
Source.__index = Source
|
|
function Source:play() self.playing = true end
|
|
function Source:stop() self.playing = false end
|
|
function Source:setVolume() end
|
|
function Source:isPlaying() return self.playing end
|
|
function Source:getChannelCount() return self.channels or 1 end
|
|
|
|
love.audio = {
|
|
newSource = function(pathOrData, mode)
|
|
record("source", pathOrData)
|
|
local channels = 1
|
|
if type(pathOrData) == "table" and pathOrData.getChannelCount then
|
|
channels = pathOrData:getChannelCount()
|
|
end
|
|
return setmetatable({
|
|
path = pathOrData, mode = mode, channels = channels,
|
|
}, Source)
|
|
end,
|
|
}
|
|
|
|
Overlay.install()
|
|
check(Overlay.isInstalled(), "overlay installs over the recorders")
|
|
|
|
-- Every recorded path under assets/generated/ must already carry the
|
|
-- active version prefix: a bare generated path here is a dynamic-path
|
|
-- regression the static guard cannot catch.
|
|
local function assertNoBareGenerated(prefix, label)
|
|
local bare = {}
|
|
for _, r in ipairs(recorded) do
|
|
if r.path:sub(1, #GENERATED) == GENERATED then
|
|
bare[#bare + 1] = r.kind .. " " .. r.path
|
|
end
|
|
end
|
|
check(#bare == 0,
|
|
label .. ": every generated path reached the raw loader " .. prefix
|
|
.. "-prefixed"
|
|
.. (#bare > 0 and (" (bare: " .. table.concat(bare, ", ") .. ")") or ""))
|
|
end
|
|
|
|
local function countRecorded(prefix)
|
|
local n = 0
|
|
for _, r in ipairs(recorded) do
|
|
if r.path:sub(1, #prefix) == prefix then n = n + 1 end
|
|
end
|
|
return n
|
|
end
|
|
|
|
-- --- Yellow boot -------------------------------------------------------
|
|
GameVersion.set("yellow")
|
|
|
|
local yellowTitleManifest = {
|
|
layout = "yellow_pikachu",
|
|
pikachu = { path = "assets/generated/title/pikachu.png" },
|
|
pikaBubble = { path = "assets/generated/title/pika_bubble.png" },
|
|
version = { path = "assets/generated/title/yellow_version.png" },
|
|
}
|
|
local yellowIntroManifest = {
|
|
studio = { logo = "assets/generated/intro/studio_logo.png" },
|
|
gamefreakLogo = { path = "assets/generated/intro/gf_logo.png" },
|
|
gamefreakText = { path = "assets/generated/intro/gf_text.png" },
|
|
bigStar = { path = "assets/generated/intro/big_star.png" },
|
|
fallingStar = { path = "assets/generated/intro/falling_star.png" },
|
|
fallingStarBlink = { path = "assets/generated/intro/falling_star_blink.png" },
|
|
gengar = {
|
|
frame1 = { path = "assets/generated/intro/gengar_1.png" },
|
|
frame2 = { path = "assets/generated/intro/gengar_2.png" },
|
|
frame3 = { path = "assets/generated/intro/gengar_3.png" },
|
|
},
|
|
nidorino = {
|
|
frame1 = { path = "assets/generated/intro/nidorino_1.png" },
|
|
frame2 = { path = "assets/generated/intro/nidorino_2.png" },
|
|
frame3 = { path = "assets/generated/intro/nidorino_3.png" },
|
|
},
|
|
}
|
|
local game = { data = { field = {
|
|
title = yellowTitleManifest,
|
|
intro = yellowIntroManifest,
|
|
} } }
|
|
|
|
local titleState = TitleState.new(game, {})
|
|
check(titleState.yellowLayout, "the Yellow manifest selects the Pikachu layout")
|
|
check(titleState.yellowPikachu ~= nil, "title Pikachu art loaded")
|
|
eq(titleState.yellowPikachu and titleState.yellowPikachu.path,
|
|
"yellow/assets/generated/title/pikachu.png",
|
|
"title Pikachu resolves to the yellow/ copy")
|
|
check(titleState.yellowBubble ~= nil, "title speech bubble loaded")
|
|
eq(titleState.yellowBubble and titleState.yellowBubble.path,
|
|
"yellow/assets/generated/title/pika_bubble.png",
|
|
"title bubble resolves to the yellow/ copy")
|
|
eq(titleState.eyesHalf and titleState.eyesHalf.path,
|
|
"yellow/assets/generated/title/eyes_half.png",
|
|
"blink overlay (eyes_half) resolves to the yellow/ copy")
|
|
eq(titleState.eyesClosed and titleState.eyesClosed.path,
|
|
"yellow/assets/generated/title/eyes_closed.png",
|
|
"blink overlay (eyes_closed) resolves to the yellow/ copy")
|
|
eq(titleState.player and titleState.player.path,
|
|
"yellow/assets/generated/title/player.png",
|
|
"title player resolves to the yellow/ copy")
|
|
eq(titleState.version and titleState.version.path,
|
|
"yellow/assets/generated/title/yellow_version.png",
|
|
"version ribbon resolves to the yellow/ copy")
|
|
|
|
-- YellowIntro.new internally builds IntroMovie.new as its pre-roll, so one
|
|
-- constructor covers the copyright card, the GAME FREAK splash and the
|
|
-- Yellow attract atlases.
|
|
local yellowIntro = YellowIntro.new(game, function() end)
|
|
eq(yellowIntro.atlas1 and yellowIntro.atlas1.path,
|
|
"yellow/assets/generated/intro/yellow_intro_1.png",
|
|
"YellowIntro atlas1 resolves to the yellow/ copy")
|
|
eq(yellowIntro.atlas2 and yellowIntro.atlas2.path,
|
|
"yellow/assets/generated/intro/yellow_intro_2.png",
|
|
"YellowIntro atlas2 resolves to the yellow/ copy")
|
|
eq(yellowIntro.clouds and yellowIntro.clouds.path,
|
|
"yellow/assets/generated/intro/clouds.png",
|
|
"YellowIntro clouds resolve to the yellow/ copy")
|
|
local pre = yellowIntro.pre
|
|
check(pre ~= nil, "the IntroMovie pre-roll was constructed")
|
|
eq(pre and pre.copyright and pre.copyright.path,
|
|
"yellow/assets/generated/title/copyright.png",
|
|
"copyright card resolves to the yellow/ copy")
|
|
eq(pre and pre.nineImg and pre.nineImg.path,
|
|
"yellow/assets/generated/title/nine.png",
|
|
"copyright NineTile resolves to the yellow/ copy")
|
|
check(pre and pre.yellowCopy == true,
|
|
"IntroMovie selects the Yellow (c)1995-1999 tile sequence")
|
|
eq(pre and pre.studioLogo and pre.studioLogo.path,
|
|
"yellow/assets/generated/intro/studio_logo.png",
|
|
"data-driven studio logo resolves to the yellow/ copy")
|
|
eq(pre and pre.logo and pre.logo.path,
|
|
"yellow/assets/generated/intro/gf_logo.png",
|
|
"data-driven gamefreakLogo resolves to the yellow/ copy")
|
|
eq(pre and pre.gfText and pre.gfText.path,
|
|
"yellow/assets/generated/intro/gf_text.png",
|
|
"data-driven gamefreakText resolves to the yellow/ copy")
|
|
eq(pre and pre.bigStar and pre.bigStar.path,
|
|
"yellow/assets/generated/intro/big_star.png",
|
|
"data-driven bigStar resolves to the yellow/ copy")
|
|
eq(pre and pre.gengarFrames and pre.gengarFrames[2]
|
|
and pre.gengarFrames[2].path,
|
|
"yellow/assets/generated/intro/gengar_2.png",
|
|
"data-driven gengar frame resolves to the yellow/ copy")
|
|
eq(pre and pre.nidoFrames and pre.nidoFrames[3]
|
|
and pre.nidoFrames[3].path,
|
|
"yellow/assets/generated/intro/nidorino_3.png",
|
|
"data-driven nidorino frame resolves to the yellow/ copy")
|
|
|
|
-- Yellow's voiced Pikachu clip: a FORMATTED path (cry_%02d.wav), invisible
|
|
-- to the static guard. playPikaCry also runs widenMono, which re-reads the
|
|
-- same bare path via newSoundData and must emit a 16-bit STEREO Source
|
|
-- (#626) -- path rewrite alone is not enough on Switch audren.
|
|
local cry = Sound.playPikaCry({ audio = { pikaCries = 1 } }, 1)
|
|
check(cry ~= nil, "playPikaCry returns a source on NX Yellow")
|
|
eq(cry and cry:getChannelCount(), 2,
|
|
"playPikaCry widens the mono PCM clip to stereo")
|
|
local sawCrySoundData = false
|
|
local sawCrySource = false
|
|
for _, r in ipairs(recorded) do
|
|
if r.kind == "sounddata"
|
|
and r.path == "yellow/assets/generated/audio/pika_cries/cry_01.wav" then
|
|
sawCrySoundData = true
|
|
end
|
|
if r.kind == "source"
|
|
and r.path == "yellow/assets/generated/audio/pika_cries/cry_01.wav" then
|
|
sawCrySource = true
|
|
end
|
|
end
|
|
check(sawCrySource,
|
|
"newSource loaded the cry through the yellow/ prefix")
|
|
check(sawCrySoundData,
|
|
"widenMono re-read the cry via newSoundData with the yellow/ path")
|
|
|
|
check(countRecorded("yellow/assets/generated/") >= 20,
|
|
"the boot pulled its generated art through the yellow/ prefix")
|
|
assertNoBareGenerated("yellow/", "Yellow boot")
|
|
|
|
-- --- Blue boot: parity with Yellow --------------------------------------
|
|
-- Blue's real boot is IntroMovie (the Red/Blue attract movie) straight onto
|
|
-- the stack, then TitleState with cycling mons -- no Pikachu layout. Same
|
|
-- broken-mount setup, same assertions, blue/ prefix.
|
|
recorded = {}
|
|
GameVersion.set("blue")
|
|
seed("blue/assets/generated/title/blue_version.png", "blue-version-bytes")
|
|
seed("blue/assets/generated/title/player.png")
|
|
seed("blue/assets/generated/title/copyright.png")
|
|
seed("blue/assets/generated/title/gamefreak_inc.png")
|
|
local B_INTRO = {
|
|
"gf_logo.png", "gf_text.png", "big_star.png",
|
|
"falling_star.png", "falling_star_blink.png", "studio_logo.png",
|
|
"gengar_1.png", "gengar_2.png", "gengar_3.png",
|
|
"nidorino_1.png", "nidorino_2.png", "nidorino_3.png",
|
|
}
|
|
for _, name in ipairs(B_INTRO) do
|
|
seed("blue/assets/generated/intro/" .. name)
|
|
end
|
|
|
|
local blueIntroManifest = {
|
|
studio = { logo = "assets/generated/intro/studio_logo.png" },
|
|
gamefreakLogo = { path = "assets/generated/intro/gf_logo.png" },
|
|
gamefreakText = { path = "assets/generated/intro/gf_text.png" },
|
|
bigStar = { path = "assets/generated/intro/big_star.png" },
|
|
fallingStar = { path = "assets/generated/intro/falling_star.png" },
|
|
fallingStarBlink = { path = "assets/generated/intro/falling_star_blink.png" },
|
|
gengar = {
|
|
frame1 = { path = "assets/generated/intro/gengar_1.png" },
|
|
frame2 = { path = "assets/generated/intro/gengar_2.png" },
|
|
frame3 = { path = "assets/generated/intro/gengar_3.png" },
|
|
},
|
|
nidorino = {
|
|
frame1 = { path = "assets/generated/intro/nidorino_1.png" },
|
|
frame2 = { path = "assets/generated/intro/nidorino_2.png" },
|
|
frame3 = { path = "assets/generated/intro/nidorino_3.png" },
|
|
},
|
|
}
|
|
local blueGame = { data = { field = {
|
|
title = { version = { path = "assets/generated/title/blue_version.png" } },
|
|
intro = blueIntroManifest,
|
|
} } }
|
|
|
|
local IntroMovie = require("src.ui.IntroMovie")
|
|
local blueIntro = IntroMovie.new(blueGame, function() end)
|
|
eq(blueIntro.copyright and blueIntro.copyright.path,
|
|
"blue/assets/generated/title/copyright.png",
|
|
"Blue copyright card resolves to the blue/ copy")
|
|
eq(blueIntro.studioLogo and blueIntro.studioLogo.path,
|
|
"blue/assets/generated/intro/studio_logo.png",
|
|
"Blue data-driven studio logo resolves to the blue/ copy")
|
|
eq(blueIntro.logo and blueIntro.logo.path,
|
|
"blue/assets/generated/intro/gf_logo.png",
|
|
"Blue data-driven gamefreakLogo resolves to the blue/ copy")
|
|
eq(blueIntro.gfText and blueIntro.gfText.path,
|
|
"blue/assets/generated/intro/gf_text.png",
|
|
"Blue data-driven gamefreakText resolves to the blue/ copy")
|
|
eq(blueIntro.bigStar and blueIntro.bigStar.path,
|
|
"blue/assets/generated/intro/big_star.png",
|
|
"Blue data-driven bigStar resolves to the blue/ copy")
|
|
eq(blueIntro.gengarFrames and blueIntro.gengarFrames[2]
|
|
and blueIntro.gengarFrames[2].path,
|
|
"blue/assets/generated/intro/gengar_2.png",
|
|
"Blue data-driven gengar frame resolves to the blue/ copy")
|
|
eq(blueIntro.nidoFrames and blueIntro.nidoFrames[3]
|
|
and blueIntro.nidoFrames[3].path,
|
|
"blue/assets/generated/intro/nidorino_3.png",
|
|
"Blue data-driven nidorino frame resolves to the blue/ copy")
|
|
|
|
local blueTitle = TitleState.new(blueGame, {})
|
|
check(not blueTitle.yellowLayout,
|
|
"the Blue manifest keeps the cycling-mons layout")
|
|
eq(blueTitle.version and blueTitle.version.path,
|
|
"blue/assets/generated/title/blue_version.png",
|
|
"Blue title ribbon resolves to the blue/ copy")
|
|
eq(blueTitle.player and blueTitle.player.path,
|
|
"blue/assets/generated/title/player.png",
|
|
"Blue title player resolves to the blue/ copy")
|
|
eq(love.filesystem.read("assets/generated/title/blue_version.png"),
|
|
"blue-version-bytes",
|
|
"a generated filesystem.read resolves to the blue/ bytes")
|
|
|
|
check(countRecorded("blue/assets/generated/") >= 12,
|
|
"the boot pulled its generated art through the blue/ prefix")
|
|
assertNoBareGenerated("blue/", "Blue boot")
|
|
|
|
-- --- cleanup ------------------------------------------------------------
|
|
Overlay.uninstall()
|
|
check(not Overlay.isInstalled(), "overlay uninstalls")
|
|
love.graphics.newImage = rawNewImage
|
|
love.filesystem.read = rawRead
|
|
love.sound.newSoundData = rawNewSoundData
|
|
love.audio = nil
|
|
for _, path in ipairs(seeded) do
|
|
love.filesystem.remove(path)
|
|
end
|
|
Platform._resetForTests()
|
|
GameVersion.set(savedVersion)
|
|
|
|
T.finish()
|