Compare commits

...

16 Commits

Author SHA1 Message Date
github-actions bc7b96c963 chore(ios): update app-repo.json [skip ci] 2026-08-24 13:18:39 -04:00
bryanthaboi aea38240b2 Merge pull request #1780 from bryanthaboi/dev
gender name string
2026-08-24 13:08:31 -04:00
bryanthaboi 0142468125 gender name string 2026-08-24 13:07:11 -04:00
github-actions e7d898625d chore(ios): update app-repo.json [skip ci] 2026-08-24 12:48:34 -04:00
bryanthaboi 5ee4a3cd35 Merge pull request #1778 from bryanthaboi/dev
some fixes
2026-08-24 12:38:35 -04:00
bryanthaboi 4794ddcfdf chris (and all gen2) fix 2026-08-24 12:35:35 -04:00
bryanthaboi 6503e982db potential fix for shaders 2026-08-24 12:10:15 -04:00
github-actions 72ce1fe33e chore(ios): update app-repo.json [skip ci] 2026-08-24 11:46:51 -04:00
bryanthaboi 493fca7297 Merge pull request #1776 from bryanthaboi/dev
chris1.1 and bonus additions
2026-08-24 11:36:23 -04:00
bryanthaboi 7d5856f93b Merge pull request #1774 from thibautbus/fix/translate-pcmenu-changebox-save
Translate the PC's CHANGE BOX save flow
2026-08-24 11:33:52 -04:00
bryanthaboi ef43814efc Merge pull request #1773 from ShaneMcGovernIE/codex/launcher-navigation-performance
perf(launcher): keep MODS and MOD INDEX navigation responsive
2026-08-24 11:33:43 -04:00
bryanthaboi bed1062a5f chris 1.1 hotfix 2026-08-24 11:32:42 -04:00
Shane McGovern 190c03cfca perf(launcher): keep mod index navigation responsive 2026-08-24 15:24:10 +01:00
github-actions cec264ad81 chore(ios): update app-repo.json [skip ci] 2026-08-24 10:23:37 -04:00
bryanthaboi 041051e154 cartkit: skip the scaffold round trip when there is no engine checkout
The generated cart release workflow curls cartkit.py into RUNNER_TEMP and
runs the selftest from the cart repo, where find_repo() finds nothing and
scaffold exits non-zero, so every cart release failed at the first step.

Report failures and skips even under --quiet; the workflow passes it, so
the only output was the FAIL count with no indication of which check.
2026-08-24 10:20:27 -04:00
thibautbus 0d14980205 Translate the PC's CHANGE BOX save flow (PcMenu.lua)
PcMenu.lua's own CHANGE BOX save confirmation stayed in English on every
non-English build: its overwrite/saving/done prompts and its YES/NO choice
were 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 (SaveMenu.lua) already routes through
Strings().

