diff --git a/README.md b/README.md index 897a2874..141f35d4 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,8 @@ game data: - Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1` - Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94` - Silver: `49b163f7e57702bc939d642a18f591de55d92dae` -- Crystal: `f4cd194bdee0d04ca4eac29e09b8e4e9d818c133` +- Crystal (1.0): `f4cd194bdee0d04ca4eac29e09b8e4e9d818c133` +- Crystal (1.1): `f2f52230b536214ef7c9924f483392993e226cfb` The packaged app contains neither a ROM nor pre-extracted game data. Music, sound effects, and cries are synthesized while the game runs from compact diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index 3e39ced9..d254a46d 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -96,6 +96,10 @@ GameVersion.VERSIONS = { saveSuffix = "_crystal", -- save_crystal.lua / .bak / .tmp generation = 2, engine = "crystal", + revisions = { + { sha1 = "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133", label = "1.0" }, + { sha1 = "f2f52230b536214ef7c9924f483392993e226cfb", label = "1.1" }, + }, fixes = { -- pokegold/docs/bugs_and_glitches.md:61 luckyNumberBoxes = true, @@ -168,10 +172,29 @@ function GameVersion.cachePrefix(id) return GameVersion.info(id).cachePrefix end +function GameVersion.revisions(id) + local info = GameVersion.info(id) + return info.revisions or { { sha1 = info.sha1 } } +end + +function GameVersion.acceptsSha1(id, sha1) + for _, revision in ipairs(GameVersion.revisions(id)) do + if revision.sha1 == sha1 then return true end + end + return false +end + +function GameVersion.revisionLabel(id, sha1) + for _, revision in ipairs(GameVersion.revisions(id)) do + if revision.sha1 == sha1 then return revision.label end + end + return nil +end + -- The version a ROM belongs to, by its SHA-1, or nil for an unknown ROM. function GameVersion.forSha1(sha1) - for id, info in pairs(GameVersion.VERSIONS) do - if info.sha1 == sha1 then return id end + for id in pairs(GameVersion.VERSIONS) do + if GameVersion.acceptsSha1(id, sha1) then return id end end return nil end diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua index dd4e8503..3ae48d21 100644 --- a/src/import/CacheContract.lua +++ b/src/import/CacheContract.lua @@ -136,8 +136,15 @@ function CacheContract.requiredFilesFor(version) return CacheContract.REQUIRED_FILES, false end -function CacheContract.markerFor(version) - return CacheContract.FORMAT .. GameVersion.info(version).sha1 +function CacheContract.markerFor(version, sha1) + return CacheContract.FORMAT .. (sha1 or GameVersion.info(version).sha1) +end + +function CacheContract.markerMatches(version, marker) + for _, revision in ipairs(GameVersion.revisions(version)) do + if marker == CacheContract.markerFor(version, revision.sha1) then return true end + end + return false end -- Keep the process-global CacheFs prefix isolated even when a filesystem @@ -189,11 +196,11 @@ 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 + if readError or not CacheContract.markerMatches(version, marker) then return false end return CacheContract.allRequiredFilesExist(version, fs) end -function CacheContract.publish(version, fs) +function CacheContract.publish(version, fs, sha1) fs = fs or require("src.import.CacheFs") local complete, missing = CacheContract.allRequiredFilesExist(version, fs) if not complete then @@ -212,7 +219,7 @@ function CacheContract.publish(version, fs) 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)) + return fs.write(CacheContract.MARKER_PATH, CacheContract.markerFor(version, sha1)) end) if not changed then return false, tostring(ok) end return ok, err diff --git a/src/import/ExtractThread.lua b/src/import/ExtractThread.lua index b1bf99b2..e4631eb4 100644 --- a/src/import/ExtractThread.lua +++ b/src/import/ExtractThread.lua @@ -13,7 +13,7 @@ require("love.math") require("love.system") require("love.timer") -local version, prefix, romData, progressName, resultName = ... +local version, prefix, romData, progressName, resultName, romSha1 = ... local progressChannel = love.thread.getChannel(progressName) local resultChannel = love.thread.getChannel(resultName) @@ -44,7 +44,7 @@ local ok, err = pcall(function() current = current, stageTotal = stageTotal, }) end - end) + end, romSha1) extractor:run() end) diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index fe8b77e9..dbe4778e 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -3056,8 +3056,8 @@ local function buildFindKindRow(imp, x, y, w, m) end local function buildFindPanel(imp, x, y, w, availH, m) + imp._findVisibleEntries = nil imp:_ensureFind() - imp:_ensureMods() local ModIndex = require("src.mods.ModIndex") local ModUpdate = require("src.mods.ModUpdate") local sources = imp.findSources or {} @@ -3126,7 +3126,9 @@ local function buildFindPanel(imp, x, y, w, availH, m) if #rows == 0 then local empty - if carts then + if imp._findFetch then + empty = Strings("Loading mod index...") + elseif carts then empty = (total == 0) and Strings("This index lists no carts yet.") or Strings("No carts match that search.") else @@ -3191,6 +3193,11 @@ local function buildFindPanel(imp, x, y, w, availH, m) local listTop = cy setPage(imp, "find", Kit.wheelPage(x, listTop, w, listH, cur, #rows, perPage)) + local visible = imp._findVisibleEntries or {} + for i = #visible, 1, -1 do visible[i] = nil end + for i = first, last do visible[#visible + 1] = rows[i] end + imp._findVisibleEntries = visible + for i = first, last do local entry = rows[i] local ry = listTop + (i - first) * (rowH + gap) @@ -5764,12 +5771,6 @@ local function loaderSpec(imp) return { title = b.title, detail = b.detail, progress = b.progress, onCancel = b.cancel } end - -- The boot prewarm runs without an overlay (the user did not ask for it and - -- must be able to use the launcher meanwhile), but if they reach the Find - -- Mods tab before it lands, THEN they are waiting on it and it earns one. - if imp.tab == "find" and imp._findFetch and not imp.findLoaded then - return { title = Strings("Loading mod index") } - end return nil end diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 9584a9b6..2e8aea2c 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -201,14 +201,23 @@ local function copy(value) return result end -function RomExtractorGen2.new(romData, manifest, progress) +function RomExtractorGen2.new(romData, manifest, progress, romSha1) -- _GOLD / _SILVER: the labels are shared, the data behind a handful of -- them is not (gfx/misc.asm:9-20 vs :46-57). local edition = GameVersion.forSha1(manifest.romSha1) or "gold" + local symbols = manifest.symbols + local revision = romSha1 and manifest.symbolRevisions + and manifest.symbolRevisions[romSha1] + if revision then + local merged = {} + for name, location in pairs(manifest.symbols) do merged[name] = location end + for name, location in pairs(revision) do merged[name] = location end + symbols = merged + end return setmetatable({ rom = Rom.new(romData), manifest = manifest, - symbols = manifest.symbols, + symbols = symbols, progress = progress, stage = 0, edition = edition, diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index d0671ad7..cff73978 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1390,8 +1390,9 @@ function RomImporter.new(onComplete, opts) -- player's index list from options; findIndex is the merged listing; -- _findThumbs caches one image per mod id (false = fetched and failed). findLoaded = false, findSources = nil, findIndex = nil, - findScroll = 0, findNotice = nil, findQuery = "", findCategory = nil, - _findSearchFocus = false, _findThumbs = nil, + findInstalled = nil, findScroll = 0, findNotice = nil, findQuery = "", + findCategory = nil, _findSearchFocus = false, _findThumbs = nil, + _findVisibleEntries = nil, -- Which half of the feed the panel is browsing. Mods by default: carts -- are the newer, much shorter list, and a feed may carry none at all. findKind = "mods", findBase = nil, @@ -1432,7 +1433,7 @@ function RomImporter.new(onComplete, opts) -- "update required" (re-import) rather than a clean first-run choose local marker = CacheContract.readMarker(version, CacheFs) self.returning[version] = - (not ready) and marker ~= nil and marker ~= CacheContract.markerFor(version) + (not ready) and marker ~= nil and not CacheContract.markerMatches(version, marker) self.romName[version] = "pokemon_" .. info.id .. ((info.id == "yellow" or GameVersion.generation(version) == 2) and ".gbc" or ".gb") @@ -1721,6 +1722,7 @@ function RomImporter:startData(data, displayName) .. "(tagged [b] or [BF]) never verify."):format(actualHash, cartsProse())) return end + self.romSha1 = actualHash local info = GameVersion.info(version) -- Bring the launcher to this version's tab so its progress bar is on screen @@ -1775,7 +1777,7 @@ function RomImporter:_startExtractThread(version, prefix, data, displayName) love.thread.getChannel(progressName):clear() love.thread.getChannel(resultName):clear() local started = pcall(thread.start, thread, version, prefix, data, - progressName, resultName) + progressName, resultName, self.romSha1) if not started then return false end self._extract = { thread = thread, version = version, prefix = prefix, @@ -1804,7 +1806,7 @@ function RomImporter:_startExtractCoroutine(version, info, prefix, displayName) self.stageCurrent = current self.stageTotal = stageTotal coroutine.yield() - end) + end, self.romSha1) extractor:run() CacheFs.prefix = "" -- restore the default so later writes stay at the root self.romData = nil @@ -1822,7 +1824,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 = CacheContract.publish(version, CacheFs) + local ok, writeError = CacheContract.publish(version, CacheFs, self.romSha1) CacheFs.prefix = savedPrefix if not ok then error("could not finish the private cache: " .. tostring(writeError)) @@ -2589,6 +2591,7 @@ function RomImporter:update(dt) -- before a tab switch still completes. self:_pumpFindFetch() self:_pumpModInfoFetch() + self:_queueFindEnrichment() self:_pumpFindStats() self:_pumpFindThumbs() self:_pumpSkinFetch() @@ -3226,6 +3229,7 @@ end -- offset persists inside the view's per-tab scroll container. function RomImporter:_switchTab(id) self.tab = id + if id ~= "find" then self._findVisibleEntries = nil end self._findSearchFocus = false self._skinUrlFocus = false self:_disarmTextInput() @@ -3823,6 +3827,7 @@ function RomImporter:_toggleSafeMode() SaveData.saveOptions(options) self.safeMode = enabled self.mods = nil + self.findInstalled = nil self._modSortCache = nil self._modInfoFetch = nil self.modNotice = nil @@ -4798,6 +4803,7 @@ function RomImporter:_refreshMods() local LauncherMods = require("src.mods.LauncherMods") local SaveData = require("src.core.SaveData") self._cartPlan = nil + self.findInstalled = nil self.safeMode = SaveData.isSafeMode(SaveData.loadOptions()) -- Once per session, ahead of the first listing: pull in any mod the player -- unzipped beside the executable, which an ordinary (non-portable) install @@ -5718,7 +5724,8 @@ end -- as long as the slowest index took -- measured at over two minutes on a -- cold open, with no spinner, because the frame that would have drawn one -- never ran. The fetch now starts here and completes across later frames in --- _pumpFindFetch; the loader overlay is up for the whole flight. +-- _pumpFindFetch. Only an explicit Refresh is blocking; boot prewarm and the +-- first visit keep the launcher interactive while the listing arrives. function RomImporter:_refreshFind(force) -- The notice is the fix, not the gate (#876). This branch used to return an -- empty listing silently, and because the player had by then added a source, @@ -5756,9 +5763,11 @@ function RomImporter:_refreshFind(force) carts = {}, cartSeen = {}, bases = {}, baseSeen = {}, stale = false, oldest = nil, at = 1, } - self:_setBusy(Strings("Fetching mod index"), - #sources == 1 and (sources[1].label or sources[1].feed) - or Strings("%d indexes", #sources)) + if force == true then + self:_setBusy(Strings("Fetching mod index"), + #sources == 1 and (sources[1].label or sources[1].feed) + or Strings("%d indexes", #sources)) + end end -- Drive the in-flight index fetch one frame at a time. Called from update(). @@ -5958,9 +5967,17 @@ end function RomImporter:_findInstalledMap() if self:findingCarts() then return self:_findInstalledCarts() end - local map = {} - for _, m in ipairs(self.mods or {}) do map[m.id] = m.version or true end - return map + if self.findInstalled then return self.findInstalled end + local LauncherMods = require("src.mods.LauncherMods") + if self.mods then + self.findInstalled = {} + for _, m in ipairs(self.mods) do + self.findInstalled[m.id] = m.version or true + end + else + self.findInstalled = LauncherMods.installedVersions() or {} + end + return self.findInstalled end -- id -> installed version for every cart on disk, whatever game it plays as. @@ -5978,10 +5995,9 @@ function RomImporter:_findInstalledCarts() return map end --- One thumbnail per frame, and only for a card actually on screen: the fetch --- is a blocking curl, so downloading a whole listing's worth on open would --- stall the launcher for as many seconds as there are mods. A failure is --- remembered as `false` so a broken URL is tried once, not every frame. +-- Read the cached thumbnail for a card. Starting a download is deliberately +-- separate: immediate-mode draw may call this for every visible row, but it +-- must not mutate the fetch queue or perform network work. function RomImporter:_findThumb(entry) self._findThumbs = self._findThumbs or {} local cached = self._findThumbs[entry.id] @@ -5992,6 +6008,20 @@ function RomImporter:_findThumb(entry) self._findThumbs[entry.id] = false return nil end + return nil +end + +-- Queue one thumbnail after draw has recorded the visible rows. The fetch +-- pool runs off-thread; only the finished image decode stays in update(). +function RomImporter:_startFindThumb(entry) + self._findThumbs = self._findThumbs or {} + if self._findThumbs[entry.id] ~= nil then return end + local ModIndex = require("src.mods.ModIndex") + local url = ModIndex.joinUrl(entry._base, entry.thumbnail) + if not url then + self._findThumbs[entry.id] = false + return + end -- ASYNC (was one blocking download per frame). Only rows on the current -- page ever ask, so pagination already bounds this to a page's worth of -- requests; the fetch pool runs them off-thread and the card shows its @@ -6112,9 +6142,9 @@ function RomImporter:_requestFindStats(entry) } end --- Request-and-read, for a row that is being drawn and for the detail modal. +-- Read-only accessor for a row being drawn or shown in the detail modal. +-- Network work is scheduled by _queueFindEnrichment from update(). function RomImporter:_findStats(entry) - self:_requestFindStats(entry) return self:_findStatsCached(entry) end @@ -6134,6 +6164,38 @@ function RomImporter:_findThumbPending(id) return (self._findThumbFetch and self._findThumbFetch[id]) ~= nil end +-- Draw records the visible page in _findVisibleEntries. Queue only a small +-- batch from that snapshot during update(), keeping network scheduling out of +-- the immediate-mode render path and preventing a large index from creating a +-- burst of thumbnail/GitHub work in one frame. +local FIND_ENRICH_PER_FRAME = 2 + +function RomImporter:_queueFindEnrichment() + if self.tab ~= "find" or not self.findLoaded then return end + local visible = self._findVisibleEntries + if not visible then return end + local thumbnails, stats = 0, 0 + for _, entry in ipairs(visible) do + if thumbnails < FIND_ENRICH_PER_FRAME + and self:_findThumb(entry) == nil + and not self:_findThumbPending(entry.id) then + self:_startFindThumb(entry) + thumbnails = thumbnails + 1 + end + if stats < FIND_ENRICH_PER_FRAME + and self:_findStatsCached(entry) == nil + and entry.github and entry.github ~= "" + and not self:_findStatsPendingFor(entry.id) then + self:_requestFindStats(entry) + stats = stats + 1 + end + if thumbnails >= FIND_ENRICH_PER_FRAME + and stats >= FIND_ENRICH_PER_FRAME then + break + end + end +end + -- Drive in-flight FIND MODS stats lookups. Called from update(). function RomImporter:_pumpFindStats() local pending = self._findStatsPending diff --git a/src/import/RomManifest.lua b/src/import/RomManifest.lua index f1205635..c3d21b40 100644 --- a/src/import/RomManifest.lua +++ b/src/import/RomManifest.lua @@ -18,7 +18,8 @@ function RomManifest.decode(version) if not manifest then error("ROM import metadata is invalid: " .. tostring(decodeError)) end - assert(manifest.romSha1 == info.sha1, "ROM import metadata version mismatch") + assert(GameVersion.acceptsSha1(version, manifest.romSha1), + "ROM import metadata version mismatch") return manifest end diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index 48f989a9..627f7ce2 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -471,6 +471,20 @@ discover = function() return out end +-- installedVersions() -> id -> installed version. MOD INDEX only needs to +-- know whether a listing is already present; it does not need enablement, +-- dependency/conflict status, required-import validation, or migration. Keep +-- that cheap read separate from list(), whose richer work belongs to MODS. +function LauncherMods.installedVersions() + local out = {} + local ok, manifests = pcall(discover) + if not ok then return out end + for _, manifest in ipairs(manifests or {}) do + out[manifest.id] = manifest.version or true + end + return out +end + -- list([version]) -> the mods-panel rows for the current install. Reads the -- same enable-state the loader persists, so a toggle here is what the game -- sees on its next boot; `version` narrows that to one game's answers. diff --git a/src/ui/gen2/PcMenu.lua b/src/ui/gen2/PcMenu.lua index cf365eca..a7fdd744 100644 --- a/src/ui/gen2/PcMenu.lua +++ b/src/ui/gen2/PcMenu.lua @@ -38,9 +38,12 @@ local MON_HOLDING_MAIL = { Strings.source("Please remove the\nMAIL."), } --- _ChangeBoxSaveText (data/text/common_2.asm:1306) is three lines whose `cont` --- has already scrolled by the time YesNoBox goes up over its last two. -local CHANGE_BOX_SAVE = { "#MON BOX, data", "will be saved. OK?" } +-- _ChangeBoxSaveText (data/text/common_2.asm:1306) is three lines whose first +-- `cont` ("When you change a") has already scrolled by the time YesNoBox goes +-- up over its last two -- confirmed against poke-corpus GoldSilver +-- en_msg.txt:4897. One \n-joined translatable key, same pattern as +-- SaveMenu.lua's OVERWRITE_PROMPT_SOURCE/SAVING_PROMPT_SOURCE. +local CHANGE_BOX_SAVE_SOURCE = Strings.source("#MON BOX, data\nwill be saved. OK?") -- YesNoBox's own `lb bc, SCREEN_WIDTH - 6, 7` (home/menu.asm:382-383). local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 14, 7, 6, 5 @@ -212,16 +215,20 @@ function PcMenu:writeChangeBox() end function PcMenu:savePrompt() - if self.savePhase == "overwrite" then return SaveMenu.OVERWRITE_PROMPT end - if self.savePhase == "saving" then return SaveMenu.SAVING_PROMPT end + if self.savePhase == "overwrite" then + return SaveMenu.twoLines(Strings(SaveMenu.OVERWRITE_PROMPT_SOURCE)) + end + if self.savePhase == "saving" then + return SaveMenu.twoLines(Strings(SaveMenu.SAVING_PROMPT_SOURCE)) + end if self.savePhase == "done" then if self.saved then local name = (self.save.player and self.save.player.name) or "GOLD" - return { name .. " saved", "the game." } + return SaveMenu.twoLines(Strings("%s saved\nthe game.", name)) end - return { "Could not save.", "" } + return SaveMenu.twoLines(Strings("Could not save.")) end - return CHANGE_BOX_SAVE + return SaveMenu.twoLines(Strings(CHANGE_BOX_SAVE_SOURCE)) end function PcMenu:updateChangeBox() @@ -404,8 +411,8 @@ function PcMenu:drawPanel() Chrome.print(lines[2] or "", 1, 16) if self.savePhase == "confirm" or self.savePhase == "overwrite" then Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H) - Chrome.print("YES", YESNO_X + 2, YESNO_Y + 1) - Chrome.print("NO", YESNO_X + 2, YESNO_Y + 3) + Chrome.print(Strings("YES"), YESNO_X + 2, YESNO_Y + 1) + Chrome.print(Strings("NO"), YESNO_X + 2, YESNO_Y + 3) Chrome.cursor(YESNO_X + 1, YESNO_Y + (self.saveChoice == 1 and 1 or 3)) end diff --git a/src/ui/gen2/SaveMenu.lua b/src/ui/gen2/SaveMenu.lua index d3e0eacc..bbcfd70e 100644 --- a/src/ui/gen2/SaveMenu.lua +++ b/src/ui/gen2/SaveMenu.lua @@ -57,27 +57,21 @@ local TIME_X, TIME_Y = 13, 8 local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 0, 7, 6, 5 -- AlreadyASaveFileText (AskOverwriteSaveFile, engine/menus/save.asm:47) and --- SavingDontTurnOffThePower's own line, shared with the PC's CHANGE BOX save --- (src/ui/gen2/PcMenu.lua:savePrompt() reads these two tables' lines[1]/ --- lines[2] directly, so their shape is a cross-file contract: keep them --- plain, untranslated tables). -local OVERWRITE_PROMPT = { "There is already a", "save file. Is it" } -local SAVING_PROMPT = { "SAVING… DON'T TURN", "OFF THE POWER." } - --- Translatable copies of the two prompts above, one \n-joined key each, used --- only by this screen's own prompt() below. One key per prompt lets a --- translation write one whole, freely reordered sentence instead of two --- fragments translated in isolation, and lets a cart whose own text is a --- single line (German's SAVING prompt) say so directly by simply omitting --- the "\n" -- the per-line override style used elsewhere requires a --- non-empty value for every line, so it can't express "this line is blank". +-- SavingDontTurnOffThePower's own line -- one \n-joined translatable key +-- each, used both by this screen's own prompt() below and, through the +-- SOURCE/twoLines() exports at the bottom of this file, by the PC's CHANGE +-- BOX save (src/ui/gen2/PcMenu.lua:savePrompt()), which shares these exact +-- same two cart messages. One key per prompt lets a translation write one +-- whole, freely reordered sentence instead of two fragments translated in +-- isolation, and lets a cart whose own text is a single line (German's +-- SAVING prompt) say so directly by simply omitting the "\n" -- the +-- per-line override style used elsewhere requires a non-empty value for +-- every line, so it can't express "this line is blank". -- --- Written as literals, not `table.concat(OVERWRITE_PROMPT, "\n")`: the --- translation tooling's string harvester only recognizes a literal inside --- Strings.source(...), not a computed expression, so a concat call here --- would quietly never reach a translator. Keep byte-for-byte in sync with --- OVERWRITE_PROMPT/SAVING_PROMPT above (checked by --- tests/engine/gen2_save_menu_translation_test.lua). +-- Written as a literal, not built from a table: the translation tooling's +-- string harvester only recognizes a literal inside Strings.source(...), +-- not a computed expression, so a concat call here would quietly never +-- reach a translator. local OVERWRITE_PROMPT_SOURCE = Strings.source("There is already a\nsave file. Is it") local SAVING_PROMPT_SOURCE = Strings.source("SAVING… DON'T TURN\nOFF THE POWER.") @@ -279,7 +273,8 @@ end SaveMenu.SFX_SAVE = SFX_SAVE SaveMenu.SAVING_FRAMES = SAVING_FRAMES SaveMenu.SAVED_FRAMES = SAVED_FRAMES -SaveMenu.OVERWRITE_PROMPT = OVERWRITE_PROMPT -SaveMenu.SAVING_PROMPT = SAVING_PROMPT +SaveMenu.OVERWRITE_PROMPT_SOURCE = OVERWRITE_PROMPT_SOURCE +SaveMenu.SAVING_PROMPT_SOURCE = SAVING_PROMPT_SOURCE +SaveMenu.twoLines = twoLines return SaveMenu diff --git a/tests/crystal_import_test.lua b/tests/crystal_import_test.lua index 00d82379..5d4dd3e7 100644 --- a/tests/crystal_import_test.lua +++ b/tests/crystal_import_test.lua @@ -37,6 +37,36 @@ eq(crystal.romSha1, GameVersion.VERSIONS.crystal.sha1, eq(crystal.generation, 2, "generation 2") eq(crystal.format, gold.format, "same manifest format as Gold") +-- ------- 1b. the 1.1 revision's symbol delta + +local rev11 +for _, revision in ipairs(GameVersion.revisions("crystal")) do + if revision.label == "1.1" then rev11 = revision end +end +check(rev11 ~= nil, "the crystal row names a 1.1 revision") + +if rev11 then + check(GameVersion.acceptsSha1("crystal", GameVersion.VERSIONS.crystal.sha1), + "the crystal row accepts the canonical 1.0 sha1") + check(GameVersion.acceptsSha1("crystal", rev11.sha1), + "and the 1.1 sha1 too") + + local overlay = (crystal.symbolRevisions or {})[rev11.sha1] + check(type(overlay) == "table", + "the manifest carries a symbolRevisions overlay for the 1.1 sha1") + if overlay then + eq(size(overlay), 1, "with exactly one symbol moved") + local moved = overlay.Stadium2N64Attrmap + check(type(moved) == "table", "and it names Stadium2N64Attrmap") + local base = crystal.symbols.Stadium2N64Attrmap + check(base ~= nil, "the base manifest still carries Stadium2N64Attrmap") + if moved and base then + check(not (moved[1] == base[1] and moved[2] == base[2]), + "at a location different from the base symbols entry") + end + end +end + -- ------- 2. content counts eq(size(crystal.maps), 388, "388 maps") diff --git a/tests/engine/crystal_version_test.lua b/tests/engine/crystal_version_test.lua index cddcacd2..8b2c8937 100644 --- a/tests/engine/crystal_version_test.lua +++ b/tests/engine/crystal_version_test.lua @@ -68,11 +68,56 @@ eq(GameVersion.ORDER[5], "silver", "silver keeps slot 5") -- ------- 4. sha1 routing eq(GameVersion.forSha1("f4cd194bdee0d04ca4eac29e09b8e4e9d818c133"), "crystal", - "the retail Crystal sha1 resolves to crystal") + "the retail Crystal 1.0 sha1 resolves to crystal") +eq(GameVersion.forSha1("f2f52230b536214ef7c9924f483392993e226cfb"), "crystal", + "the retail Crystal 1.1 sha1 also resolves to crystal") eq(GameVersion.forSha1("d8b8a3600a465308c9953dfa04f0081c05bdcb94"), "gold", "Gold's sha1 still resolves to gold") eq(GameVersion.forSha1("deadbeef"), nil, "an unknown ROM resolves to nothing") +-- ------- 4b. revisions / acceptsSha1 / revisionLabel + +local crystalRevisions = GameVersion.revisions("crystal") +eq(#crystalRevisions, 2, "crystal lists both accepted revisions") +eq(crystalRevisions[1].sha1, "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133", + "revision 1 is the 1.0 sha1") +eq(crystalRevisions[1].label, "1.0", "revision 1 is labeled 1.0") +eq(crystalRevisions[2].sha1, "f2f52230b536214ef7c9924f483392993e226cfb", + "revision 2 is the 1.1 sha1") +eq(crystalRevisions[2].label, "1.1", "revision 2 is labeled 1.1") + +check(GameVersion.acceptsSha1("crystal", "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133"), + "crystal accepts the 1.0 sha1") +check(GameVersion.acceptsSha1("crystal", "f2f52230b536214ef7c9924f483392993e226cfb"), + "crystal accepts the 1.1 sha1") +check(not GameVersion.acceptsSha1("crystal", "deadbeef"), + "crystal rejects an unknown sha1") + +eq(GameVersion.revisionLabel("crystal", "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133"), + "1.0", "revisionLabel resolves the 1.0 hash") +eq(GameVersion.revisionLabel("crystal", "f2f52230b536214ef7c9924f483392993e226cfb"), + "1.1", "revisionLabel resolves the 1.1 hash") +eq(GameVersion.revisionLabel("crystal", "deadbeef"), nil, + "revisionLabel is nil for an unrecognized hash") + +local goldRevisions = GameVersion.revisions("gold") +eq(#goldRevisions, 1, "gold synthesizes a single revision entry") +eq(goldRevisions[1].sha1, GameVersion.info("gold").sha1, + "the synthesized entry carries gold's canonical sha1") +check(GameVersion.acceptsSha1("gold", GameVersion.info("gold").sha1), + "gold accepts its own canonical sha1") +check(not GameVersion.acceptsSha1("gold", "deadbeef"), + "gold rejects an unknown sha1") +eq(GameVersion.revisionLabel("gold", GameVersion.info("gold").sha1), nil, + "gold's synthesized entry carries no label") + +local redRevisions = GameVersion.revisions("red") +eq(#redRevisions, 1, "red synthesizes a single revision entry") +check(GameVersion.acceptsSha1("red", GameVersion.info("red").sha1), + "red accepts its own canonical sha1") +eq(GameVersion.forSha1(GameVersion.info("red").sha1), "red", + "and forSha1 still resolves red through the synthesized entry") + -- ------- 5. set / get round trip local savedCurrent = GameVersion.get() diff --git a/tests/engine/gen2_pcmenu_changebox_save_translation_test.lua b/tests/engine/gen2_pcmenu_changebox_save_translation_test.lua new file mode 100644 index 00000000..ef58dba7 --- /dev/null +++ b/tests/engine/gen2_pcmenu_changebox_save_translation_test.lua @@ -0,0 +1,159 @@ +-- The PC's CHANGE BOX save flow (src/ui/gen2/PcMenu.lua:savePrompt()) used to +-- draw its overwrite/saving/done prompts and its YES/NO choice as bare +-- literals, invisible to a translation mod's `strings` registry, even though +-- the overwrite/saving prompts are the exact same two cart messages Gold's +-- SAVE screen (src/ui/gen2/SaveMenu.lua) already routes through Strings(). +-- Same technique as tests/engine/gen2_save_menu_translation_test.lua: drives +-- PcMenu:drawPanel() directly at each save phase with a mod-loaded Strings +-- catalog and checks the translated text reaches Font.draw. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local drawn +package.loaded["src.render.Font"] = { + draw = function(text, x, y) + drawn[#drawn + 1] = { text = text, x = x, y = y } + end, + drawCode = function() end, + drawBox = function() end, + width = function() return 0 end, +} + +local PcMenu = require("src.ui.gen2.PcMenu") +local Strings = require("src.core.Strings") + +local function drawnAt(x, y) + for _, d in ipairs(drawn) do + if d.x == x and d.y == y then return d.text end + end + return nil +end + +-- Chrome.print multiplies tile coordinates by 8 (src/ui/gen2/Chrome.lua). +-- The save-prompt box sits at the same (0,12) origin SaveMenu.lua's does, so +-- its two lines print at the same (1,14)/(1,16); PcMenu's own YESNO_X/Y +-- (14,7) differ from SaveMenu's (0,7), so YES/NO print at (16,8)/(16,10). +local PROMPT1_X, PROMPT1_Y = 1 * 8, 14 * 8 +local PROMPT2_X, PROMPT2_Y = 1 * 8, 16 * 8 +local YES_X, YES_Y = 16 * 8, 8 * 8 +local NO_X, NO_Y = 16 * 8, 10 * 8 + +-- One party mon so Boxes.canUsePc doesn't refuse to open the PC at all. +local SAVE = { player = { name = "GOLD" }, party = { {} } } + +local function newMenu() + return PcMenu.new({}, { + save = SAVE, + saveExists = false, + writer = function() return true end, + }) +end + +-- ---------------------------------------------- vanilla: no mod catalog +do + local menu = newMenu() + menu.picking = true + menu.pickIndex = 1 + menu.savePhase = "confirm" + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "#MON BOX, data", "the confirm prompt draws in English with no mod loaded") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "will be saved. OK?", "and its second line") + T.eq(drawnAt(YES_X, YES_Y), "YES", "and YES") + T.eq(drawnAt(NO_X, NO_Y), "NO", "and NO") + + menu.savePhase = "overwrite" + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "There is already a", + "the overwrite prompt, the same cart message SaveMenu.lua's SAVE screen shares") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "save file. Is it", "its second line") + + menu.savePhase = "saving" + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "SAVING… DON'T TURN", "the saving message") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "OFF THE POWER.", "its second line") + + menu.savePhase, menu.saved = "done", true + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "GOLD saved", "the saved message") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "the game.", "its second line") + + menu.savePhase, menu.saved = "done", false + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Could not save.", "the failed-save message") +end + +-- ------------------------------------------------- a translation mod's turn +-- +-- Same catalog values as gen2_save_menu_translation_test.lua's own +-- translated block: the overwrite/saving prompts, the saved/failed messages, +-- and YES/NO are the exact same keys both screens read, so one translation +-- covers both without a PcMenu-specific fork. Only the CHANGE BOX confirm +-- prompt's key is new here. +do + Strings.load({ + strings = { + ["YES"] = "OUI", + ["NO"] = "NON", + ["#MON BOX, data\nwill be saved. OK?"] = "Les donnees de la\nBOITE seront sauv.", + ["There is already a\nsave file. Is it"] = "Un fichier existe\ndeja. Est-ce", + ["SAVING… DON'T TURN\nOFF THE POWER."] = "SAUVEGARDE...\nN'ETEIGNEZ PAS.", + ["%s saved\nthe game."] = "%s a sauvegarde\nla partie.", + ["Could not save."] = "Echec de sauvegarde.", + }, + }) + + local menu = newMenu() + menu.picking = true + menu.pickIndex = 1 + menu.savePhase = "confirm" + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Les donnees de la", "the confirm prompt") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "BOITE seront sauv.", "its second line") + T.eq(drawnAt(YES_X, YES_Y), "OUI", "and YES") + T.eq(drawnAt(NO_X, NO_Y), "NON", "and NO") + + menu.savePhase = "overwrite" + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Un fichier existe", + "the overwrite prompt, translated with no PcMenu-specific key") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "deja. Est-ce", "its second line") + + menu.savePhase = "saving" + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "SAUVEGARDE...", "the saving message") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "N'ETEIGNEZ PAS.", "its second line") + + menu.savePhase, menu.saved = "done", true + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "GOLD a sauvegarde", + "the saved message folds the player name into the mod's own word order") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "la partie.", "its second line") + + menu.savePhase, menu.saved = "done", false + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Echec de sauvegarde.", "the failed-save message") + + -- Module state is process-global (see tests/gen2_clock_test.lua's own + -- note); this suite gets its own process from tests/tier_runner.lua, but + -- leaving the catalog loaded past this point would still mistranslate + -- every check below it in this file. + Strings.load({}) + T.check(not Strings.active(), "the catalog is unloaded for the checks after this one") +end + +T.finish("gen2_pcmenu_changebox_save_translation_test") diff --git a/tests/engine/gen2_save_menu_translation_test.lua b/tests/engine/gen2_save_menu_translation_test.lua index c560d0bf..91dad6af 100644 --- a/tests/engine/gen2_save_menu_translation_test.lua +++ b/tests/engine/gen2_save_menu_translation_test.lua @@ -147,21 +147,26 @@ do T.check(not Strings.active(), "the catalog is unloaded for the checks after this one") end --- src/ui/gen2/PcMenu.lua:savePrompt() returns SaveMenu.OVERWRITE_PROMPT/ --- SAVING_PROMPT straight through to its own `lines[1]`/`lines[2]` --- Chrome.print calls (the PC's CHANGE BOX save uses the same two prompts). --- Indexing a plain string with [1]/[2] returns nil, not characters, so this --- shape is a cross-file contract: it caught a real regression during review, --- where routing these through a single Strings.source()-wrapped string (to --- translate SaveMenu's own screen) silently turned them into non-table --- values and left PcMenu's overwrite/saving prompt blank. +-- src/ui/gen2/PcMenu.lua:savePrompt() shares SaveMenu's overwrite/saving +-- prompts through SaveMenu.OVERWRITE_PROMPT_SOURCE/SAVING_PROMPT_SOURCE and +-- SaveMenu.twoLines(), rather than duplicating them -- both exported below, +-- both used by PcMenu's own translation test +-- (tests/engine/gen2_pcmenu_changebox_save_translation_test.lua). Checked +-- here that they stay callable the shape twoLines() expects: a table in, +-- one \n-joined string out with the split back on load. do - T.eq(type(SaveMenu.OVERWRITE_PROMPT), "table", "OVERWRITE_PROMPT stays a table for PcMenu.lua") - T.eq(SaveMenu.OVERWRITE_PROMPT[1], "There is already a", "and its first line stays indexable") - T.eq(SaveMenu.OVERWRITE_PROMPT[2], "save file. Is it", "and its second line") - T.eq(type(SaveMenu.SAVING_PROMPT), "table", "SAVING_PROMPT stays a table for PcMenu.lua") - T.eq(SaveMenu.SAVING_PROMPT[1], "SAVING… DON'T TURN", "and its first line stays indexable") - T.eq(SaveMenu.SAVING_PROMPT[2], "OFF THE POWER.", "and its second line") + T.eq(SaveMenu.OVERWRITE_PROMPT_SOURCE, "There is already a\nsave file. Is it", + "OVERWRITE_PROMPT_SOURCE stays the cart's own \\n-joined text") + T.eq(SaveMenu.twoLines(Strings(SaveMenu.OVERWRITE_PROMPT_SOURCE))[1], "There is already a", + "and twoLines() splits its untranslated fallback back to the first line") + T.eq(SaveMenu.twoLines(Strings(SaveMenu.OVERWRITE_PROMPT_SOURCE))[2], "save file. Is it", + "and its second line") + T.eq(SaveMenu.SAVING_PROMPT_SOURCE, "SAVING… DON'T TURN\nOFF THE POWER.", + "SAVING_PROMPT_SOURCE stays the cart's own \\n-joined text") + T.eq(SaveMenu.twoLines(Strings(SaveMenu.SAVING_PROMPT_SOURCE))[1], "SAVING… DON'T TURN", + "and twoLines() splits its untranslated fallback back to the first line") + T.eq(SaveMenu.twoLines(Strings(SaveMenu.SAVING_PROMPT_SOURCE))[2], "OFF THE POWER.", + "and its second line") end -- A translation with a THIRD line (a second embedded "\n") has nowhere on diff --git a/tests/engine/launcher_mod_downloads.lua b/tests/engine/launcher_mod_downloads.lua index 7792f59f..90275de7 100644 --- a/tests/engine/launcher_mod_downloads.lua +++ b/tests/engine/launcher_mod_downloads.lua @@ -75,7 +75,8 @@ end do local ri = launcher() check(ri:_findStats(entry("nulled", nil, "someone/nulled")) == nil, - "a null count is not an answer; the repo is still consulted") + "a null count is not an answer") + ri:_requestFindStats(entry("nulled", nil, "someone/nulled")) eq(#fetched, 1, "which is the fetch the panel already made for dates") local bare = launcher() @@ -83,7 +84,7 @@ do check(stats ~= nil and stats.total == nil, "a listing with neither counts nor a repo is resolved-but-unknown") check(stats.recent == nil, "and has nothing to trend on") - eq(#fetched, 1, "and queues nothing of its own") + eq(#fetched, 1, "and the explicit scheduler queues nothing without a repo") end -- A real zero is not unknown: the index has seen the releases and counted diff --git a/tests/engine/launcher_navigation_perf.lua b/tests/engine/launcher_navigation_perf.lua new file mode 100644 index 00000000..d394bb69 --- /dev/null +++ b/tests/engine/launcher_navigation_perf.lua @@ -0,0 +1,88 @@ +-- Launcher navigation performance seams. MOD INDEX must not pay the full +-- MODS validation pass, background index prefetch must not create a blocking +-- overlay, and visible-row enrichment must be scheduled from update state +-- rather than from immediate-mode draw calls. +-- luajit tests/engine/launcher_navigation_perf.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, eq = T.check, T.eq +local LauncherMods = require("src.mods.LauncherMods") +local ModIndex = require("src.mods.ModIndex") +local RomImporter = require("src.import.RomImporter") + +check(type(LauncherMods.installedVersions) == "function", + "LauncherMods exposes a lightweight installed-version scan") + +do + local old = LauncherMods.installedVersions + local called = 0 + LauncherMods.installedVersions = function() + called = called + 1 + return { alpha = "1.2.3" } + end + local imp = setmetatable({}, RomImporter) + local installed = imp:_findInstalledMap() + eq(called, 1, "MOD INDEX asks for the lightweight scan when MODS is cold") + eq(installed.alpha, "1.2.3", + "the lightweight scan supplies installed versions") + LauncherMods.installedVersions = old +end + +do + local imp = setmetatable({ tab = "find", findLoaded = false }, RomImporter) + imp._refreshFindSources = function(self) + self.findSources = { { feed = "https://example.invalid/index.json" } } + end + local oldBegin = ModIndex.beginFetch + ModIndex.beginFetch = function() return { test = true } end + imp:_refreshFind(false) + check(imp._findFetch ~= nil, "background index refresh starts asynchronously") + eq(imp._busy, nil, + "background index refresh does not block navigation with a loader") + imp:_clearBusy() + imp._findFetch = nil + ModIndex.beginFetch = oldBegin +end + +do + local requests = 0 + local imp = setmetatable({ tab = "find", findLoaded = true, + _findVisibleEntries = { + { id = "one", thumbnail = "one.png", github = "a/one" }, + { id = "two", thumbnail = "two.png", github = "a/two" }, + { id = "three", thumbnail = "three.png", github = "a/three" }, + } }, RomImporter) + imp._findThumb = function() return nil end + imp._findThumbPending = function() return false end + imp._findStatsCached = function() return nil end + imp._startFindThumb = function() requests = requests + 1 end + imp._requestFindStats = function() requests = requests + 1 end + imp:_queueFindEnrichment() + eq(requests, 4, + "update schedules a bounded thumbnail and stats batch for visible rows") +end + +do + local requests = 0 + local imp = setmetatable({}, RomImporter) + imp._findStatsCached = function() return nil end + imp._requestFindStats = function() requests = requests + 1 end + imp:_findStats({ id = "draw-only", github = "a/draw-only" }) + eq(requests, 0, "reading row stats during draw never starts a request") +end + +do + local f = assert(io.open("src/import/LauncherView.lua", "rb")) + local src = f:read("*a") + f:close() + local start = assert(src:find("local function buildFindPanel", 1, true)) + local finish = assert(src:find("\nlocal function ", start + 1, true)) + local panel = src:sub(start, finish - 1) + check(not panel:find("imp:_ensureMods()", 1, true), + "MOD INDEX panel does not force the full MODS list") +end + +T.finish("launcher_navigation_perf") diff --git a/tests/engine/rom_cache_contract_test.lua b/tests/engine/rom_cache_contract_test.lua index f5f5102d..b8960b02 100644 --- a/tests/engine/rom_cache_contract_test.lua +++ b/tests/engine/rom_cache_contract_test.lua @@ -110,6 +110,56 @@ check(not silverSet["assets/generated/trade/game_boy.png"], check(CacheContract.VERSION_REQUIRED_FILES.yellow ~= nil, "Yellow has version-specific required outputs") +-- Revisioned cache markers: Crystal accepts either the 1.0 or the 1.1 cart. +local CRYSTAL_1_0 = "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133" +local CRYSTAL_1_1 = "f2f52230b536214ef7c9924f483392993e226cfb" + +eq(CacheContract.markerFor("crystal", CRYSTAL_1_1), + CacheContract.FORMAT .. CRYSTAL_1_1, + "markerFor with an explicit sha1 uses that sha1, not the canonical one") +eq(CacheContract.markerFor("crystal"), CacheContract.FORMAT .. CRYSTAL_1_0, + "markerFor with no sha1 still defaults to the canonical (1.0) sha1") + +check(CacheContract.markerMatches("crystal", CacheContract.FORMAT .. CRYSTAL_1_0), + "a marker written from the 1.0 hash matches crystal") +check(CacheContract.markerMatches("crystal", CacheContract.FORMAT .. CRYSTAL_1_1), + "a marker written from the 1.1 hash also matches crystal") +check(not CacheContract.markerMatches("crystal", + CacheContract.FORMAT .. "ea9bcae617fdf159b045185467ae58b2e4a48b9a"), + "a marker written from Red's hash does not match crystal") +check(not CacheContract.markerMatches("red", CacheContract.FORMAT .. CRYSTAL_1_1), + "a marker written from Crystal's 1.1 hash does not match red") + +local crystalFs = { prefix = "crystal-marker-test/", files = {} } +function crystalFs.exists(path) return crystalFs.files[crystalFs.prefix .. path] ~= nil end +function crystalFs.read(path) return crystalFs.files[crystalFs.prefix .. path] end +function crystalFs.write(path, value) + crystalFs.files[crystalFs.prefix .. path] = value + return true +end +function crystalFs.remove(path) crystalFs.files[crystalFs.prefix .. path] = nil end + +local crystalRequired = CacheContract.requiredFilesFor("crystal") +for _, path in ipairs(crystalRequired) do + crystalFs.files["crystal/" .. path] = true +end +local published11 = CacheContract.publish("crystal", crystalFs, CRYSTAL_1_1) +check(published11, "publishing with the 1.1 sha1 succeeds") +eq(crystalFs.files["crystal/" .. CacheContract.MARKER_PATH], + CacheContract.FORMAT .. CRYSTAL_1_1, "the marker records the 1.1 sha1") +check(CacheContract.isReady("crystal", crystalFs), + "a cache published with the 1.1 sha1 still reads ready for crystal") + +crystalFs.files["crystal/" .. CacheContract.MARKER_PATH] = + CacheContract.FORMAT .. "ea9bcae617fdf159b045185467ae58b2e4a48b9a" +check(not CacheContract.isReady("crystal", crystalFs), + "a marker from another version's hash does not read ready for crystal") + +check(CacheContract.markerMatches("blue", CacheContract.markerFor("blue")), + "blue's own marker still matches blue") +check(not CacheContract.markerMatches("blue", CacheContract.markerFor("red")), + "red's marker still does not match blue") + -- A throwing adapter must not strand the process in its temporary prefix. local throwingFs = { prefix = "before/" } function throwingFs.exists() error("probe failed") end diff --git a/tests/gen2_crystal_mobile_gfx_test.lua b/tests/gen2_crystal_mobile_gfx_test.lua index f4cf94d3..b0659512 100644 --- a/tests/gen2_crystal_mobile_gfx_test.lua +++ b/tests/gen2_crystal_mobile_gfx_test.lua @@ -194,6 +194,21 @@ if not cache then cache = home .. "/Library/Application Support/LOVE/crystal-dev/crystal" end +local GameVersion = require("src.core.GameVersion") +local CacheContract = require("src.import.CacheContract") + +local function crystalCacheRevision() + local fs = { prefix = "", read = function(rel) return readFile(cache .. "/" .. rel) end } + local marker = CacheContract.readMarker("crystal", fs) + if not marker then return "1.0" end + for _, revision in ipairs(GameVersion.revisions("crystal")) do + if marker == CacheContract.markerFor("crystal", revision.sha1) then + return revision.label or "1.0" + end + end + return "1.0" +end + local function loadCache(rel) local chunk = loadfile(cache .. "/data/generated/" .. rel .. ".lua") if not chunk then return nil end @@ -338,7 +353,7 @@ local MAPS = { { "centerAttrmap", "mobile_center_attrmap", 360, "gfx/mobile/mobile_center.attrmap" }, { "stadium2N64Tilemap", "stadium2_n64_tilemap", 360, - "gfx/mobile/stadium2_n64.tilemap" }, + "revision" }, { "stadium2N64Attrmap", "stadium2_n64_attrmap", 360, "gfx/mobile/stadium2_n64.attrmap" }, { "dialpadTilemap", "dialpad_tilemap", 360, "gfx/mobile/dialpad.tilemap" }, @@ -408,6 +423,14 @@ else for _, row in ipairs(MAPS) do local key, file, bytes, source = row[1], row[2], row[3], row[4] + local revisionLabel + if key == "stadium2N64Tilemap" then + -- ../pokecrystal/mobile/mobile_5c.asm:869 + revisionLabel = crystalCacheRevision() + source = revisionLabel == "1.1" + and "gfx/mobile/stadium2_n64_corrupt.tilemap" + or "gfx/mobile/stadium2_n64.tilemap" + end local entry = maps[key] local rel = "assets/generated/mobile/" .. file .. ".bin" if type(entry) ~= "table" then @@ -425,7 +448,10 @@ else check(true, "no ../pokecrystal: " .. source .. " not diffed (SKIP)") else check(blob == want:sub(1, bytes), - "it is ../pokecrystal/" .. source .. " byte for byte") + "it is ../pokecrystal/" .. source .. " byte for byte" + .. (revisionLabel + and (" (cache built from Crystal " .. revisionLabel .. ")") + or "")) end end end diff --git a/tools/cartkit.py b/tools/cartkit.py index 0e1db64a..af6cb1a7 100644 --- a/tools/cartkit.py +++ b/tools/cartkit.py @@ -1582,9 +1582,17 @@ def t_png(): assert label_art("#123456") == art +class SelftestSkip(Exception): + pass + + def t_roundtrip(): root = tempfile.mkdtemp(prefix="cartkit-selftest-") - repo = find_repo(os.path.dirname(os.path.abspath(__file__))) + repo = find_repo(os.getcwd()) or find_repo( + os.path.dirname(os.path.abspath(__file__))) + if not repo: + raise SelftestSkip("scaffold needs an engine checkout; none found " + "from the cwd or the script") try: with contextlib.redirect_stdout(io.StringIO()): _roundtrip(root, repo) @@ -1650,21 +1658,26 @@ CHECKS = [ def cmd_selftest(args, repo): failures = [] + skipped = [] for name, check in CHECKS: try: check() + except SelftestSkip as why: + skipped.append((name, why)) + print(f"skip {name}: {why}") except Exception as problem: failures.append((name, problem)) - if not args.quiet: - print(f"FAIL {name}: {problem!r}") + print(f"FAIL {name}: {problem!r}") else: if not args.quiet: print(f"ok {name}") if failures: print(f"FAIL {len(failures)} of {len(CHECKS)} checks") return 1 + ran = len(CHECKS) - len(skipped) + tail = f" ({len(skipped)} skipped)" if skipped else "" if not args.quiet: - print(f"ok {len(CHECKS)} checks") + print(f"ok {ran} checks{tail}") return 0 diff --git a/tools/make_crystal_manifest.py b/tools/make_crystal_manifest.py index 033686a6..f79678cb 100644 --- a/tools/make_crystal_manifest.py +++ b/tools/make_crystal_manifest.py @@ -31,10 +31,19 @@ where `charmap` and `fontCharmap` already live, so a consumer picks the map it wants by name and no reader of the main table can see these bytes. The `ascii` map is not emitted, because nothing outside the Mobile System reads it. +The manifest also carries `symbolRevisions`, a map of ROM sha1 -> symbol name +-> [bank, address] for the retail revisions this port accepts besides the 1.0 +hash in `romSha1`. Crystal has one, v1.1, where `Stadium2N64Attrmap` sits 13 +bytes later than on 1.0 because the Stadium 2 tilemap in front of it is longer +(../pokecrystal/mobile/mobile_5c.asm:869). `crystal11_symbol_revisions` diffs +the v1.1 symbol table against the resolved 1.0 `symbols` and keeps only the +names that appear in both and moved. + Usage: python3 tools/make_crystal_manifest.py Default paths: pokecrystal at ../pokecrystal (relative to the repo) or /Users/bryanbassett/Documents/development/pokecrystal; symbols at -/Users/bryanbassett/Documents/development/pokecrystal-symbols/pokecrystal.sym. +/Users/bryanbassett/Documents/development/pokecrystal-symbols/pokecrystal.sym +and /Users/bryanbassett/Documents/development/pokecrystal-symbols/pokecrystal11.sym. """ from __future__ import annotations @@ -49,7 +58,9 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import make_gold_manifest as gold # noqa: E402 from crystal_symbol_deltas import crystal_required # noqa: E402 -from rom_data import CANONICAL_CRYSTAL_SHA1 # noqa: E402 +from rom_data import ( # noqa: E402 + CANONICAL_CRYSTAL_SHA1, CANONICAL_CRYSTAL11_SHA1, SymbolTable, +) REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) DEV = "/Users/bryanbassett/Documents/development" @@ -58,6 +69,8 @@ DEFAULT_POKECRYSTAL_CANDIDATES = [ os.path.join(DEV, "pokecrystal"), ] DEFAULT_SYMBOLS = os.path.join(DEV, "pokecrystal-symbols/pokecrystal.sym") +DEFAULT_SYMBOLS11 = os.path.join( + DEV, "pokecrystal-symbols/pokecrystal11.sym") DEFAULT_OUT = os.path.join( os.path.dirname(__file__), "rom_manifest_crystal.json") @@ -126,7 +139,31 @@ def unown_charmap(pokecrystal, defines=None): return out -def generate(pokecrystal, symbols_path): +def crystal11_symbol_revisions(symbols11_path, base_symbols): + """Diff the v1.1 symbol table against the manifest's resolved 1.0 symbols. + + Returns only the entries whose [bank, address] differs, restricted to + names the 1.0 manifest actually carries -- a v1.1-only symbol with no + 1.0 counterpart is nothing an extractor built against `symbols` could + ever look up, so it is not this table's business to report. + """ + if not os.path.isfile(symbols11_path): + raise SystemExit( + f"Crystal v1.1 symbol file not found: {symbols11_path} " + "(pass --symbols11 or install it at the default path)") + symbols11 = SymbolTable(symbols11_path) + revisions = {} + for name, location in base_symbols.items(): + symbol11 = symbols11.by_name.get(name) + if symbol11 is None: + continue + location11 = [symbol11.bank, symbol11.address] + if location11 != location: + revisions[name] = location11 + return revisions + + +def generate(pokecrystal, symbols_path, symbols11_path=DEFAULT_SYMBOLS11): data = gold.generate( pokecrystal, symbols_path, defines=CRYSTAL_ASM_DEFINES, @@ -139,6 +176,10 @@ def generate(pokecrystal, symbols_path): os.path.join(pokecrystal, "constants", "engine_flags.asm"), defines=CRYSTAL_ASM_DEFINES) data["unownCharmap"] = unown_charmap(pokecrystal, CRYSTAL_ASM_DEFINES) + data["symbolRevisions"] = { + CANONICAL_CRYSTAL11_SHA1: crystal11_symbol_revisions( + symbols11_path, data["symbols"]), + } return data @@ -153,13 +194,16 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--pokecrystal", default=find_pokecrystal()) parser.add_argument("--symbols", default=DEFAULT_SYMBOLS) + parser.add_argument("--symbols11", default=DEFAULT_SYMBOLS11) parser.add_argument("--out", default=DEFAULT_OUT) args = parser.parse_args() pokecrystal = os.path.abspath(args.pokecrystal) if not os.path.isfile(os.path.join(pokecrystal, "main.asm")): raise SystemExit(f"{pokecrystal} is not a pokecrystal checkout") - data = generate(pokecrystal, os.path.abspath(args.symbols)) + data = generate( + pokecrystal, os.path.abspath(args.symbols), + os.path.abspath(args.symbols11)) with open(args.out, "w", encoding="utf-8", newline="\n") as f: json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True) f.write("\n") diff --git a/tools/rom_data.py b/tools/rom_data.py index bfb3d084..43d12e82 100644 --- a/tools/rom_data.py +++ b/tools/rom_data.py @@ -14,9 +14,9 @@ CANONICAL_YELLOW_SHA1 = "cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1" # Gold and Silver are Gen 2: 2 MiB carts, twice the size of the Gen 1 ROMs above. CANONICAL_GOLD_SHA1 = "d8b8a3600a465308c9953dfa04f0081c05bdcb94" CANONICAL_SILVER_SHA1 = "49b163f7e57702bc939d642a18f591de55d92dae" -# Crystal is the retail international v1.0 build (pret/pokecrystal's default -# `make` target), also 2 MiB. +# Crystal is the retail international v1.0 cart, also 2 MiB. CANONICAL_CRYSTAL_SHA1 = "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133" +CANONICAL_CRYSTAL11_SHA1 = "f2f52230b536214ef7c9924f483392993e226cfb" ROM_BANK_SIZE = 0x4000 @@ -74,9 +74,16 @@ class RomImage: with open(path, "rb") as f: self.data = f.read() self.sha1 = hashlib.sha1(self.data).hexdigest() - if expected_sha1 and self.sha1 != expected_sha1: + if isinstance(expected_sha1, (tuple, set, frozenset, list)): + allowed = expected_sha1 + elif expected_sha1: + allowed = (expected_sha1,) + else: + allowed = None + if allowed and self.sha1 not in allowed: raise ValueError( - f"unsupported ROM SHA-1 {self.sha1}; expected {expected_sha1}") + f"unsupported ROM SHA-1 {self.sha1}; " + f"expected {' or '.join(allowed)}") @staticmethod def offset(bank, address): diff --git a/tools/rom_manifest_crystal.json b/tools/rom_manifest_crystal.json index 260b93f7..b4e7546c 100644 --- a/tools/rom_manifest_crystal.json +++ b/tools/rom_manifest_crystal.json @@ -14279,6 +14279,14 @@ } }, "romSha1": "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133", + "symbolRevisions": { + "f2f52230b536214ef7c9924f483392993e226cfb": { + "Stadium2N64Attrmap": [ + 92, + 29988 + ] + } + }, "symbols": { "AbraBackpic": [ 86,