mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 15:01:11 +02:00
Merge pull request #1776 from bryanthaboi/dev
chris1.1 and bonus additions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+78
-16
@@ -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,
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+17
-10
@@ -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
|
||||
|
||||
+17
-22
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+16
-3
@@ -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,12 +1658,15 @@ 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}")
|
||||
else:
|
||||
if not args.quiet:
|
||||
@@ -1663,8 +1674,10 @@ def cmd_selftest(args, repo):
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
+11
-4
@@ -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):
|
||||
|
||||
@@ -14279,6 +14279,14 @@
|
||||
}
|
||||
},
|
||||
"romSha1": "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133",
|
||||
"symbolRevisions": {
|
||||
"f2f52230b536214ef7c9924f483392993e226cfb": {
|
||||
"Stadium2N64Attrmap": [
|
||||
92,
|
||||
29988
|
||||
]
|
||||
}
|
||||
},
|
||||
"symbols": {
|
||||
"AbraBackpic": [
|
||||
86,
|
||||
|
||||
Reference in New Issue
Block a user