SaveMenu.lua now exports OVERWRITE_PROMPT_SOURCE, SAVING_PROMPT_SOURCE and
twoLines() so PcMenu:savePrompt() can call through them instead of
duplicating the two shared prompts. The now-unused plain
OVERWRITE_PROMPT/SAVING_PROMPT tables (PcMenu's old direct-read contract)
are gone along with them, removing a second copy of the same literal text
that had to be kept in sync by hand. The CHANGE BOX confirm prompt itself
("#MON BOX, data\nwill be saved. OK?") gets its own new Strings.source()
key -- it is not shared with SaveMenu, and the port's own English text is
already a same-shape truncation of the cart's three-line
_ChangeBoxSaveText, confirmed against poke-corpus GoldSilver
en_msg.txt:4897. The done message and YES/NO now go through the exact
same Strings() keys SaveMenu.lua already uses ("%s saved\nthe game.",
"Could not save.", "YES", "NO"), so a translation covering SaveMenu's
screen needs no PcMenu-specific fork for any of those.

Added tests/engine/gen2_pcmenu_changebox_save_translation_test.lua,
mirroring gen2_save_menu_translation_test.lua's technique for PcMenu's own
CHANGE BOX flow, and updated the stale cross-file-contract regression case
in gen2_save_menu_translation_test.lua to match the new shared-export
shape instead of the old plain-table one.
2026-08-24 15:55:08 +02:00
30 changed files with 804 additions and 112 deletions
+2 -1
View File
@@ -82,7 +82,8 @@ game data:
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1` - Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
- Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94` - Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94`
- Silver: `49b163f7e57702bc939d642a18f591de55d92dae` - 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, 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 sound effects, and cries are synthesized while the game runs from compact
+28
View File
@@ -12,6 +12,34 @@
"tintColor": "3b5ca8", "tintColor": "3b5ca8",
"category": "games", "category": "games",
"versions": [ "versions": [
{
"version": "0.2.24",
"date": "2026-08-24",
"size": 14060528,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.24/gen1recomp++-0.2.24-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.23",
"date": "2026-08-24",
"size": 14060437,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.23/gen1recomp++-0.2.23-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.22",
"date": "2026-08-24",
"size": 14060365,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.22/gen1recomp++-0.2.22-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi\n- @ShaneMcGovernIE\n- @thibautbus"
},
{
"version": "0.2.21",
"date": "2026-08-24",
"size": 14058979,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.21/gen1recomp++-0.2.21-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1483 (Pokémon Gold) Missing HM/TM fanfare when receiving them\n- #1610 Wrong text when choosing Pokemon to trade\n- #1615 Fishing Guru dialogue issue\n- #1640 Sync doesnt work on XBOX build\n- #1646 Cashier dialogue + sound effects\n- #1649 Pokemon cries missing in Vermilion City\n- #1651 Ticket taker asks for ticket too much\n- #1653 Wrap animation is too fast and has no screen shake\n- #1656 Map visible in between end of fight & evolution\n- #1683 Music not changing when talking to trainer\n- #1685 Cancel option missing in item menu\n- #1686 Issues with teaching TMs & HMs\n- #1687 Pokemon cries missing on S.S. Anne\n- #1688 S.S. Anne rival dialogue\n- #1689 S.S. Anne departing cutscene broken\n- #1690 Wrong dialogue when taking back entrance to museum\n- #1693 (Gold) Issues not properly fixed\n- #1694 (Gold) Arrow when seeing items in the bag should be red, not black\n- #1695 (Gold) Wrong layout in TMs/HMs pocket\n- #1696 (Gold) Caught pokémon don't get healed when automatically sent to the PC\n- #1697 Standardize identification of mod-created options/settings screens\n- #1702 Lt. Surge's door opening isn't synced with the sound effect\n- #1704 Ledge jumping on the bike doesn't slow you down\n- #1705 The bike shouldn't have Use/Toss options\n- #1706 (Gold) Cursor wraps around the edge in the battle interface\n- #1707 (Gold) Pokémon sometimes wake up in the same turn they were put to sleep\n- #1708 (Gold) Missing rod when fishing\n- #1710 (Gold) You shouldn't be able to change boxes by pressing LEFT or RIGHT\n- #1711 (Gold) Missing animation for FLY in the overworld\n- #1712 (Gold) Wrong cursor in the map when using FLY\n- #1713 (Gold) Low framerate when jumping off ledges\n- #1716 (Gold) Player can go through a whirlpool even without having the HM\n- #1717 (Gold) Missing animation and SFX when using whirlpool in the overworld\n- #1718 (Gold) Can't pause in the Cycling Road while moving down\n- #1719 (Gold) Pokémon given by NPCs not being registered in the pokédex\n- #1720 Critical hit on multi-hit moves repeats the message\n- #1721 Rival not visible while talking if you lose first fight\n- #1724 Centralize ROM cache readiness and publication in an engine-owned contract\n- #1725 (Gold) Text not spaced nor centered correctly for secondary actions in the bag\n- #1732 Cursor doesn't stay on last used item\n- #1745 Viewing items makes ingame menu disappear\n- #1748 (Gold) Overworld pokémon sprite are stactic\n- #1749 (Gold) You shouldn't be able to surf on Cycling Road\n- #1751 (Gold) Synthesis and Morning Sun always recover 50% of max HP\n- #1754 Leaving building with bike speed\n- #1756 No cursor or gamepad controls in skin menu, locked out on Xbox\n\n## Contributors\n\n- @1Jamie\n- @BoLayerDev\n- @bryanthaboi\n- @campavao\n- @caorthann-celt\n- @dlloa\n- @syybott\n- @thibautbus\n- MaxTomahawk"
},
{ {
"version": "0.2.20", "version": "0.2.20",
"date": "2026-08-22", "date": "2026-08-22",
+25 -2
View File
@@ -96,6 +96,10 @@ GameVersion.VERSIONS = {
saveSuffix = "_crystal", -- save_crystal.lua / .bak / .tmp saveSuffix = "_crystal", -- save_crystal.lua / .bak / .tmp
generation = 2, generation = 2,
engine = "crystal", engine = "crystal",
revisions = {
{ sha1 = "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133", label = "1.0" },
{ sha1 = "f2f52230b536214ef7c9924f483392993e226cfb", label = "1.1" },
},
fixes = { fixes = {
-- pokegold/docs/bugs_and_glitches.md:61 -- pokegold/docs/bugs_and_glitches.md:61
luckyNumberBoxes = true, luckyNumberBoxes = true,
@@ -168,10 +172,29 @@ function GameVersion.cachePrefix(id)
return GameVersion.info(id).cachePrefix return GameVersion.info(id).cachePrefix
end 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. -- The version a ROM belongs to, by its SHA-1, or nil for an unknown ROM.
function GameVersion.forSha1(sha1) function GameVersion.forSha1(sha1)
for id, info in pairs(GameVersion.VERSIONS) do for id in pairs(GameVersion.VERSIONS) do
if info.sha1 == sha1 then return id end if GameVersion.acceptsSha1(id, sha1) then return id end
end end
return nil return nil
end end
+20 -5
View File
@@ -9,6 +9,9 @@ local GameVersion = require("src.core.GameVersion")
local CacheContract = {} local CacheContract = {}
CacheContract.FORMAT = "rom-cache-v10:" CacheContract.FORMAT = "rom-cache-v10:"
CacheContract.VERSION_FORMAT = {
crystal = "rom-cache-v10-crystal2:",
}
CacheContract.MARKER_PATH = "rom-cache.complete" CacheContract.MARKER_PATH = "rom-cache.complete"
CacheContract.REQUIRED_FILES = { CacheContract.REQUIRED_FILES = {
@@ -136,8 +139,20 @@ function CacheContract.requiredFilesFor(version)
return CacheContract.REQUIRED_FILES, false return CacheContract.REQUIRED_FILES, false
end end
function CacheContract.markerFor(version) function CacheContract.formatFor(version)
return CacheContract.FORMAT .. GameVersion.info(version).sha1 return CacheContract.VERSION_FORMAT[version] or CacheContract.FORMAT
end
function CacheContract.markerFor(version, sha1)
return CacheContract.formatFor(version)
.. (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 end
-- Keep the process-global CacheFs prefix isolated even when a filesystem -- Keep the process-global CacheFs prefix isolated even when a filesystem
@@ -189,11 +204,11 @@ function CacheContract.isReady(version, fs)
fs = fs or require("src.import.CacheFs") fs = fs or require("src.import.CacheFs")
if CacheContract.sourceTreeHasData(version) then return true end if CacheContract.sourceTreeHasData(version) then return true end
local marker, readError = CacheContract.readMarker(version, fs) 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) return CacheContract.allRequiredFilesExist(version, fs)
end end
function CacheContract.publish(version, fs) function CacheContract.publish(version, fs, sha1)
fs = fs or require("src.import.CacheFs") fs = fs or require("src.import.CacheFs")
local complete, missing = CacheContract.allRequiredFilesExist(version, fs) local complete, missing = CacheContract.allRequiredFilesExist(version, fs)
if not complete then if not complete then
@@ -212,7 +227,7 @@ function CacheContract.publish(version, fs)
return false, "cache is incomplete; missing " .. tostring(missing) return false, "cache is incomplete; missing " .. tostring(missing)
end end
local changed, ok, err = withVersionPrefix(version, fs, function() 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) end)
if not changed then return false, tostring(ok) end if not changed then return false, tostring(ok) end
return ok, err return ok, err
+2 -2
View File
@@ -13,7 +13,7 @@ require("love.math")
require("love.system") require("love.system")
require("love.timer") require("love.timer")
local version, prefix, romData, progressName, resultName = ... local version, prefix, romData, progressName, resultName, romSha1 = ...
local progressChannel = love.thread.getChannel(progressName) local progressChannel = love.thread.getChannel(progressName)
local resultChannel = love.thread.getChannel(resultName) local resultChannel = love.thread.getChannel(resultName)
@@ -44,7 +44,7 @@ local ok, err = pcall(function()
current = current, stageTotal = stageTotal, current = current, stageTotal = stageTotal,
}) })
end end
end) end, romSha1)
extractor:run() extractor:run()
end) end)
+9 -8
View File
@@ -3056,8 +3056,8 @@ local function buildFindKindRow(imp, x, y, w, m)
end end
local function buildFindPanel(imp, x, y, w, availH, m) local function buildFindPanel(imp, x, y, w, availH, m)
imp._findVisibleEntries = nil
imp:_ensureFind() imp:_ensureFind()
imp:_ensureMods()
local ModIndex = require("src.mods.ModIndex") local ModIndex = require("src.mods.ModIndex")
local ModUpdate = require("src.mods.ModUpdate") local ModUpdate = require("src.mods.ModUpdate")
local sources = imp.findSources or {} local sources = imp.findSources or {}
@@ -3126,7 +3126,9 @@ local function buildFindPanel(imp, x, y, w, availH, m)
if #rows == 0 then if #rows == 0 then
local empty 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.") empty = (total == 0) and Strings("This index lists no carts yet.")
or Strings("No carts match that search.") or Strings("No carts match that search.")
else else
@@ -3191,6 +3193,11 @@ local function buildFindPanel(imp, x, y, w, availH, m)
local listTop = cy local listTop = cy
setPage(imp, "find", Kit.wheelPage(x, listTop, w, listH, cur, #rows, perPage)) 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 for i = first, last do
local entry = rows[i] local entry = rows[i]
local ry = listTop + (i - first) * (rowH + gap) 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, return { title = b.title, detail = b.detail, progress = b.progress,
onCancel = b.cancel } onCancel = b.cancel }
end 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 return nil
end end
+17 -8
View File
@@ -56,9 +56,6 @@ local MAP_GROUP_COUNT = 26 -- constants/map_constants.asm NUM_MAP_GROUPS
-- "bank13" (../pokecrystal/main.asm:192-195). -- "bank13" (../pokecrystal/main.asm:192-195).
local PAL_MAP_BANK = 0x02 local PAL_MAP_BANK = 0x02
local PAL_MAP_BANK_CRYSTAL = 0x13 local PAL_MAP_BANK_CRYSTAL = 0x13
-- LoadBallIconGFX.gfx (engine/battle/trainer_huds.asm:225-232); bank $0b
-- carries no manifest symbol to resolve it through.
local BALL_ICON_GFX = { 0x0b, 0x41a4 }
-- A tileset sheet is 96 tiles (128x48 at 8x8), and its PalMap packs two -- A tileset sheet is 96 tiles (128x48 at 8x8), and its PalMap packs two
-- tiles per byte: low nibble first tile, high nibble second (`dn` in the -- tiles per byte: low nibble first tile, high nibble second (`dn` in the
-- tilepal macro). The high bit of each nibble is the VRAM bank, not colour. -- tilepal macro). The high bit of each nibble is the VRAM bank, not colour.
@@ -201,14 +198,23 @@ local function copy(value)
return result return result
end 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 -- _GOLD / _SILVER: the labels are shared, the data behind a handful of
-- them is not (gfx/misc.asm:9-20 vs :46-57). -- them is not (gfx/misc.asm:9-20 vs :46-57).
local edition = GameVersion.forSha1(manifest.romSha1) or "gold" 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({ return setmetatable({
rom = Rom.new(romData), rom = Rom.new(romData),
manifest = manifest, manifest = manifest,
symbols = manifest.symbols, symbols = symbols,
progress = progress, progress = progress,
stage = 0, stage = 0,
edition = edition, edition = edition,
@@ -2843,7 +2849,10 @@ function RomExtractorGen2:decodeGen2Text(bank, address, charmap, buffers)
out[#out + 1] = "\f" out[#out + 1] = "\f"
elseif b == 0x55 then elseif b == 0x55 then
out[#out + 1] = "\v" out[#out + 1] = "\v"
elseif b == 0x52 then elseif b == 0x52
or (b == 0x14 and inString and self.edition == "crystal") then
-- ../pokecrystal/constants/charmap.asm:6 <PLAY_G>,
-- ../pokecrystal/home/text.asm:243,380 PlaceGenderedPlayerName
out[#out + 1] = "{PLAYER}" out[#out + 1] = "{PLAYER}"
elseif b == 0x53 then elseif b == 0x53 then
out[#out + 1] = "{RIVAL}" out[#out + 1] = "{RIVAL}"
@@ -5707,8 +5716,8 @@ function RomExtractorGen2:extractMenuGfx()
-- Four OAM tiles at $31 -- normal, statused, fainted, empty -- and OBJ -- Four OAM tiles at $31 -- normal, statused, fainted, empty -- and OBJ
-- colour 0 is transparent (engine/battle/trainer_huds.asm:47-99, :225-232). -- colour 0 is transparent (engine/battle/trainer_huds.asm:47-99, :225-232).
local balls = self.symbols["LoadBallIconGFX.gfx"] or BALL_ICON_GFX local balls = self:symbol("LoadBallIconGFX.gfx")
self:write2bpp(self.rom:bytes(balls[1], balls[2], 4 * 16), 32, 8, self:write2bpp(self.rom:bytes(balls.bank, balls.address, 4 * 16), 32, 8,
"battle/hud/balls.png", true) "battle/hud/balls.png", true)
hud.balls = "assets/generated/battle/hud/balls.png" hud.balls = "assets/generated/battle/hud/balls.png"
hud.ballsFirstTile = 0x31 hud.ballsFirstTile = 0x31
+81 -19
View File
@@ -1390,8 +1390,9 @@ function RomImporter.new(onComplete, opts)
-- player's index list from options; findIndex is the merged listing; -- player's index list from options; findIndex is the merged listing;
-- _findThumbs caches one image per mod id (false = fetched and failed). -- _findThumbs caches one image per mod id (false = fetched and failed).
findLoaded = false, findSources = nil, findIndex = nil, findLoaded = false, findSources = nil, findIndex = nil,
findScroll = 0, findNotice = nil, findQuery = "", findCategory = nil, findInstalled = nil, findScroll = 0, findNotice = nil, findQuery = "",
_findSearchFocus = false, _findThumbs = nil, findCategory = nil, _findSearchFocus = false, _findThumbs = nil,
_findVisibleEntries = nil,
-- Which half of the feed the panel is browsing. Mods by default: carts -- 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. -- are the newer, much shorter list, and a feed may carry none at all.
findKind = "mods", findBase = nil, findKind = "mods", findBase = nil,
@@ -1432,7 +1433,7 @@ function RomImporter.new(onComplete, opts)
-- "update required" (re-import) rather than a clean first-run choose -- "update required" (re-import) rather than a clean first-run choose
local marker = CacheContract.readMarker(version, CacheFs) local marker = CacheContract.readMarker(version, CacheFs)
self.returning[version] = 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 self.romName[version] = "pokemon_" .. info.id
.. ((info.id == "yellow" or GameVersion.generation(version) == 2) .. ((info.id == "yellow" or GameVersion.generation(version) == 2)
and ".gbc" or ".gb") and ".gbc" or ".gb")
@@ -1721,6 +1722,7 @@ function RomImporter:startData(data, displayName)
.. "(tagged [b] or [BF]) never verify."):format(actualHash, cartsProse())) .. "(tagged [b] or [BF]) never verify."):format(actualHash, cartsProse()))
return return
end end
self.romSha1 = actualHash
local info = GameVersion.info(version) local info = GameVersion.info(version)
-- Bring the launcher to this version's tab so its progress bar is on screen -- 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(progressName):clear()
love.thread.getChannel(resultName):clear() love.thread.getChannel(resultName):clear()
local started = pcall(thread.start, thread, version, prefix, data, local started = pcall(thread.start, thread, version, prefix, data,
progressName, resultName) progressName, resultName, self.romSha1)
if not started then return false end if not started then return false end
self._extract = { self._extract = {
thread = thread, version = version, prefix = prefix, thread = thread, version = version, prefix = prefix,
@@ -1804,7 +1806,7 @@ function RomImporter:_startExtractCoroutine(version, info, prefix, displayName)
self.stageCurrent = current self.stageCurrent = current
self.stageTotal = stageTotal self.stageTotal = stageTotal
coroutine.yield() coroutine.yield()
end) end, self.romSha1)
extractor:run() extractor:run()
CacheFs.prefix = "" -- restore the default so later writes stay at the root CacheFs.prefix = "" -- restore the default so later writes stay at the root
self.romData = nil self.romData = nil
@@ -1822,7 +1824,7 @@ function RomImporter:_completeImport(version, prefix, displayName)
-- appear once every required file is in place. -- appear once every required file is in place.
local savedPrefix = CacheFs.prefix local savedPrefix = CacheFs.prefix
CacheFs.prefix = prefix CacheFs.prefix = prefix
local ok, writeError = CacheContract.publish(version, CacheFs) local ok, writeError = CacheContract.publish(version, CacheFs, self.romSha1)
CacheFs.prefix = savedPrefix CacheFs.prefix = savedPrefix
if not ok then if not ok then
error("could not finish the private cache: " .. tostring(writeError)) error("could not finish the private cache: " .. tostring(writeError))
@@ -2589,6 +2591,7 @@ function RomImporter:update(dt)
-- before a tab switch still completes. -- before a tab switch still completes.
self:_pumpFindFetch() self:_pumpFindFetch()
self:_pumpModInfoFetch() self:_pumpModInfoFetch()
self:_queueFindEnrichment()
self:_pumpFindStats() self:_pumpFindStats()
self:_pumpFindThumbs() self:_pumpFindThumbs()
self:_pumpSkinFetch() self:_pumpSkinFetch()
@@ -3226,6 +3229,7 @@ end
-- offset persists inside the view's per-tab scroll container. -- offset persists inside the view's per-tab scroll container.
function RomImporter:_switchTab(id) function RomImporter:_switchTab(id)
self.tab = id self.tab = id
if id ~= "find" then self._findVisibleEntries = nil end
self._findSearchFocus = false self._findSearchFocus = false
self._skinUrlFocus = false self._skinUrlFocus = false
self:_disarmTextInput() self:_disarmTextInput()
@@ -3823,6 +3827,7 @@ function RomImporter:_toggleSafeMode()
SaveData.saveOptions(options) SaveData.saveOptions(options)
self.safeMode = enabled self.safeMode = enabled
self.mods = nil self.mods = nil
self.findInstalled = nil
self._modSortCache = nil self._modSortCache = nil
self._modInfoFetch = nil self._modInfoFetch = nil
self.modNotice = nil self.modNotice = nil
@@ -4798,6 +4803,7 @@ function RomImporter:_refreshMods()
local LauncherMods = require("src.mods.LauncherMods") local LauncherMods = require("src.mods.LauncherMods")
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
self._cartPlan = nil self._cartPlan = nil
self.findInstalled = nil
self.safeMode = SaveData.isSafeMode(SaveData.loadOptions()) self.safeMode = SaveData.isSafeMode(SaveData.loadOptions())
-- Once per session, ahead of the first listing: pull in any mod the player -- Once per session, ahead of the first listing: pull in any mod the player
-- unzipped beside the executable, which an ordinary (non-portable) install -- 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 -- 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 -- 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 -- 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) function RomImporter:_refreshFind(force)
-- The notice is the fix, not the gate (#876). This branch used to return an -- 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, -- 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 = {}, carts = {}, cartSeen = {}, bases = {}, baseSeen = {},
stale = false, oldest = nil, at = 1, stale = false, oldest = nil, at = 1,
} }
self:_setBusy(Strings("Fetching mod index"), if force == true then
#sources == 1 and (sources[1].label or sources[1].feed) self:_setBusy(Strings("Fetching mod index"),
or Strings("%d indexes", #sources)) #sources == 1 and (sources[1].label or sources[1].feed)
or Strings("%d indexes", #sources))
end
end end
-- Drive the in-flight index fetch one frame at a time. Called from update(). -- Drive the in-flight index fetch one frame at a time. Called from update().
@@ -5958,9 +5967,17 @@ end
function RomImporter:_findInstalledMap() function RomImporter:_findInstalledMap()
if self:findingCarts() then return self:_findInstalledCarts() end if self:findingCarts() then return self:_findInstalledCarts() end
local map = {} if self.findInstalled then return self.findInstalled end
for _, m in ipairs(self.mods or {}) do map[m.id] = m.version or true end local LauncherMods = require("src.mods.LauncherMods")
return map 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 end
-- id -> installed version for every cart on disk, whatever game it plays as. -- id -> installed version for every cart on disk, whatever game it plays as.
@@ -5978,10 +5995,9 @@ function RomImporter:_findInstalledCarts()
return map return map
end end
-- One thumbnail per frame, and only for a card actually on screen: the fetch -- Read the cached thumbnail for a card. Starting a download is deliberately
-- is a blocking curl, so downloading a whole listing's worth on open would -- separate: immediate-mode draw may call this for every visible row, but it
-- stall the launcher for as many seconds as there are mods. A failure is -- must not mutate the fetch queue or perform network work.
-- remembered as `false` so a broken URL is tried once, not every frame.
function RomImporter:_findThumb(entry) function RomImporter:_findThumb(entry)
self._findThumbs = self._findThumbs or {} self._findThumbs = self._findThumbs or {}
local cached = self._findThumbs[entry.id] local cached = self._findThumbs[entry.id]
@@ -5992,6 +6008,20 @@ function RomImporter:_findThumb(entry)
self._findThumbs[entry.id] = false self._findThumbs[entry.id] = false
return nil return nil
end 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 -- 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 -- 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 -- requests; the fetch pool runs them off-thread and the card shows its
@@ -6112,9 +6142,9 @@ function RomImporter:_requestFindStats(entry)
} }
end 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) function RomImporter:_findStats(entry)
self:_requestFindStats(entry)
return self:_findStatsCached(entry) return self:_findStatsCached(entry)
end end
@@ -6134,6 +6164,38 @@ function RomImporter:_findThumbPending(id)
return (self._findThumbFetch and self._findThumbFetch[id]) ~= nil return (self._findThumbFetch and self._findThumbFetch[id]) ~= nil
end 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(). -- Drive in-flight FIND MODS stats lookups. Called from update().
function RomImporter:_pumpFindStats() function RomImporter:_pumpFindStats()
local pending = self._findStatsPending local pending = self._findStatsPending
+2 -1
View File
@@ -18,7 +18,8 @@ function RomManifest.decode(version)
if not manifest then if not manifest then
error("ROM import metadata is invalid: " .. tostring(decodeError)) error("ROM import metadata is invalid: " .. tostring(decodeError))
end end
assert(manifest.romSha1 == info.sha1, "ROM import metadata version mismatch") assert(GameVersion.acceptsSha1(version, manifest.romSha1),
"ROM import metadata version mismatch")
return manifest return manifest
end end
+14
View File
@@ -471,6 +471,20 @@ discover = function()
return out return out
end 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 -- 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 -- 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. -- sees on its next boot; `version` narrows that to one game's answers.
+13 -3
View File
@@ -371,9 +371,13 @@ end
-- `es`: true for GLSL ES 1.00 (mobile), false for GLSL 1.20 (desktop). Only -- `es`: true for GLSL ES 1.00 (mobile), false for GLSL 1.20 (desktop). Only
-- ever called from ShaderFX.convert(). -- ever called from ShaderFX.convert().
function ShaderFX.translate(fullPath, es) function ShaderFX.translate(fullPath, es)
local ffi = require("ffi") local okFfi, ffi = pcall(require, "ffi")
local ok, l = pcall(ensureLib) if not okFfi or type(ffi) ~= "table" then
return nil, "this build has no ffi, so presets cannot be converted here"
end
local ok, l, lerr = pcall(ensureLib)
if not ok then return nil, "ffi.load failed: " .. tostring(l) end if not ok then return nil, "ffi.load failed: " .. tostring(l) end
if not l then return nil, tostring(lerr or libError or "librashader bridge not available") end
local ptr = l.librashader_translate_preset(fullPath, es and 1 or 0) local ptr = l.librashader_translate_preset(fullPath, es and 1 or 0)
if ptr == nil then return nil, "librashader_translate_preset returned NULL" end if ptr == nil then return nil, "librashader_translate_preset returned NULL" end
local json = ffi.string(ptr) local json = ffi.string(ptr)
@@ -780,7 +784,7 @@ end
-- Translates `entry` via the bridge and writes ShaderFX.artifactPath(entry). -- Translates `entry` via the bridge and writes ShaderFX.artifactPath(entry).
-- Sets entry.converted on success; an existing artifact survives a failure. -- Sets entry.converted on success; an existing artifact survives a failure.
function ShaderFX.convert(entry, es) local function doConvert(entry, es)
ShaderSourcePatches.apply(entry) ShaderSourcePatches.apply(entry)
local preset, err = ShaderFX.translate(entry.fullPath, es == nil and defaultEs() or es) local preset, err = ShaderFX.translate(entry.fullPath, es == nil and defaultEs() or es)
if not preset then return false, err end if not preset then return false, err end
@@ -797,6 +801,12 @@ function ShaderFX.convert(entry, es)
return true return true
end end
function ShaderFX.convert(entry, es)
local ok, res, err = pcall(doConvert, entry, es)
if not ok then return false, tostring(res) end
return res, err
end
-- Two independent slots. "shaderfx" stays main's option key, so existing -- Two independent slots. "shaderfx" stays main's option key, so existing
-- saves keep meaning what they already meant. -- saves keep meaning what they already meant.
ShaderFX.SLOTS = { "main", "secondary" } ShaderFX.SLOTS = { "main", "secondary" }
+1
View File
@@ -119,6 +119,7 @@ function ShaderFXScreen.new(game, slot)
item.entry.name, tostring(err)) item.entry.name, tostring(err))
end end
applyRowState(item) applyRowState(item)
if not ok then item.right = Strings("FAILED") end
return return
end end
+17 -10
View File
@@ -38,9 +38,12 @@ local MON_HOLDING_MAIL = {
Strings.source("Please remove the\nMAIL."), Strings.source("Please remove the\nMAIL."),
} }
-- _ChangeBoxSaveText (data/text/common_2.asm:1306) is three lines whose `cont` -- _ChangeBoxSaveText (data/text/common_2.asm:1306) is three lines whose first
-- has already scrolled by the time YesNoBox goes up over its last two. -- `cont` ("When you change a") has already scrolled by the time YesNoBox goes
local CHANGE_BOX_SAVE = { "#MON BOX, data", "will be saved. OK?" } -- 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). -- 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 local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 14, 7, 6, 5
@@ -212,16 +215,20 @@ function PcMenu:writeChangeBox()
end end
function PcMenu:savePrompt() function PcMenu:savePrompt()
if self.savePhase == "overwrite" then return SaveMenu.OVERWRITE_PROMPT end if self.savePhase == "overwrite" then
if self.savePhase == "saving" then return SaveMenu.SAVING_PROMPT end 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.savePhase == "done" then
if self.saved then if self.saved then
local name = (self.save.player and self.save.player.name) or "GOLD" 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 end
return { "Could not save.", "" } return SaveMenu.twoLines(Strings("Could not save."))
end end
return CHANGE_BOX_SAVE return SaveMenu.twoLines(Strings(CHANGE_BOX_SAVE_SOURCE))
end end
function PcMenu:updateChangeBox() function PcMenu:updateChangeBox()
@@ -404,8 +411,8 @@ function PcMenu:drawPanel()
Chrome.print(lines[2] or "", 1, 16) Chrome.print(lines[2] or "", 1, 16)
if self.savePhase == "confirm" or self.savePhase == "overwrite" then if self.savePhase == "confirm" or self.savePhase == "overwrite" then
Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H) Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H)
Chrome.print("YES", YESNO_X + 2, YESNO_Y + 1) Chrome.print(Strings("YES"), YESNO_X + 2, YESNO_Y + 1)
Chrome.print("NO", YESNO_X + 2, YESNO_Y + 3) Chrome.print(Strings("NO"), YESNO_X + 2, YESNO_Y + 3)
Chrome.cursor(YESNO_X + 1, Chrome.cursor(YESNO_X + 1,
YESNO_Y + (self.saveChoice == 1 and 1 or 3)) YESNO_Y + (self.saveChoice == 1 and 1 or 3))
end end
+17 -22
View File
@@ -57,27 +57,21 @@ local TIME_X, TIME_Y = 13, 8
local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 0, 7, 6, 5 local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 0, 7, 6, 5
-- AlreadyASaveFileText (AskOverwriteSaveFile, engine/menus/save.asm:47) and -- AlreadyASaveFileText (AskOverwriteSaveFile, engine/menus/save.asm:47) and
-- SavingDontTurnOffThePower's own line, shared with the PC's CHANGE BOX save -- SavingDontTurnOffThePower's own line -- one \n-joined translatable key
-- (src/ui/gen2/PcMenu.lua:savePrompt() reads these two tables' lines[1]/ -- each, used both by this screen's own prompt() below and, through the
-- lines[2] directly, so their shape is a cross-file contract: keep them -- SOURCE/twoLines() exports at the bottom of this file, by the PC's CHANGE
-- plain, untranslated tables). -- BOX save (src/ui/gen2/PcMenu.lua:savePrompt()), which shares these exact
local OVERWRITE_PROMPT = { "There is already a", "save file. Is it" } -- same two cart messages. One key per prompt lets a translation write one
local SAVING_PROMPT = { "SAVING… DON'T TURN", "OFF THE POWER." } -- whole, freely reordered sentence instead of two fragments translated in
-- isolation, and lets a cart whose own text is a single line (German's
-- Translatable copies of the two prompts above, one \n-joined key each, used -- SAVING prompt) say so directly by simply omitting the "\n" -- the
-- only by this screen's own prompt() below. One key per prompt lets a -- per-line override style used elsewhere requires a non-empty value for
-- translation write one whole, freely reordered sentence instead of two -- every line, so it can't express "this line is blank".
-- 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 -- Written as a literal, not built from a table: the translation tooling's
-- translation tooling's string harvester only recognizes a literal inside -- string harvester only recognizes a literal inside Strings.source(...),
-- Strings.source(...), not a computed expression, so a concat call here -- not a computed expression, so a concat call here would quietly never
-- would quietly never reach a translator. Keep byte-for-byte in sync with -- reach a translator.
-- OVERWRITE_PROMPT/SAVING_PROMPT above (checked by
-- tests/engine/gen2_save_menu_translation_test.lua).
local OVERWRITE_PROMPT_SOURCE = Strings.source("There is already a\nsave file. Is it") 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.") local SAVING_PROMPT_SOURCE = Strings.source("SAVING… DON'T TURN\nOFF THE POWER.")
@@ -279,7 +273,8 @@ end
SaveMenu.SFX_SAVE = SFX_SAVE SaveMenu.SFX_SAVE = SFX_SAVE
SaveMenu.SAVING_FRAMES = SAVING_FRAMES SaveMenu.SAVING_FRAMES = SAVING_FRAMES
SaveMenu.SAVED_FRAMES = SAVED_FRAMES SaveMenu.SAVED_FRAMES = SAVED_FRAMES
SaveMenu.OVERWRITE_PROMPT = OVERWRITE_PROMPT SaveMenu.OVERWRITE_PROMPT_SOURCE = OVERWRITE_PROMPT_SOURCE
SaveMenu.SAVING_PROMPT = SAVING_PROMPT SaveMenu.SAVING_PROMPT_SOURCE = SAVING_PROMPT_SOURCE
SaveMenu.twoLines = twoLines
return SaveMenu return SaveMenu
+30
View File
@@ -37,6 +37,36 @@ eq(crystal.romSha1, GameVersion.VERSIONS.crystal.sha1,
eq(crystal.generation, 2, "generation 2") eq(crystal.generation, 2, "generation 2")
eq(crystal.format, gold.format, "same manifest format as Gold") 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 -- ------- 2. content counts
eq(size(crystal.maps), 388, "388 maps") eq(size(crystal.maps), 388, "388 maps")
+46 -1
View File
@@ -68,11 +68,56 @@ eq(GameVersion.ORDER[5], "silver", "silver keeps slot 5")
-- ------- 4. sha1 routing -- ------- 4. sha1 routing
eq(GameVersion.forSha1("f4cd194bdee0d04ca4eac29e09b8e4e9d818c133"), "crystal", 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", eq(GameVersion.forSha1("d8b8a3600a465308c9953dfa04f0081c05bdcb94"), "gold",
"Gold's sha1 still resolves to gold") "Gold's sha1 still resolves to gold")
eq(GameVersion.forSha1("deadbeef"), nil, "an unknown ROM resolves to nothing") 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 -- ------- 5. set / get round trip
local savedCurrent = GameVersion.get() 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") T.check(not Strings.active(), "the catalog is unloaded for the checks after this one")
end end
-- src/ui/gen2/PcMenu.lua:savePrompt() returns SaveMenu.OVERWRITE_PROMPT/ -- src/ui/gen2/PcMenu.lua:savePrompt() shares SaveMenu's overwrite/saving
-- SAVING_PROMPT straight through to its own `lines[1]`/`lines[2]` -- prompts through SaveMenu.OVERWRITE_PROMPT_SOURCE/SAVING_PROMPT_SOURCE and
-- Chrome.print calls (the PC's CHANGE BOX save uses the same two prompts). -- SaveMenu.twoLines(), rather than duplicating them -- both exported below,
-- Indexing a plain string with [1]/[2] returns nil, not characters, so this -- both used by PcMenu's own translation test
-- shape is a cross-file contract: it caught a real regression during review, -- (tests/engine/gen2_pcmenu_changebox_save_translation_test.lua). Checked
-- where routing these through a single Strings.source()-wrapped string (to -- here that they stay callable the shape twoLines() expects: a table in,
-- translate SaveMenu's own screen) silently turned them into non-table -- one \n-joined string out with the split back on load.
-- values and left PcMenu's overwrite/saving prompt blank.
do do
T.eq(type(SaveMenu.OVERWRITE_PROMPT), "table", "OVERWRITE_PROMPT stays a table for PcMenu.lua") T.eq(SaveMenu.OVERWRITE_PROMPT_SOURCE, "There is already a\nsave file. Is it",
T.eq(SaveMenu.OVERWRITE_PROMPT[1], "There is already a", "and its first line stays indexable") "OVERWRITE_PROMPT_SOURCE stays the cart's own \\n-joined text")
T.eq(SaveMenu.OVERWRITE_PROMPT[2], "save file. Is it", "and its second line") T.eq(SaveMenu.twoLines(Strings(SaveMenu.OVERWRITE_PROMPT_SOURCE))[1], "There is already a",
T.eq(type(SaveMenu.SAVING_PROMPT), "table", "SAVING_PROMPT stays a table for PcMenu.lua") "and twoLines() splits its untranslated fallback back to the first line")
T.eq(SaveMenu.SAVING_PROMPT[1], "SAVING… DON'T TURN", "and its first line stays indexable") T.eq(SaveMenu.twoLines(Strings(SaveMenu.OVERWRITE_PROMPT_SOURCE))[2], "save file. Is it",
T.eq(SaveMenu.SAVING_PROMPT[2], "OFF THE POWER.", "and its second line") "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 end
-- A translation with a THIRD line (a second embedded "\n") has nowhere on -- A translation with a THIRD line (a second embedded "\n") has nowhere on
+3 -2
View File
@@ -75,7 +75,8 @@ end
do do
local ri = launcher() local ri = launcher()
check(ri:_findStats(entry("nulled", nil, "someone/nulled")) == nil, 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") eq(#fetched, 1, "which is the fetch the panel already made for dates")
local bare = launcher() local bare = launcher()
@@ -83,7 +84,7 @@ do
check(stats ~= nil and stats.total == nil, check(stats ~= nil and stats.total == nil,
"a listing with neither counts nor a repo is resolved-but-unknown") "a listing with neither counts nor a repo is resolved-but-unknown")
check(stats.recent == nil, "and has nothing to trend on") 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 end
-- A real zero is not unknown: the index has seen the releases and counted -- A real zero is not unknown: the index has seen the releases and counted
+88
View File
@@ -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")
+55
View File
@@ -110,6 +110,61 @@ check(not silverSet["assets/generated/trade/game_boy.png"],
check(CacheContract.VERSION_REQUIRED_FILES.yellow ~= nil, check(CacheContract.VERSION_REQUIRED_FILES.yellow ~= nil,
"Yellow has version-specific required outputs") "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"
local CRYSTAL_FORMAT = CacheContract.formatFor("crystal")
eq(CacheContract.markerFor("crystal", CRYSTAL_1_1),
CRYSTAL_FORMAT .. CRYSTAL_1_1,
"markerFor with an explicit sha1 uses that sha1, not the canonical one")
eq(CacheContract.markerFor("crystal"), CRYSTAL_FORMAT .. CRYSTAL_1_0,
"markerFor with no sha1 still defaults to the canonical (1.0) sha1")
check(CacheContract.markerMatches("crystal", CRYSTAL_FORMAT .. CRYSTAL_1_0),
"a marker written from the 1.0 hash matches crystal")
check(CacheContract.markerMatches("crystal", CRYSTAL_FORMAT .. CRYSTAL_1_1),
"a marker written from the 1.1 hash also matches crystal")
check(not CacheContract.markerMatches("crystal",
CRYSTAL_FORMAT .. "ea9bcae617fdf159b045185467ae58b2e4a48b9a"),
"a marker written from Red's hash does not match crystal")
check(not CacheContract.markerMatches("red", CRYSTAL_FORMAT .. CRYSTAL_1_1),
"a marker written from Crystal's 1.1 hash does not match red")
check(not CacheContract.markerMatches("crystal",
CacheContract.FORMAT .. CRYSTAL_1_0),
"a crystal marker written on the shared format no longer matches")
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],
CRYSTAL_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. -- A throwing adapter must not strand the process in its temporary prefix.
local throwingFs = { prefix = "before/" } local throwingFs = { prefix = "before/" }
function throwingFs.exists() error("probe failed") end function throwingFs.exists() error("probe failed") end
+28 -2
View File
@@ -194,6 +194,21 @@ if not cache then
cache = home .. "/Library/Application Support/LOVE/crystal-dev/crystal" cache = home .. "/Library/Application Support/LOVE/crystal-dev/crystal"
end 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 function loadCache(rel)
local chunk = loadfile(cache .. "/data/generated/" .. rel .. ".lua") local chunk = loadfile(cache .. "/data/generated/" .. rel .. ".lua")
if not chunk then return nil end if not chunk then return nil end
@@ -338,7 +353,7 @@ local MAPS = {
{ "centerAttrmap", "mobile_center_attrmap", 360, { "centerAttrmap", "mobile_center_attrmap", 360,
"gfx/mobile/mobile_center.attrmap" }, "gfx/mobile/mobile_center.attrmap" },
{ "stadium2N64Tilemap", "stadium2_n64_tilemap", 360, { "stadium2N64Tilemap", "stadium2_n64_tilemap", 360,
"gfx/mobile/stadium2_n64.tilemap" }, "revision" },
{ "stadium2N64Attrmap", "stadium2_n64_attrmap", 360, { "stadium2N64Attrmap", "stadium2_n64_attrmap", 360,
"gfx/mobile/stadium2_n64.attrmap" }, "gfx/mobile/stadium2_n64.attrmap" },
{ "dialpadTilemap", "dialpad_tilemap", 360, "gfx/mobile/dialpad.tilemap" }, { "dialpadTilemap", "dialpad_tilemap", 360, "gfx/mobile/dialpad.tilemap" },
@@ -408,6 +423,14 @@ else
for _, row in ipairs(MAPS) do for _, row in ipairs(MAPS) do
local key, file, bytes, source = row[1], row[2], row[3], row[4] 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 entry = maps[key]
local rel = "assets/generated/mobile/" .. file .. ".bin" local rel = "assets/generated/mobile/" .. file .. ".bin"
if type(entry) ~= "table" then if type(entry) ~= "table" then
@@ -425,7 +448,10 @@ else
check(true, "no ../pokecrystal: " .. source .. " not diffed (SKIP)") check(true, "no ../pokecrystal: " .. source .. " not diffed (SKIP)")
else else
check(blob == want:sub(1, bytes), 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 end
end end
+29
View File
@@ -91,6 +91,35 @@ do
:format(extra)) :format(extra))
end end
-- ---- the gendered player name ---------------------------------------------
-- ../pokecrystal/constants/charmap.asm:6 <PLAY_G> ($14),
-- ../pokecrystal/home/text.asm:243,380 PlaceGenderedPlayerName
do
local Extractor = require("src.import.RomExtractorGen2")
local charmap = manifest("tools/rom_manifest_crystal.json").charmap
local function decode(edition, bytes)
local rom = {}
function rom:byte(_, address) return bytes[address] or 0x50 end
function rom:word(_, address)
return (bytes[address] or 0) + (bytes[address + 1] or 0) * 0x100
end
local extractor = setmetatable({ rom = rom, edition = edition }, Extractor)
return extractor:decodeGen2Text(0, 0, charmap)
end
local H, i, comma, space, bang = 0x87, 0xa8, 0xf4, 0x7f, 0xe7
local inString = { [0] = 0x00, H, i, comma, space, 0x14, bang, 0x57 }
eq(decode("crystal", inString), "Hi, {PLAYER}!",
"an in-string $14 decodes as the player's name")
eq(decode("gold", inString), "Hi, !",
"and only on Crystal, which is the only edition that writes one")
local command = { [0] = 0x14, 0x03, 0x00, H, i, 0x57 }
eq(decode("crystal", command), "{STRBUF}Hi",
"a $14 outside a string is still TX_STRINGBUFFER and eats its buffer id")
end
-- ---- the slots RomText fills ---------------------------------------------- -- ---- the slots RomText fills ----------------------------------------------
-- decodeGen2Text emits {USER}, {TARGET} and {ENEMY} for the three names -- decodeGen2Text emits {USER}, {TARGET} and {ENEMY} for the three names
-- PlaceMoveUsersName / PlaceMoveTargetsName / PlaceEnemysName write at -- PlaceMoveUsersName / PlaceMoveTargetsName / PlaceEnemysName write at
+17 -4
View File
@@ -1582,9 +1582,17 @@ def t_png():
assert label_art("#123456") == art assert label_art("#123456") == art
class SelftestSkip(Exception):
pass
def t_roundtrip(): def t_roundtrip():
root = tempfile.mkdtemp(prefix="cartkit-selftest-") 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: try:
with contextlib.redirect_stdout(io.StringIO()): with contextlib.redirect_stdout(io.StringIO()):
_roundtrip(root, repo) _roundtrip(root, repo)
@@ -1650,21 +1658,26 @@ CHECKS = [
def cmd_selftest(args, repo): def cmd_selftest(args, repo):
failures = [] failures = []
skipped = []
for name, check in CHECKS: for name, check in CHECKS:
try: try:
check() check()
except SelftestSkip as why:
skipped.append((name, why))
print(f"skip {name}: {why}")
except Exception as problem: except Exception as problem:
failures.append((name, problem)) failures.append((name, problem))
if not args.quiet: print(f"FAIL {name}: {problem!r}")
print(f"FAIL {name}: {problem!r}")
else: else:
if not args.quiet: if not args.quiet:
print(f"ok {name}") print(f"ok {name}")
if failures: if failures:
print(f"FAIL {len(failures)} of {len(CHECKS)} checks") print(f"FAIL {len(failures)} of {len(CHECKS)} checks")
return 1 return 1
ran = len(CHECKS) - len(skipped)
tail = f" ({len(skipped)} skipped)" if skipped else ""
if not args.quiet: if not args.quiet:
print(f"ok {len(CHECKS)} checks") print(f"ok {ran} checks{tail}")
return 0 return 0
+48 -4
View File
@@ -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` 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. 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 Usage: python3 tools/make_crystal_manifest.py
Default paths: pokecrystal at ../pokecrystal (relative to the repo) or Default paths: pokecrystal at ../pokecrystal (relative to the repo) or
/Users/bryanbassett/Documents/development/pokecrystal; symbols at /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 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 import make_gold_manifest as gold # noqa: E402
from crystal_symbol_deltas import crystal_required # 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__))) REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEV = "/Users/bryanbassett/Documents/development" DEV = "/Users/bryanbassett/Documents/development"
@@ -58,6 +69,8 @@ DEFAULT_POKECRYSTAL_CANDIDATES = [
os.path.join(DEV, "pokecrystal"), os.path.join(DEV, "pokecrystal"),
] ]
DEFAULT_SYMBOLS = os.path.join(DEV, "pokecrystal-symbols/pokecrystal.sym") 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( DEFAULT_OUT = os.path.join(
os.path.dirname(__file__), "rom_manifest_crystal.json") os.path.dirname(__file__), "rom_manifest_crystal.json")
@@ -126,7 +139,31 @@ def unown_charmap(pokecrystal, defines=None):
return out 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( data = gold.generate(
pokecrystal, symbols_path, pokecrystal, symbols_path,
defines=CRYSTAL_ASM_DEFINES, defines=CRYSTAL_ASM_DEFINES,
@@ -139,6 +176,10 @@ def generate(pokecrystal, symbols_path):
os.path.join(pokecrystal, "constants", "engine_flags.asm"), os.path.join(pokecrystal, "constants", "engine_flags.asm"),
defines=CRYSTAL_ASM_DEFINES) defines=CRYSTAL_ASM_DEFINES)
data["unownCharmap"] = unown_charmap(pokecrystal, 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 return data
@@ -153,13 +194,16 @@ def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--pokecrystal", default=find_pokecrystal()) parser.add_argument("--pokecrystal", default=find_pokecrystal())
parser.add_argument("--symbols", default=DEFAULT_SYMBOLS) parser.add_argument("--symbols", default=DEFAULT_SYMBOLS)
parser.add_argument("--symbols11", default=DEFAULT_SYMBOLS11)
parser.add_argument("--out", default=DEFAULT_OUT) parser.add_argument("--out", default=DEFAULT_OUT)
args = parser.parse_args() args = parser.parse_args()
pokecrystal = os.path.abspath(args.pokecrystal) pokecrystal = os.path.abspath(args.pokecrystal)
if not os.path.isfile(os.path.join(pokecrystal, "main.asm")): if not os.path.isfile(os.path.join(pokecrystal, "main.asm")):
raise SystemExit(f"{pokecrystal} is not a pokecrystal checkout") 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: with open(args.out, "w", encoding="utf-8", newline="\n") as f:
json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True) json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n") f.write("\n")
+3
View File
@@ -778,6 +778,9 @@ REQUIRED_SYMBOLS = {
# nine fill cells. "HP:" and the ten HP-bar cells come from # nine fill cells. "HP:" and the ten HP-bar cells come from
# FontBattleExtra, which is already extracted. # FontBattleExtra, which is already extracted.
"EnemyHPBarBorderGFX", "HPExpBarBorderGFX", "ExpBarGFX", "EnemyHPBarBorderGFX", "HPExpBarBorderGFX", "ExpBarGFX",
# gfx/battle/balls.2bpp, the four party-ball OAM tiles
# (engine/battle/trainer_huds.asm LoadBallIconGFX).
"LoadBallIconGFX.gfx",
# gfx/stats/stats_tiles.png + gfx/stats/pages.pal, StatsScreen_LoadFont # gfx/stats/stats_tiles.png + gfx/stats/pages.pal, StatsScreen_LoadFont
# and _CGB_StatsScreenHPPals (#1558) # and _CGB_StatsScreenHPPals (#1558)
"StatsScreenPageTilesGFX", "StatsScreenPagePals", "StatsScreenPageTilesGFX", "StatsScreenPagePals",
+11 -4
View File
@@ -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. # Gold and Silver are Gen 2: 2 MiB carts, twice the size of the Gen 1 ROMs above.
CANONICAL_GOLD_SHA1 = "d8b8a3600a465308c9953dfa04f0081c05bdcb94" CANONICAL_GOLD_SHA1 = "d8b8a3600a465308c9953dfa04f0081c05bdcb94"
CANONICAL_SILVER_SHA1 = "49b163f7e57702bc939d642a18f591de55d92dae" CANONICAL_SILVER_SHA1 = "49b163f7e57702bc939d642a18f591de55d92dae"
# Crystal is the retail international v1.0 build (pret/pokecrystal's default # Crystal is the retail international v1.0 cart, also 2 MiB.
# `make` target), also 2 MiB.
CANONICAL_CRYSTAL_SHA1 = "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133" CANONICAL_CRYSTAL_SHA1 = "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133"
CANONICAL_CRYSTAL11_SHA1 = "f2f52230b536214ef7c9924f483392993e226cfb"
ROM_BANK_SIZE = 0x4000 ROM_BANK_SIZE = 0x4000
@@ -74,9 +74,16 @@ class RomImage:
with open(path, "rb") as f: with open(path, "rb") as f:
self.data = f.read() self.data = f.read()
self.sha1 = hashlib.sha1(self.data).hexdigest() 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( 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 @staticmethod
def offset(bank, address): def offset(bank, address):
+12
View File
@@ -14279,6 +14279,14 @@
} }
}, },
"romSha1": "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133", "romSha1": "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133",
"symbolRevisions": {
"f2f52230b536214ef7c9924f483392993e226cfb": {
"Stadium2N64Attrmap": [
92,
29988
]
}
},
"symbols": { "symbols": {
"AbraBackpic": [ "AbraBackpic": [
86, 86,
@@ -17016,6 +17024,10 @@
32, 32,
21684 21684
], ],
"LoadBallIconGFX.gfx": [
11,
16754
],
"LoadOpponentTrainerAndPokemon.resample": [ "LoadOpponentTrainerAndPokemon.resample": [
126, 126,
16418 16418
+4
View File
@@ -15084,6 +15084,10 @@
64, 64,
24169 24169
], ],
"LoadBallIconGFX.gfx": [
11,
16804
],
"LoadOrientedFrontpic": [ "LoadOrientedFrontpic": [
20, 20,
22948 22948
+4
View File
@@ -15084,6 +15084,10 @@
64, 64,
24169 24169
], ],
"LoadBallIconGFX.gfx": [
11,
16804
],
"LoadOrientedFrontpic": [ "LoadOrientedFrontpic": [
20, 20,
22948 22948