From bdbdbf2ed6185129a0d0f2058fb0141bc8fa5aa5 Mon Sep 17 00:00:00 2001 From: david Date: Sat, 22 Aug 2026 18:10:05 -0700 Subject: [PATCH] Centralize ROM cache publication contract --- src/import/CacheContract.lua | 196 ++++++++++++++++ src/import/RomImporter.lua | 209 ++---------------- tests/engine/rom_cache_contract_test.lua | 176 +++++++++++++++ .../engine/rom_importer_source_tree_test.lua | 38 ++-- tests/engine/trade_art_import.lua | 14 +- 5 files changed, 413 insertions(+), 220 deletions(-) create mode 100644 src/import/CacheContract.lua create mode 100644 tests/engine/rom_cache_contract_test.lua diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua new file mode 100644 index 00000000..9aef281e --- /dev/null +++ b/src/import/CacheContract.lua @@ -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 diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index aa6ea4cf..152fe88e 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -28,137 +28,12 @@ local function pickFile(...) return fn(...) and true or false end --- Cache generation tag; bump to force every imported version to re-extract. --- 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 CacheContract = require("src.import.CacheContract") local COMMUNITY_URL = "https://bois.icu" 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 " .. "it might have been tampered with. go to the discord to verify " .. 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): -- a dark neon arcade panel, one column per game. -- 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 } --- 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 -- -- 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. for _, version in ipairs(GameVersion.ORDER) do 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 .. "assets/generated") - love.filesystem.remove(prefix .. MARKER_PATH) + love.filesystem.remove(prefix .. CacheContract.MARKER_PATH) end end end @@ -336,13 +164,7 @@ function RomImporter.isReady(version) -- save-directory copy that would otherwise shadow it at runtime. purgeSaveDirCache() end - -- Generated data in a developer checkout / Python build is always current. - 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) + return CacheContract.isReady(version, CacheFs) end function RomImporter.syncAndroidShortcuts(activeVersion) @@ -1574,12 +1396,9 @@ function RomImporter.new(onComplete, opts) self.ready[version] = ready -- a marker present but for an older cache generation / different ROM means -- "update required" (re-import) rather than a clean first-run choose - local saved = CacheFs.prefix - CacheFs.prefix = info.cachePrefix - local marker = CacheFs.read(MARKER_PATH) - CacheFs.prefix = saved + local marker = CacheContract.readMarker(version, CacheFs) 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 .. ((info.id == "yellow" or GameVersion.generation(version) == 2) and ".gbc" or ".gb") @@ -1889,10 +1708,10 @@ function RomImporter:startData(data, displayName) local cleared, clearError = pcall(function() removeTree(prefix .. "data/generated") removeTree(prefix .. "assets/generated") - love.filesystem.remove(prefix .. MARKER_PATH) + love.filesystem.remove(prefix .. CacheContract.MARKER_PATH) CacheFs.removeTree("data/generated") CacheFs.removeTree("assets/generated") - CacheFs.remove(MARKER_PATH) + CacheFs.remove(CacheContract.MARKER_PATH) end) CacheFs.prefix = savedPrefix if not cleared then @@ -1964,7 +1783,7 @@ function RomImporter:_completeImport(version, prefix, displayName) -- appear once every required file is in place. local savedPrefix = CacheFs.prefix CacheFs.prefix = prefix - local ok, writeError = CacheFs.write(MARKER_PATH, markerFor(version)) + local ok, writeError = CacheContract.publish(version, CacheFs) CacheFs.prefix = savedPrefix if not ok then error("could not finish the private cache: " .. tostring(writeError)) diff --git a/tests/engine/rom_cache_contract_test.lua b/tests/engine/rom_cache_contract_test.lua new file mode 100644 index 00000000..f5f5102d --- /dev/null +++ b/tests/engine/rom_cache_contract_test.lua @@ -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") diff --git a/tests/engine/rom_importer_source_tree_test.lua b/tests/engine/rom_importer_source_tree_test.lua index 508ba043..4ac595cb 100644 --- a/tests/engine/rom_importer_source_tree_test.lua +++ b/tests/engine/rom_importer_source_tree_test.lua @@ -1,32 +1,34 @@ --- sourceTreeHasData must use each version's required-file list. Gold's --- cache has no Gen 1 trade art / pikachu.png; validating it against --- REQUIRED_FILES made a Gold source tree look incomplete forever. +-- sourceTreeHasData must use the engine-owned cache contract. Gold's cache has +-- no Gen 1 trade art; validating it against the Gen 1 list made a Gold source +-- tree look incomplete forever. package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.harness") local check = T.check +local CacheContract = require("src.import.CacheContract") local f = assert(io.open("src/import/RomImporter.lua", "r")) local src = f:read("*a") f:close() -local start = src:find("local function sourceTreeHasData", 1, true) -check(start ~= nil, "sourceTreeHasData is defined") -local finish = src:find("\nfunction RomImporter.isReady", start, true) -check(finish ~= nil, "sourceTreeHasData ends before isReady") -local body = src:sub(start, finish) +local readyStart = src:find("function RomImporter.isReady", 1, true) +check(readyStart ~= nil, "isReady is defined") +local readyEnd = src:find("\nfunction RomImporter.syncAndroidShortcuts", readyStart, true) +check(readyEnd ~= nil, "isReady ends before the next importer helper") +local readyBody = src:sub(readyStart, readyEnd) -check(body:find("requiredFilesFor", 1, true) ~= nil, - "sourceTreeHasData uses requiredFilesFor (Gold override, not Gen 1 only)") -check(body:find("ipairs(REQUIRED_FILES)", 1, true) == nil, - "sourceTreeHasData does not iterate the Gen 1 REQUIRED_FILES list raw") +check(readyBody:find("CacheContract.isReady", 1, true) ~= nil, + "isReady delegates source-tree and cache readiness to the contract") +check(readyBody:find("ipairs(REQUIRED_FILES)", 1, true) == nil, + "isReady does not iterate the Gen 1 REQUIRED_FILES list raw") -local helperStart = src:find("local function requiredFilesFor", 1, true) -check(helperStart ~= nil, "requiredFilesFor helper exists") -local helper = src:sub(helperStart, start) -check(helper:find("VERSION_REQUIRED_FILES_OVERRIDE", 1, true) ~= nil, - "requiredFilesFor consults VERSION_REQUIRED_FILES_OVERRIDE") -check(src:find('"assets/generated/battle/hud/balls.png"', 1, true) ~= nil, +local required, isOverride = CacheContract.requiredFilesFor("gold") +check(isOverride, "Gold uses the override required-file list") +local requiredSet = {} +for _, path in ipairs(required) do requiredSet[path] = true end +check(requiredSet["assets/generated/battle/hud/balls.png"], "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() diff --git a/tests/engine/trade_art_import.lua b/tests/engine/trade_art_import.lua index 71736adf..28cbed6f 100644 --- a/tests/engine/trade_art_import.lua +++ b/tests/engine/trade_art_import.lua @@ -84,15 +84,15 @@ if extractor then end -- a cache imported before #750 has none of the art; listing one of the --- files in REQUIRED_FILES is what makes it re-import -local importer = readFile("src/import/RomImporter.lua") -T.check(importer ~= nil, "src/import/RomImporter.lua is readable") -if importer then - local required = importer:match("local REQUIRED_FILES = {(.-)\n}") - T.check(required ~= nil, "REQUIRED_FILES parses") +-- files in the engine-owned cache contract is what makes it re-import +local contract = readFile("src/import/CacheContract.lua") +T.check(contract ~= nil, "src/import/CacheContract.lua is readable") +if contract then + local required = contract:match("CacheContract.REQUIRED_FILES = {(.-)\n}") + T.check(required ~= nil, "CacheContract.REQUIRED_FILES parses") T.check(required ~= nil and required:find( '"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 T.finish("trade art import")