Centralize ROM cache publication contract

This commit is contained in:
david
2026-08-22 18:10:05 -07:00
parent d54f9a02e0
commit bdbdbf2ed6
5 changed files with 413 additions and 220 deletions
+196
View File
@@ -0,0 +1,196 @@
-- Engine-owned contract for generated ROM caches.
--
-- A cache is playable only when its versioned marker matches the ROM and every
-- required output for that version exists. Extraction writers may differ by
-- platform, but they must publish through this contract so partial staging
-- cannot look ready to the runtime.
local GameVersion = require("src.core.GameVersion")
local CacheContract = {}
CacheContract.FORMAT = "rom-cache-v10:"
CacheContract.MARKER_PATH = "rom-cache.complete"
CacheContract.REQUIRED_FILES = {
"data/generated/constants.lua",
"data/generated/maps.lua",
"data/generated/text.lua",
"data/generated/field.lua",
"data/generated/battle_anims.lua",
"assets/generated/title/pokemon_logo.png",
"assets/generated/fonts/font.png",
"assets/generated/battle/front/pikachu.png",
"assets/generated/battle/anims/move_anim_0.png",
"assets/generated/battle/anims/move_anim_1.png",
"assets/generated/audio/programs.bin",
"assets/generated/trade/game_boy.png",
}
CacheContract.VERSION_REQUIRED_FILES = {
yellow = {
"assets/generated/battle/trainers/jessie_james.png",
"assets/generated/battle/profoakb.png",
"assets/generated/pikachu/pikapic_1.png",
},
}
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE = {
gold = {
"data/generated/constants.lua",
"data/generated/maps.lua",
"data/generated/roofs.lua",
"data/generated/sprites.lua",
"data/generated/scripts.lua",
"data/generated/text.lua",
-- The engine's label-keyed strings are separate from Gen 2 script text.
-- Caches made before RomExtractorGen2:extractText must be rebuilt so
-- src/core/RomText.lua does not silently fall back to built-in wording.
"data/generated/rom_text.lua",
"data/generated/pokemon.lua",
"data/generated/tilesets.lua",
"data/generated/audio.lua",
"data/generated/marts.lua",
"assets/generated/fonts/font.png",
"assets/generated/fonts/frames.png",
"assets/generated/title/pokemon_logo.png",
"assets/generated/title/title_screen.png",
"assets/generated/title/hooh.png",
"assets/generated/title/hooh_5.png",
"assets/generated/title/clouds.png",
"assets/generated/title/copyright_splash.png",
"data/generated/oak_speech.lua",
"assets/generated/intro/oak.png",
"assets/generated/intro/cal.png",
"assets/generated/tilesets/johto.png",
"assets/generated/tilesets/roofs/new_bark.png",
"assets/generated/sprites/chris.png",
"assets/generated/battle/front/chikorita.png",
"assets/generated/battle/front/pikachu.png",
"assets/generated/battle/front/marill.png",
"assets/generated/battle/trainers/falkner.png",
"assets/generated/battle/hud/balls.png",
"assets/generated/audio/programs.bin",
"assets/generated/slots/gold_slots_1.png",
"assets/generated/card_flip/card_flip_1.png",
"assets/generated/pc/mail_item.png",
},
}
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.silver =
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.gold
function CacheContract.requiredFilesFor(version)
local override = CacheContract.VERSION_REQUIRED_FILES_OVERRIDE[version]
if override then return override, true end
return CacheContract.REQUIRED_FILES, false
end
function CacheContract.markerFor(version)
return CacheContract.FORMAT .. GameVersion.info(version).sha1
end
-- Keep the process-global CacheFs prefix isolated even when a filesystem
-- adapter raises while probing or publishing. The real CacheFs methods
-- return errors, but this also makes the contract safe for platform adapters
-- that surface I/O failures as Lua errors.
local function withVersionPrefix(version, fs, action)
local saved = fs.prefix
fs.prefix = GameVersion.cachePrefix(version)
local ok, first, second = pcall(action)
fs.prefix = saved
if not ok then return false, first end
return true, first, second
end
function CacheContract.allRequiredFilesExist(version, fs)
fs = fs or require("src.import.CacheFs")
local ok, complete, missing = withVersionPrefix(version, fs, function()
local required, isOverride = CacheContract.requiredFilesFor(version)
local missingPath
for _, path in ipairs(required) do
if not fs.exists(path) then missingPath = path; break end
end
if not missingPath and not isOverride then
for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES[version] or {}) do
if not fs.exists(path) then missingPath = path; break end
end
end
return missingPath == nil, missingPath
end)
if not ok then return false, complete end
return complete, missing
end
function CacheContract.readMarker(version, fs)
fs = fs or require("src.import.CacheFs")
local ok, marker, readError = withVersionPrefix(version, fs, function()
return fs.read(CacheContract.MARKER_PATH)
end)
if not ok then return nil, marker end
-- LÖVE may return contents plus a byte count; only a nil contents result
-- makes the auxiliary value an error. CacheFs' portable reader returns
-- just the contents, so this remains adapter-neutral.
if marker == nil then return nil, readError end
return marker
end
function CacheContract.isReady(version, fs)
fs = fs or require("src.import.CacheFs")
if CacheContract.sourceTreeHasData(version) then return true end
local marker, readError = CacheContract.readMarker(version, fs)
if readError or marker ~= CacheContract.markerFor(version) then return false end
return CacheContract.allRequiredFilesExist(version, fs)
end
function CacheContract.publish(version, fs)
fs = fs or require("src.import.CacheFs")
local complete, missing = CacheContract.allRequiredFilesExist(version, fs)
if not complete then
-- A caller may be retrying over a partially replaced cache. Do not
-- leave its old marker advertising readiness after this failed check.
local removed, removeError = withVersionPrefix(version, fs, function()
if not fs.remove then
error("cache filesystem cannot remove the completion marker")
end
return fs.remove(CacheContract.MARKER_PATH)
end)
if not removed then
return false, "cache is incomplete; missing " .. tostring(missing)
.. "; could not remove completion marker: " .. tostring(removeError)
end
return false, "cache is incomplete; missing " .. tostring(missing)
end
local changed, ok, err = withVersionPrefix(version, fs, function()
return fs.write(CacheContract.MARKER_PATH, CacheContract.markerFor(version))
end)
if not changed then return false, tostring(ok) end
return ok, err
end
function CacheContract.sourceTreeHasData(version)
if not (love and love.filesystem and love.filesystem.getInfo
and love.filesystem.getRealDirectory and love.filesystem.getSource) then
return false
end
local prefix = version == "red" and "" or GameVersion.cachePrefix(version)
local required, isOverride = CacheContract.requiredFilesFor(version)
local source = love.filesystem.getSource()
for _, path in ipairs(required) do
local fullPath = prefix .. path
if love.filesystem.getInfo(fullPath, "file") == nil
or love.filesystem.getRealDirectory(fullPath) ~= source then
return false
end
end
if not isOverride then
for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES[version] or {}) do
local fullPath = prefix .. path
if love.filesystem.getInfo(fullPath, "file") == nil
or love.filesystem.getRealDirectory(fullPath) ~= source then
return false
end
end
end
return true
end
return CacheContract
+14 -195
View File
@@ -28,137 +28,12 @@ local function pickFile(...)
return fn(...) and true or false return fn(...) and true or false
end end
-- Cache generation tag; bump to force every imported version to re-extract. local CacheContract = require("src.import.CacheContract")
-- v9: Yellow audio re-anchored on pokeyellow.sym (#522) -- stale caches
-- carry Red's bank $1f header, wave-table, and CryData offsets.
-- v10: maps carry their raw map-header/connection/object bytes and tilesets
-- their Tilesets row (#889), which a .sav export replays so a Continue on
-- real hardware has a map to load; a v9 cache has none of them and exports
-- the same unbootable save as before.
-- Deliberately NOT bumped for the Gold trainer-pic gap: this tag invalidates
-- every version at once, and that gap is Gold-only. A per-version marker in
-- VERSION_REQUIRED_FILES_OVERRIDE.gold re-imports exactly the caches that lack
-- the stage, which is what the Yellow markers below already do for #439/#557.
-- Reach for a bump when the change spans versions or has no single file to
-- point at.
local CACHE_FORMAT = "rom-cache-v10:"
-- The completion marker is written under each version's cache prefix
-- (red/rom-cache.complete, blue/rom-cache.complete, ...).
local MARKER_PATH = "rom-cache.complete"
-- The marker a finished import writes for a version: the generation tag plus
-- that version's ROM hash, so both a format bump and a swapped ROM invalidate.
local function markerFor(version)
return CACHE_FORMAT .. GameVersion.info(version).sha1
end
local COMMUNITY_URL = "https://bois.icu" local COMMUNITY_URL = "https://bois.icu"
local TRUST_WARNING = "if you did not get this from bryanthaboi's github " .. local TRUST_WARNING = "if you did not get this from bryanthaboi's github " ..
"or a link from the discord that bryanthaboi himself posted, just know " .. "or a link from the discord that bryanthaboi himself posted, just know " ..
"it might have been tampered with. go to the discord to verify " .. "it might have been tampered with. go to the discord to verify " ..
COMMUNITY_URL .. " (or click the logo above)" COMMUNITY_URL .. " (or click the logo above)"
local REQUIRED_FILES = {
"data/generated/constants.lua",
"data/generated/maps.lua",
"data/generated/text.lua",
"data/generated/field.lua",
"data/generated/battle_anims.lua",
"assets/generated/title/pokemon_logo.png",
"assets/generated/fonts/font.png",
"assets/generated/battle/front/pikachu.png",
"assets/generated/battle/anims/move_anim_0.png",
"assets/generated/battle/anims/move_anim_1.png",
"assets/generated/audio/programs.bin",
-- The trade cinematic's Game Boy / cable art. Caches built before #750
-- carry none of it and fall back to plain rectangles, so listing one of
-- the files re-imports them without a CACHE_FORMAT bump.
"assets/generated/trade/game_boy.png",
}
-- Files only one version's cache carries. A version that predates one of
-- them re-imports on its own, without dragging the other versions through a
-- CACHE_FORMAT bump.
local VERSION_REQUIRED_FILES = {
yellow = {
"assets/generated/battle/trainers/jessie_james.png", -- #439
-- Oak's own back pic and the pikapic base frames only exist in caches
-- built after their manifest symbols landed, so an older Yellow cache
-- has to re-import to stop falling back to the old man's back pic and
-- to the battle front pic (#557, #561). Both are gated on manifest
-- symbols in RomExtractor, so these markers must only ever list files
-- tools/rom_manifest_yellow.json can actually produce -- otherwise the
-- cache reads as incomplete and re-importing cannot clear it.
"assets/generated/battle/profoakb.png",
"assets/generated/pikachu/pikapic_1.png",
},
}
-- Gold Phase 1 writes a thinner cache than Gen 1 (no battle anim sheets,
-- trade art, or field.lua payload yet -- see docs/gold-phase1.md). This
-- list replaces REQUIRED_FILES entirely for that version so a successful
-- Gen 2 extract is not stuck as "incomplete" waiting on Gen 1 markers.
local VERSION_REQUIRED_FILES_OVERRIDE = {
gold = {
"data/generated/constants.lua",
"data/generated/maps.lua",
"data/generated/roofs.lua", -- Phase 2: forces re-import of Phase 1 caches
"data/generated/sprites.lua", -- OW sheets (Chris + NPCs)
"data/generated/scripts.lua", -- disassembled map scripts
"data/generated/text.lua", -- decoded Gen 2 dialogue strings
-- The engine's own strings, keyed by label rather than by address. A
-- cache built before RomExtractorGen2:extractText has none, and every
-- line that reads through src/core/RomText.lua would silently keep
-- printing its Lua fallback, so this re-imports those caches rather than
-- bumping CACHE_FORMAT and dragging Red, Blue and Yellow through it too.
"data/generated/rom_text.lua",
"data/generated/pokemon.lua",
"data/generated/tilesets.lua",
"data/generated/audio.lua",
-- Mart shelves + the heal machine art ride the same import, so listing
-- marts.lua alone re-imports the caches from before either existed
-- (empty shop shelves, no Pokecenter light show).
"data/generated/marts.lua",
"assets/generated/fonts/font.png",
"assets/generated/fonts/frames.png", -- the seven other OPTION textbox frames
"assets/generated/title/pokemon_logo.png",
"assets/generated/title/title_screen.png", -- TitleScreenTilemap composition
"assets/generated/title/hooh.png",
"assets/generated/title/hooh_5.png", -- wing-flap frames force re-import
"assets/generated/title/clouds.png",
"assets/generated/title/copyright_splash.png",
"data/generated/oak_speech.lua", -- Oak texts + trainer pics
"assets/generated/intro/oak.png",
"assets/generated/intro/cal.png",
"assets/generated/tilesets/johto.png",
"assets/generated/tilesets/roofs/new_bark.png",
"assets/generated/sprites/chris.png",
"assets/generated/battle/front/chikorita.png",
"assets/generated/battle/front/pikachu.png",
"assets/generated/battle/front/marill.png", -- Oak speech demo mon
-- The trainer class pics (TrainerPicPointers). FALKNER is row 0 of that
-- table, so a cache that produced any class pic at all produced this one.
-- Listed for the reason the Yellow markers above are: a cache built before
-- the stage existed reads as INCOMPLETE and re-imports itself, so this
-- particular gap cannot survive a tag bump being forgotten again. It
-- costs nothing on a current cache and is the difference between every
-- trainer battle opening with a picture and opening with none.
"assets/generated/battle/trainers/falkner.png",
-- BattleStart_TrainerHuds cannot draw its party rows from a cache made
-- before the four ball tiles were extracted (#1502).
"assets/generated/battle/hud/balls.png",
"assets/generated/audio/programs.bin",
-- Goldenrod Game Corner reel + board art (#1581). menu_gfx.lua used to
-- advertise these paths even when Slots*LZ / CardFlip* were absent from
-- the manifest, so a cache that never wrote the PNGs still looked
-- complete and SlotMachine crashed on its labelled-cell fallback.
"assets/generated/slots/gold_slots_1.png",
"assets/generated/card_flip/card_flip_1.png",
-- PCMailGFX (engine/pokemon/bills_pc.asm:2170-2173)
"assets/generated/pc/mail_item.png",
},
}
-- Same Gen 2 extract, so a Silver cache is complete when the same files exist.
VERSION_REQUIRED_FILES_OVERRIDE.silver = VERSION_REQUIRED_FILES_OVERRIDE.gold
-- "Split-screen ROM selector" first-run palette (matches the FirstRun mockup): -- "Split-screen ROM selector" first-run palette (matches the FirstRun mockup):
-- a dark neon arcade panel, one column per game. -- a dark neon arcade panel, one column per game.
-- Red, Blue, and Yellow share the same importer flow once listed in -- Red, Blue, and Yellow share the same importer flow once listed in
@@ -211,58 +86,6 @@ local PAL = {
chipInkGold = { 58, 44, 0 }, -- #3a2c00 dark "Y" on the gold chip chipInkGold = { 58, 44, 0 }, -- #3a2c00 dark "Y" on the gold chip
} }
-- Per-version required cache files. Gold replaces the Gen 1 list entirely
-- (VERSION_REQUIRED_FILES_OVERRIDE); Yellow adds a few extra markers.
local function requiredFilesFor(version)
local override = VERSION_REQUIRED_FILES_OVERRIDE[version]
if override then return override, true end
return REQUIRED_FILES, false
end
-- CacheFs.exists checks the game folder directly for a portable install,
-- otherwise the save directory through love.filesystem. It honors
-- CacheFs.prefix, so we point it at the version's cache subtree (red/,
-- blue/, yellow/, gold/).
local function allRequiredFilesExist(version)
local CacheFs = require("src.import.CacheFs")
local saved = CacheFs.prefix
CacheFs.prefix = GameVersion.cachePrefix(version)
local ok = true
local required, isOverride = requiredFilesFor(version)
for _, path in ipairs(required) do
if not CacheFs.exists(path) then ok = false; break end
end
if ok and not isOverride then
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
if not CacheFs.exists(path) then ok = false; break end
end
end
CacheFs.prefix = saved
return ok
end
-- A developer checkout / Python build leaves generated data in the physfs
-- source: Red at the historical root, Blue/Yellow/Gold in their versioned
-- trees. Imported Red caches still live under red/. Check source paths
-- directly so that cache prefix cannot hide Red's source tree, and keep
-- save-dir caches from counting as current source data.
local function sourceTreeHasData(version)
if not love.filesystem.getRealDirectory then return false end
local prefix = version == "red" and "" or GameVersion.cachePrefix(version)
local required, isOverride = requiredFilesFor(version)
for _, path in ipairs(required) do
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
end
if not isOverride then
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
end
end
local path = prefix .. required[1]
local real = love.filesystem.getRealDirectory(path)
return real == love.filesystem.getSource()
end
-- ------- ROM cache location -- ------- ROM cache location
-- --
-- The extracted cache (data/generated, assets/generated) plus the -- The extracted cache (data/generated, assets/generated) plus the
@@ -318,10 +141,15 @@ local function purgeSaveDirCache()
-- / yellow/ / gold/ prefix) so it cannot shadow the portable game-folder cache. -- / yellow/ / gold/ prefix) so it cannot shadow the portable game-folder cache.
for _, version in ipairs(GameVersion.ORDER) do for _, version in ipairs(GameVersion.ORDER) do
local prefix = GameVersion.cachePrefix(version) local prefix = GameVersion.cachePrefix(version)
if saveDirHas(prefix .. MARKER_PATH) or saveDirHas(prefix .. REQUIRED_FILES[1]) then local required = CacheContract.requiredFilesFor(version)
local hasRequired = false
for _, path in ipairs(required) do
if saveDirHas(prefix .. path) then hasRequired = true; break end
end
if saveDirHas(prefix .. CacheContract.MARKER_PATH) or hasRequired then
removeTree(prefix .. "data/generated") removeTree(prefix .. "data/generated")
removeTree(prefix .. "assets/generated") removeTree(prefix .. "assets/generated")
love.filesystem.remove(prefix .. MARKER_PATH) love.filesystem.remove(prefix .. CacheContract.MARKER_PATH)
end end
end end
end end
@@ -336,13 +164,7 @@ function RomImporter.isReady(version)
-- save-directory copy that would otherwise shadow it at runtime. -- save-directory copy that would otherwise shadow it at runtime.
purgeSaveDirCache() purgeSaveDirCache()
end end
-- Generated data in a developer checkout / Python build is always current. return CacheContract.isReady(version, CacheFs)
if sourceTreeHasData(version) then return true end
local saved = CacheFs.prefix
CacheFs.prefix = GameVersion.cachePrefix(version)
local marker = CacheFs.read(MARKER_PATH)
CacheFs.prefix = saved
return marker == markerFor(version) and allRequiredFilesExist(version)
end end
function RomImporter.syncAndroidShortcuts(activeVersion) function RomImporter.syncAndroidShortcuts(activeVersion)
@@ -1574,12 +1396,9 @@ function RomImporter.new(onComplete, opts)
self.ready[version] = ready self.ready[version] = ready
-- a marker present but for an older cache generation / different ROM means -- a marker present but for an older cache generation / different ROM means
-- "update required" (re-import) rather than a clean first-run choose -- "update required" (re-import) rather than a clean first-run choose
local saved = CacheFs.prefix local marker = CacheContract.readMarker(version, CacheFs)
CacheFs.prefix = info.cachePrefix
local marker = CacheFs.read(MARKER_PATH)
CacheFs.prefix = saved
self.returning[version] = self.returning[version] =
(not ready) and marker ~= nil and marker ~= markerFor(version) (not ready) and marker ~= nil and marker ~= CacheContract.markerFor(version)
self.romName[version] = "pokemon_" .. info.id self.romName[version] = "pokemon_" .. info.id
.. ((info.id == "yellow" or GameVersion.generation(version) == 2) .. ((info.id == "yellow" or GameVersion.generation(version) == 2)
and ".gbc" or ".gb") and ".gbc" or ".gb")
@@ -1889,10 +1708,10 @@ function RomImporter:startData(data, displayName)
local cleared, clearError = pcall(function() local cleared, clearError = pcall(function()
removeTree(prefix .. "data/generated") removeTree(prefix .. "data/generated")
removeTree(prefix .. "assets/generated") removeTree(prefix .. "assets/generated")
love.filesystem.remove(prefix .. MARKER_PATH) love.filesystem.remove(prefix .. CacheContract.MARKER_PATH)
CacheFs.removeTree("data/generated") CacheFs.removeTree("data/generated")
CacheFs.removeTree("assets/generated") CacheFs.removeTree("assets/generated")
CacheFs.remove(MARKER_PATH) CacheFs.remove(CacheContract.MARKER_PATH)
end) end)
CacheFs.prefix = savedPrefix CacheFs.prefix = savedPrefix
if not cleared then if not cleared then
@@ -1964,7 +1783,7 @@ function RomImporter:_completeImport(version, prefix, displayName)
-- appear once every required file is in place. -- appear once every required file is in place.
local savedPrefix = CacheFs.prefix local savedPrefix = CacheFs.prefix
CacheFs.prefix = prefix CacheFs.prefix = prefix
local ok, writeError = CacheFs.write(MARKER_PATH, markerFor(version)) local ok, writeError = CacheContract.publish(version, CacheFs)
CacheFs.prefix = savedPrefix CacheFs.prefix = savedPrefix
if not ok then if not ok then
error("could not finish the private cache: " .. tostring(writeError)) error("could not finish the private cache: " .. tostring(writeError))
+176
View File
@@ -0,0 +1,176 @@
-- The cache contract is the shared Lua-side publication boundary. A writer
-- may stage outputs in any order, but readiness is published only after the
-- version-specific required set exists.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local eq = T.eq
local CacheContract = require("src.import.CacheContract")
local fs = { prefix = "initial/", files = {} }
local writes = {}
function fs.exists(path)
return fs.files[fs.prefix .. path] ~= nil
end
function fs.read(path)
return fs.files[fs.prefix .. path]
end
function fs.write(path, value)
writes[#writes + 1] = fs.prefix .. path
fs.files[fs.prefix .. path] = value
return true
end
function fs.remove(path)
fs.files[fs.prefix .. path] = nil
end
local required, isOverride = CacheContract.requiredFilesFor("red")
check(not isOverride, "Red uses the shared required-file list")
check(#required > 0, "Red has required outputs")
eq(CacheContract.markerFor("red"),
CacheContract.FORMAT .. "ea9bcae617fdf159b045185467ae58b2e4a48b9a",
"marker contains format and Red SHA-1")
for index = 1, #required - 1 do
fs.files["red/" .. required[index]] = true
end
local complete, missing = CacheContract.allRequiredFilesExist("red", fs)
check(not complete, "missing output keeps cache incomplete")
eq(missing, required[#required], "missing output is reported")
local published, publishError = CacheContract.publish("red", fs)
check(not published, "incomplete cache is not published")
check(publishError ~= nil, "incomplete publication explains the missing output")
check(fs.files["red/" .. CacheContract.MARKER_PATH] == nil,
"incomplete cache has no completion marker")
-- Publication must remove a stale marker left by an interrupted replacement,
-- and must restore the caller prefix on both the success and failure paths.
fs.files["red/" .. CacheContract.MARKER_PATH] = "old-marker"
local removedMarker = CacheContract.publish("red", fs)
check(not removedMarker, "incomplete retry is still rejected")
check(fs.files["red/" .. CacheContract.MARKER_PATH] == nil,
"incomplete retry removes a stale completion marker")
eq(fs.prefix, "initial/", "incomplete publication restores the caller prefix")
fs.files["red/" .. required[#required]] = true
fs.prefix = "caller/prefix/"
fs.files["red/" .. required[#required]] = true
for _, path in ipairs(required) do fs.files["red/" .. path] = true end
published, publishError = CacheContract.publish("red", fs)
check(published, "complete cache is published")
eq(publishError, nil, "complete publication has no error")
eq(fs.prefix, "caller/prefix/", "publication restores the caller prefix")
eq(fs.files["red/" .. CacheContract.MARKER_PATH], CacheContract.markerFor("red"),
"marker is written under the version prefix")
local marker = CacheContract.readMarker("red", fs)
eq(marker, CacheContract.markerFor("red"), "marker reads through the version prefix")
eq(writes[#writes], "red/" .. CacheContract.MARKER_PATH,
"the marker is the only publication write and comes last")
-- Every supported version gets its own marker and complete cache semantics;
-- Yellow adds its three outputs, while Gold/Silver replace the Gen 1 set.
for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do
local versionFiles, override = CacheContract.requiredFilesFor(version)
for _, path in ipairs(versionFiles) do
fs.files[version .. "/" .. path] = true
end
if not override then
for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES[version] or {}) do
fs.files[version .. "/" .. path] = true
end
end
local ready, missing = CacheContract.allRequiredFilesExist(version, fs)
check(ready, version .. " complete cache is ready (" .. tostring(missing) .. ")")
local didPublish = CacheContract.publish(version, fs)
check(didPublish, version .. " complete cache publishes")
eq(fs.files[version .. "/" .. CacheContract.MARKER_PATH],
CacheContract.markerFor(version), version .. " marker is version-scoped")
eq(fs.prefix, "caller/prefix/", version .. " publication restores prefix")
check(CacheContract.isReady(version, fs), version .. " complete cache is ready")
end
local gold, goldOverride = CacheContract.requiredFilesFor("gold")
check(goldOverride, "Gold uses a version-specific required set")
local goldSet = {}
for _, path in ipairs(gold) do goldSet[path] = true end
check(goldSet["assets/generated/battle/hud/balls.png"],
"Gold required set includes trainer HUD art")
check(not goldSet["assets/generated/trade/game_boy.png"],
"Gold required set excludes Gen 1 trade art")
check(goldSet["data/generated/rom_text.lua"],
"Gold required set includes the Gen 2 engine text table")
local silver = CacheContract.requiredFilesFor("silver")
local silverSet = {}
for _, path in ipairs(silver) do silverSet[path] = true end
check(silverSet["data/generated/rom_text.lua"],
"Silver required set includes the Gen 2 engine text table")
check(not silverSet["assets/generated/trade/game_boy.png"],
"Silver required set excludes Gen 1 trade art")
check(CacheContract.VERSION_REQUIRED_FILES.yellow ~= nil,
"Yellow has version-specific required outputs")
-- A throwing adapter must not strand the process in its temporary prefix.
local throwingFs = { prefix = "before/" }
function throwingFs.exists() error("probe failed") end
local probed, probeError = CacheContract.allRequiredFilesExist("blue", throwingFs)
check(not probed and probeError ~= nil, "filesystem probe errors are returned")
eq(throwingFs.prefix, "before/", "probe errors restore the caller prefix")
function throwingFs.write() error("write failed") end
function throwingFs.remove() end
for _, path in ipairs(CacheContract.REQUIRED_FILES) do
throwingFs.files = throwingFs.files or {}
throwingFs.files["blue/" .. path] = true
end
function throwingFs.exists(path)
return throwingFs.files[throwingFs.prefix .. path] ~= nil
end
local wrote = CacheContract.publish("blue", throwingFs)
check(not wrote, "write errors are returned")
eq(throwingFs.prefix, "before/", "write errors restore the caller prefix")
-- Source-tree readiness must use the same version lists and reject a cache
-- when LÖVE cannot identify a real source directory.
local oldLove = love
love = nil
check(not CacheContract.sourceTreeHasData("red"),
"source-tree check is safe without LÖVE")
local sourceFiles = {}
love = {
filesystem = {
getRealDirectory = function(path) return sourceFiles[path] end,
getSource = function() return "/source" end,
getInfo = function(path)
return sourceFiles[path] and { type = "file" } or nil
end,
},
}
for _, path in ipairs(CacheContract.REQUIRED_FILES) do sourceFiles[path] = "/source" end
check(CacheContract.sourceTreeHasData("red"),
"Red source tree uses the shared required set")
sourceFiles[CacheContract.REQUIRED_FILES[2]] = "/save"
check(not CacheContract.sourceTreeHasData("red"),
"source-tree readiness rejects a cache-overlaid required file")
sourceFiles = {}
local goldFiles = CacheContract.requiredFilesFor("gold")
for _, path in ipairs(goldFiles) do sourceFiles["gold/" .. path] = "/source" end
check(CacheContract.sourceTreeHasData("gold"),
"Gold source tree uses its override set")
love = oldLove
-- Both importer completion paths must call the shared publication boundary.
local importerFile = assert(io.open("src/import/RomImporter.lua", "r"))
local importerSource = importerFile:read("*a")
importerFile:close()
local completionCalls = 0
for _ in importerSource:gmatch("CacheContract%.publish%(%s*version") do
completionCalls = completionCalls + 1
end
eq(completionCalls, 1,
"both thread and coroutine paths converge on one publishing helper")
check(importerSource:find("self:_completeImport%(version, prefix, displayName%)")
~= nil, "coroutine completion uses the shared helper")
check(importerSource:find("pcall%(self%._completeImport") ~= nil,
"thread completion uses the shared helper")
T.finish("rom cache contract")
+20 -18
View File
@@ -1,32 +1,34 @@
-- sourceTreeHasData must use each version's required-file list. Gold's -- sourceTreeHasData must use the engine-owned cache contract. Gold's cache has
-- cache has no Gen 1 trade art / pikachu.png; validating it against -- no Gen 1 trade art; validating it against the Gen 1 list made a Gold source
-- REQUIRED_FILES made a Gold source tree look incomplete forever. -- tree look incomplete forever.
package.path = "./?.lua;./?/init.lua;" .. package.path package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness") local T = require("tests.harness")
local check = T.check local check = T.check
local CacheContract = require("src.import.CacheContract")
local f = assert(io.open("src/import/RomImporter.lua", "r")) local f = assert(io.open("src/import/RomImporter.lua", "r"))
local src = f:read("*a") local src = f:read("*a")
f:close() f:close()
local start = src:find("local function sourceTreeHasData", 1, true) local readyStart = src:find("function RomImporter.isReady", 1, true)
check(start ~= nil, "sourceTreeHasData is defined") check(readyStart ~= nil, "isReady is defined")
local finish = src:find("\nfunction RomImporter.isReady", start, true) local readyEnd = src:find("\nfunction RomImporter.syncAndroidShortcuts", readyStart, true)
check(finish ~= nil, "sourceTreeHasData ends before isReady") check(readyEnd ~= nil, "isReady ends before the next importer helper")
local body = src:sub(start, finish) local readyBody = src:sub(readyStart, readyEnd)
check(body:find("requiredFilesFor", 1, true) ~= nil, check(readyBody:find("CacheContract.isReady", 1, true) ~= nil,
"sourceTreeHasData uses requiredFilesFor (Gold override, not Gen 1 only)") "isReady delegates source-tree and cache readiness to the contract")
check(body:find("ipairs(REQUIRED_FILES)", 1, true) == nil, check(readyBody:find("ipairs(REQUIRED_FILES)", 1, true) == nil,
"sourceTreeHasData does not iterate the Gen 1 REQUIRED_FILES list raw") "isReady does not iterate the Gen 1 REQUIRED_FILES list raw")
local helperStart = src:find("local function requiredFilesFor", 1, true) local required, isOverride = CacheContract.requiredFilesFor("gold")
check(helperStart ~= nil, "requiredFilesFor helper exists") check(isOverride, "Gold uses the override required-file list")
local helper = src:sub(helperStart, start) local requiredSet = {}
check(helper:find("VERSION_REQUIRED_FILES_OVERRIDE", 1, true) ~= nil, for _, path in ipairs(required) do requiredSet[path] = true end
"requiredFilesFor consults VERSION_REQUIRED_FILES_OVERRIDE") check(requiredSet["assets/generated/battle/hud/balls.png"],
check(src:find('"assets/generated/battle/hud/balls.png"', 1, true) ~= nil,
"Gold caches require the trainer HUD ball sheet") "Gold caches require the trainer HUD ball sheet")
check(not requiredSet["assets/generated/trade/game_boy.png"],
"Gold does not inherit the Gen 1 trade-art requirement")
T.finish() T.finish()
+7 -7
View File
@@ -84,15 +84,15 @@ if extractor then
end end
-- a cache imported before #750 has none of the art; listing one of the -- a cache imported before #750 has none of the art; listing one of the
-- files in REQUIRED_FILES is what makes it re-import -- files in the engine-owned cache contract is what makes it re-import
local importer = readFile("src/import/RomImporter.lua") local contract = readFile("src/import/CacheContract.lua")
T.check(importer ~= nil, "src/import/RomImporter.lua is readable") T.check(contract ~= nil, "src/import/CacheContract.lua is readable")
if importer then if contract then
local required = importer:match("local REQUIRED_FILES = {(.-)\n}") local required = contract:match("CacheContract.REQUIRED_FILES = {(.-)\n}")
T.check(required ~= nil, "REQUIRED_FILES parses") T.check(required ~= nil, "CacheContract.REQUIRED_FILES parses")
T.check(required ~= nil and required:find( T.check(required ~= nil and required:find(
'"assets/generated/trade/game_boy.png"', 1, true) ~= nil, '"assets/generated/trade/game_boy.png"', 1, true) ~= nil,
"REQUIRED_FILES makes pre-#750 caches re-import the trade art") "cache contract makes pre-#750 caches re-import the trade art")
end end
T.finish("trade art import") T.finish("trade art import")