This commit is contained in:
bryanthaboi
2026-07-23 11:14:40 -04:00
parent 5128b840b1
commit 78aa1f3c0a
15 changed files with 45826 additions and 208 deletions
+24 -6
View File
@@ -390,24 +390,42 @@ M.GAME_CORNER = {
},
}
-- Red-version prize lists (data/events/prizes.asm, prize_mon_levels.asm)
local PRIZES = {
-- Game Corner prize lists (data/events/prizes.asm, prize_mon_levels.asm).
-- The six mon prizes differ between Red and Blue; the three TM prizes are
-- identical, so they are shared and appended to each version's mon list.
local PRIZE_TMS = {
{ kind = "item", item = "TM_DRAGON_RAGE", cost = 3300 },
{ kind = "item", item = "TM_HYPER_BEAM", cost = 5500 },
{ kind = "item", item = "TM_SUBSTITUTE", cost = 7700 },
}
local RED_PRIZES = {
{ kind = "mon", species = "ABRA", level = 9, cost = 180 },
{ kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 },
{ kind = "mon", species = "NIDORINA", level = 17, cost = 1200 },
{ kind = "mon", species = "DRATINI", level = 18, cost = 2800 },
{ kind = "mon", species = "SCYTHER", level = 25, cost = 5500 },
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
{ kind = "item", item = "TM_DRAGON_RAGE", cost = 3300 },
{ kind = "item", item = "TM_HYPER_BEAM", cost = 5500 },
{ kind = "item", item = "TM_SUBSTITUTE", cost = 7700 },
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
}
local BLUE_PRIZES = {
{ kind = "mon", species = "ABRA", level = 6, cost = 120 },
{ kind = "mon", species = "CLEFAIRY", level = 12, cost = 750 },
{ kind = "mon", species = "NIDORINO", level = 17, cost = 1200 },
{ kind = "mon", species = "PINSIR", level = 20, cost = 2500 },
{ kind = "mon", species = "DRATINI", level = 24, cost = 4600 },
{ kind = "mon", species = "PORYGON", level = 18, cost = 6500 },
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
}
local function activePrizes()
return require("src.core.GameVersion").isBlue() and BLUE_PRIZES or RED_PRIZES
end
local function prizeCounter(game, ow, npc, done)
local ListMenu = require("src.ui.ListMenu")
local Commands = require("src.script.Commands")
local items = {}
for _, p in ipairs(PRIZES) do
for _, p in ipairs(activePrizes()) do
local label
if p.kind == "mon" then
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
+29 -11
View File
@@ -26,7 +26,19 @@ local function scriptedIterations()
return math.max(1, math.floor(require("src.core.GameSpeed").clamp(speedOverride)))
end
local function bootGame()
local function bootGame(version)
-- The launcher hands us the chosen game (Red / Blue); scripted and headless
-- runs fall back to POKEPORT_VERSION, then Red. Set the active version and
-- overlay its extracted cache BEFORE anything requires generated data, so
-- data/generated + assets/generated resolve to that version's files.
local GameVersion = require("src.core.GameVersion")
GameVersion.set(version or os.getenv("POKEPORT_VERSION") or "red")
require("src.import.CacheFs").mountVersion(GameVersion.get())
if love.window and love.window.setTitle then
local Version = require("src.core.Version")
love.window.setTitle(Version.title(
GameVersion.info().displayName .. " (Gen 1 Recompilation Project)"))
end
Game = require("src.core.Game")
Game:load()
if os.getenv("POKEPORT_AUTOPILOT") then
@@ -68,9 +80,12 @@ function love.load(args)
end
local RomImporter = require("src.import.RomImporter")
local ready = RomImporter.isReady()
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
local importPath = os.getenv("POKEPORT_IMPORT_ROM")
-- Scripted / headless runs pick their game from POKEPORT_VERSION (default
-- Red); the launcher's per-column choice does not apply to them.
local scriptedVersion = os.getenv("POKEPORT_VERSION") or "red"
local ready = RomImporter.isReady(scriptedVersion)
-- Scripted / headless runs have to reach the game with no human pressing
-- Play: an autopilot, a frame driver, an import-only build step, or an
-- explicit ROM path all bypass the interactive launcher and keep today's
@@ -80,28 +95,31 @@ function love.load(args)
if scripted then
if forceImport or not ready then
Importer = RomImporter.new(function()
-- The importer detects the dropped/loaded ROM's version by SHA-1 and
-- passes it to onComplete; boot that version.
Importer = RomImporter.new(function(version)
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then
love.event.quit()
return
end
Importer = nil
bootGame()
bootGame(version or scriptedVersion)
end)
if importPath then Importer:startPath(importPath) end
return
end
bootGame()
bootGame(scriptedVersion)
return
end
-- Interactive: the launcher always runs. Its Red column shows Play when the
-- ROM is already imported or Choose ROM / drag-drop when it is not (Blue and
-- Yellow are placeholders); pressing Play boots the chosen game.
Importer = RomImporter.new(function()
-- Interactive: the launcher always runs. Red and Blue are each live: a
-- column shows Play when that game's ROM is already imported, or Choose ROM
-- / drag-drop when it is not (Yellow is still a placeholder). Any dropped
-- .gb is routed to Red or Blue by its SHA-1; pressing Play boots that game.
Importer = RomImporter.new(function(version)
Importer = nil
bootGame()
end, { ready = ready and not forceImport, launcher = true })
bootGame(version)
end, { launcher = true, forceImport = forceImport })
end
function love.update(dt)
+1 -1
View File
@@ -60,7 +60,7 @@ say "packing game.love"
LOVE_FILE="$WORK/game.love"
rm -f "$LOVE_FILE"
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/rom_manifest.json \
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
+1 -1
View File
@@ -137,7 +137,7 @@ pack_game_love() {
mkdir -p "$EMBED_ASSETS"
rm -f "$LOVE_FILE"
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/rom_manifest.json \
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \
+1 -1
View File
@@ -166,7 +166,7 @@ pack_game_love() {
rm -f "$LOVE_FILE"
# Same payload as scripts/build.sh / build_android.sh: game sources only.
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/rom_manifest.json \
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \
+6 -1
View File
@@ -99,7 +99,12 @@ end
-- total conversion overrides. Threaded into SaveData so persistence stays
-- free of a Data dependency.
function Game:bootConfig()
return self.data and self.data.field and self.data.field.boot
local boot = self.data and self.data.field and self.data.field.boot
-- stamp the running game version onto the boot config so a New Game records
-- it (SaveData.newGame reads boot.version); this is what routes a Blue
-- playthrough to save_blue.lua and Blue's version-gated content
if boot then boot.version = require("src.core.GameVersion").get() end
return boot
end
-- the title screen with its NEW GAME / CONTINUE wiring; used at boot
+77
View File
@@ -0,0 +1,77 @@
-- Which Gen-1 game this process is running: Red (the historical default) or
-- Blue. One source of truth for everything that differs by version -- the
-- accepted ROM hash, the import manifest, where the extracted cache lives,
-- and the save-file suffix -- so the importer, cache mount, SaveData, title
-- screen and palette all agree.
--
-- Red keeps every un-suffixed path it always used (save.lua, the root cache),
-- so existing installs are untouched; Blue is namespaced under blue/ and
-- _blue so both can be imported and played side by side.
--
-- Zero requires, so it loads during love.conf and under plain Lua for tools
-- and tests. The active version is a process-global set once at boot from
-- the launcher's column choice (main.lua); it defaults to Red.
local GameVersion = {}
GameVersion.VERSIONS = {
red = {
id = "red",
label = "Red",
displayName = "Pokemon Red",
sha1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a",
manifest = "tools/rom_manifest.json",
cachePrefix = "", -- Red owns the cache root (backwards compatible)
saveSuffix = "", -- save.lua / save.lua.bak / save.lua.tmp
},
blue = {
id = "blue",
label = "Blue",
displayName = "Pokemon Blue",
sha1 = "d7037c83e1ae5b39bde3c30787637ba1d4c48ce2",
manifest = "tools/rom_manifest_blue.json",
cachePrefix = "blue/", -- blue/data/generated, blue/assets/generated
saveSuffix = "_blue", -- save_blue.lua / .bak / .tmp
},
}
-- Launcher column order (Yellow is still a placeholder, handled by the UI).
GameVersion.ORDER = { "red", "blue" }
GameVersion.current = "red"
function GameVersion.set(id)
GameVersion.current = GameVersion.VERSIONS[id] and id or "red"
return GameVersion.current
end
function GameVersion.get()
return GameVersion.current
end
function GameVersion.isBlue()
return GameVersion.current == "blue"
end
-- Metadata for a version id, defaulting to the active one.
function GameVersion.info(id)
return GameVersion.VERSIONS[id or GameVersion.current]
end
function GameVersion.saveSuffix(id)
return GameVersion.info(id).saveSuffix
end
function GameVersion.cachePrefix(id)
return GameVersion.info(id).cachePrefix
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
end
return nil
end
return GameVersion
+28 -6
View File
@@ -18,14 +18,30 @@ local Semver = require("src.mods.Semver")
local Boxes = require("src.pokemon.Boxes")
local Bag = require("src.inventory.Bag")
local GameVersion = require("src.core.GameVersion")
local SaveData = {}
local FILENAME = "save.lua"
-- Progress files carry the game-version suffix so Red and Blue saves coexist:
-- Red keeps save.lua / .bak / .tmp exactly as before; Blue is save_blue.lua
-- (+ .bak/.tmp). options.lua is deliberately shared across versions (it holds
-- global preferences and the mod enable-state, not per-playthrough data).
local OPTIONS_FILENAME = "options.lua"
-- one rolling backup plus the staged-write witness; load promotes either
-- when the main file is missing or fails to parse
local BACKUP_FILENAME = FILENAME .. ".bak"
local TMP_FILENAME = FILENAME .. ".tmp"
-- Main / backup / staged-witness names for a version (defaults to the active
-- one). The backup is a rolling copy and .tmp is the staged-write witness;
-- load promotes either when the main file is missing or fails to parse.
local function saveNames(version)
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
-- The main save filename for a version -- used by the title screen's
-- CONTINUE gate so it looks for the right game's save.
function SaveData.saveFilename(version)
local main = saveNames(version)
return main
end
-- ------- portable mode
-- LÖVE's save directory is always the OS per-user path derived from the
@@ -469,6 +485,9 @@ end)
-- itself rolls the last good save into .bak and stages the new bytes as
-- a .tmp witness before the swap, so a crash mid-write is recoverable.
function SaveData.save(data, mods)
-- write to the file matching this save's own version, not just the active
-- one, so a Blue playthrough always lands in save_blue.lua
local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(data.version)
if data.options then
SaveData.saveOptions(data.options)
end
@@ -508,7 +527,10 @@ end
-- returns the parsed save plus "tmp"/"bak" when the main file was gone
-- or corrupt and a staged/backup copy was promoted; Game surfaces the
-- recovery on the load report
function SaveData.load()
function SaveData.load(version)
-- version defaults to the active game (set at boot from the launcher);
-- an explicit version lets callers/tests load a specific game's save.
local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(version)
local fs = persistFs(nil)
local data, err = readTable(fs, FILENAME)
local recovered
+55 -4
View File
@@ -31,6 +31,20 @@ local CacheFs = {}
local SEP = package.config:sub(1, 1)
-- Cache-relative paths are prefixed with this before every read/write, so a
-- Blue import lands in blue/ (see src.core.GameVersion) while a Red import
-- keeps the historical root. The launcher sets it per import / per readiness
-- check; it stays "" for Red. Runtime *reads* (require / newImage) do NOT go
-- through here -- CacheFs.mountVersion overlays the active version's subtree
-- onto the un-prefixed paths instead.
CacheFs.prefix = ""
local function withPrefix(rel)
local p = CacheFs.prefix
if p == nil or p == "" then return rel end
return p .. rel
end
-- lazily-resolved windowless mkdir: function(absolutePath) or false when
-- FFI is unavailable (the cache then stays on the save directory)
local mkdirFn = nil
@@ -86,8 +100,9 @@ local function resolveMount()
if okl and lib then
local oks, fn = pcall(function() return lib.PHYSFS_mount end)
if oks and fn then
physfsMountFn = function(d)
local okr, ret = pcall(fn, d, "", 1)
physfsMountFn = function(d, append)
if append == nil then append = true end
local okr, ret = pcall(fn, d, "", append and 1 or 0)
return okr and ret ~= 0
end
break
@@ -97,10 +112,14 @@ local function resolveMount()
return physfsMountFn
end
local function mountReadable(dir)
-- append (default true): the game's own source wins a name clash, matching
-- how the portable cache root has always been mounted. Pass false to
-- prepend, so the mounted tree wins -- used to overlay the active version's
-- cache on top of the root (Red) copy and the source.
local function mountReadable(dir, append)
local fn = resolveMount()
if not fn then return false end
return fn(dir)
return fn(dir, append)
end
-- The portable game folder when the cache should live there, else nil.
@@ -152,6 +171,7 @@ end
-- write cache-relative `rel` (forward-slash path) with the given bytes;
-- returns ok, err like love.filesystem.write
function CacheFs.write(rel, data)
rel = withPrefix(rel)
local root = CacheFs.root()
if root then
ensureParents(root, rel)
@@ -173,6 +193,7 @@ end
-- read cache-relative `rel`; returns the bytes or nil
function CacheFs.read(rel)
rel = withPrefix(rel)
local root = CacheFs.root()
if root then
local f = io.open(realPath(root, rel), "rb")
@@ -186,6 +207,7 @@ end
-- does cache-relative `rel` exist as a file?
function CacheFs.exists(rel)
rel = withPrefix(rel)
local root = CacheFs.root()
if root then
local f = io.open(realPath(root, rel), "rb")
@@ -198,6 +220,7 @@ end
-- remove a single cache-relative file
function CacheFs.remove(rel)
rel = withPrefix(rel)
local root = CacheFs.root()
if root then
os.remove(realPath(root, rel))
@@ -213,6 +236,7 @@ end
-- love.filesystem (the game folder is mounted) and the real files deleted
-- with os.remove; empty directories are harmless and left in place.
function CacheFs.removeTree(rel)
rel = withPrefix(rel)
local root = CacheFs.root()
if not root then return end
local function walk(r)
@@ -229,4 +253,31 @@ function CacheFs.removeTree(rel)
walk(rel)
end
-- Overlay the active version's extracted cache onto the un-prefixed read
-- paths, so require("data.generated.*") and love.graphics.newImage(
-- "assets/generated/*") resolve to that version's files. Red lives at the
-- cache root and needs nothing; Blue lives under blue/ and is *prepended* so
-- it wins over any Red copy at the root and over the game source. Called
-- once at boot, before Game:load (main.lua). Returns true when nothing was
-- needed or the mount succeeded.
function CacheFs.mountVersion(version)
local prefix = require("src.core.GameVersion").cachePrefix(version)
if prefix == "" then return true end -- Red: already at the root
local sub = prefix:gsub("/+$", "") -- "blue/" -> "blue"
-- The cache root is the portable game folder when active, else LÖVE's OS
-- save directory (where love.filesystem wrote blue/...).
local base = CacheFs.root()
if not base and love.filesystem.getSaveDirectory then
base = love.filesystem.getSaveDirectory()
end
if not base then return false end
if mountReadable(base .. SEP .. sub, false) then return true end
-- Fallback when FFI/PHYSFS_mount is unavailable: LÖVE can mount a folder
-- that lives in the save directory by name (prepended: appendToPath=false).
if love.filesystem.mount then
return love.filesystem.mount(sub, "", false)
end
return false
end
return CacheFs
+257 -165
View File
@@ -1,9 +1,19 @@
local GameVersion = require("src.core.GameVersion")
local RomImporter = {}
RomImporter.__index = RomImporter
local ROM_SHA1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a"
local CACHE_MARKER = "rom-cache-v7:" .. ROM_SHA1
-- Cache generation tag; bump to force every imported version to re-extract.
local CACHE_FORMAT = "rom-cache-v7:"
-- The completion marker is written under each version's cache prefix
-- (rom-cache.complete for Red, blue/rom-cache.complete for Blue).
local MARKER_PATH = "rom-cache.complete"
-- The marker a finished import writes for a version: the generation tag plus
-- that version's ROM hash, so both a format bump and a swapped ROM invalidate.
local function markerFor(version)
return CACHE_FORMAT .. GameVersion.info(version).sha1
end
local COMMUNITY_URL = "https://bois.icu"
local TRUST_WARNING = "if you did not get this from bryanthaboi's github " ..
"or a link from the discord that bryanthaboi himself posted, just know " ..
@@ -58,18 +68,27 @@ local PAL = {
disabledInk = { 149, 161, 189 }, -- #95a1bd
}
local function allRequiredFilesExist()
-- CacheFs.exists checks the game folder directly for a portable install,
-- otherwise the save directory through love.filesystem.
-- CacheFs.exists checks the game folder directly for a portable install,
-- otherwise the save directory through love.filesystem. It honors
-- CacheFs.prefix, so we point it at the version's cache subtree (Red at the
-- root, Blue under blue/).
local function allRequiredFilesExist(version)
local CacheFs = require("src.import.CacheFs")
local saved = CacheFs.prefix
CacheFs.prefix = GameVersion.cachePrefix(version)
local ok = true
for _, path in ipairs(REQUIRED_FILES) do
if not CacheFs.exists(path) then return false end
if not CacheFs.exists(path) then ok = false; break end
end
return true
CacheFs.prefix = saved
return ok
end
-- A developer checkout / Python build leaves Red's generated data in the
-- physfs SOURCE at the un-prefixed root; that is always current. Only Red
-- ships this way (Blue is import-only), so this stays a Red-root check.
local function sourceTreeHasData()
if not allRequiredFilesExist() or not love.filesystem.getRealDirectory then
if not allRequiredFilesExist("red") or not love.filesystem.getRealDirectory then
return false
end
local real = love.filesystem.getRealDirectory(REQUIRED_FILES[1])
@@ -127,38 +146,49 @@ local function purgeSaveDirCache()
f:close()
return true
end
if not (saveDirHas(MARKER_PATH) or saveDirHas(REQUIRED_FILES[1])) then
return
-- Purge each version's stale save-directory copy (Red at the root, Blue
-- under blue/) so it cannot shadow the portable game-folder cache.
for _, version in ipairs(GameVersion.ORDER) do
local prefix = GameVersion.cachePrefix(version)
if saveDirHas(prefix .. MARKER_PATH) or saveDirHas(prefix .. REQUIRED_FILES[1]) then
removeTree(prefix .. "data/generated")
removeTree(prefix .. "assets/generated")
love.filesystem.remove(prefix .. MARKER_PATH)
end
end
removeTree("data/generated")
removeTree("assets/generated")
love.filesystem.remove(MARKER_PATH)
end
function RomImporter.isReady()
-- Whether a given game version's ROM has already been imported and cached.
function RomImporter.isReady(version)
version = version or "red"
local CacheFs = require("src.import.CacheFs")
if CacheFs.root() then
-- Portable: the cache lives in the game folder next to the executable
-- (mounted onto the read path for a fused build). Drop any stale
-- save-directory copy that would otherwise shadow it at runtime -- and,
-- for a source run, hide the game folder from sourceTreeHasData below.
-- save-directory copy that would otherwise shadow it at runtime.
purgeSaveDirCache()
end
-- Generated data sitting in the physfs source -- a developer checkout, a
-- Python/bootstrap build, or a source-run portable import -- is always
-- current (as it has always been). A fused portable install is not the
-- source, so it falls through to the version-marker gate.
if sourceTreeHasData() then return true end
return CacheFs.read(MARKER_PATH) == CACHE_MARKER and allRequiredFilesExist()
-- Red generated data in the physfs source (developer checkout / Python
-- build) is always current; Blue is import-only and falls through to the
-- version-marker gate.
if version == "red" and sourceTreeHasData() then return true end
local saved = CacheFs.prefix
CacheFs.prefix = GameVersion.cachePrefix(version)
local marker = CacheFs.read(MARKER_PATH)
CacheFs.prefix = saved
return marker == markerFor(version) and allRequiredFilesExist(version)
end
local function decodeManifest()
local raw, readError = love.filesystem.read("tools/rom_manifest.json")
-- Load the import manifest for a version and confirm it matches that ROM.
local function decodeManifest(version)
local path = GameVersion.info(version).manifest
local raw, readError = love.filesystem.read(path)
if not raw then error("ROM import metadata is missing: " .. tostring(readError)) end
local Json = require("src.link.Json")
local manifest, decodeError = Json.decode(raw)
if not manifest then error("ROM import metadata is invalid: " .. tostring(decodeError)) end
assert(manifest.romSha1 == ROM_SHA1, "ROM import metadata version mismatch")
assert(manifest.romSha1 == GameVersion.info(version).sha1,
"ROM import metadata version mismatch")
return manifest
end
@@ -215,16 +245,19 @@ local function scanForRom()
return nil
end
local function chooseRom()
local function chooseRom(promptName)
promptName = promptName or "Pokemon"
local prompt = "Choose your " .. promptName .. " ROM"
local platform = love.system.getOS()
if platform == "OS X" then
return commandOutput(
[[osascript -e 'POSIX path of (choose file with prompt "Choose your Pokemon Red ROM" of type {"gb"})' 2>/dev/null]])
([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"gb"})' 2>/dev/null]])
:format(prompt))
elseif platform == "Windows" then
local script = table.concat({
"Add-Type -AssemblyName System.Windows.Forms;",
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
"$d.Title='Choose your Pokemon Red ROM';",
"$d.Title='" .. prompt .. "';",
"$d.Filter='Game Boy ROM (*.gb)|*.gb|All files (*.*)|*.*';",
"if($d.ShowDialog() -eq 'OK'){[Console]::Write($d.FileName)}",
})
@@ -232,7 +265,8 @@ local function chooseRom()
'powershell -NoProfile -STA -Command "' .. script .. '"')
elseif platform == "Linux" then
local path = commandOutput(
[[zenity --file-selection --title="Choose your Pokemon Red ROM" --file-filter="Game Boy ROM | *.gb" 2>/dev/null]])
([[zenity --file-selection --title="%s" --file-filter="Game Boy ROM | *.gb" 2>/dev/null]])
:format(prompt))
if path then return path end
return commandOutput(
[[kdialog --getopenfilename "$HOME" "*.gb|Game Boy ROM" 2>/dev/null]])
@@ -240,47 +274,53 @@ local function chooseRom()
return nil
end
-- onComplete hands off to the game (boot). opts:
-- ready -- this game's ROM is already imported: open on the Play state
-- launcher -- interactive launcher: a fresh import lands on the Play state
-- instead of auto-booting (headless callers omit this and keep
-- the old import-then-boot behavior)
-- romName -- filename shown next to Play when already imported
-- The launcher runs Red and Blue as two independent columns. Each dropped or
-- chosen ROM is routed to its version by SHA-1, extracted into that version's
-- own cache (Red at the root, Blue under blue/), so both can be imported and
-- played side by side. onComplete(version) hands the chosen game off to boot.
-- opts: launcher (a fresh import stays on the launcher instead of auto-booting),
-- forceImport (treat every version as not-yet-imported, so re-import is forced).
function RomImporter.new(onComplete, opts)
opts = opts or {}
local previousMarker = require("src.import.CacheFs").read(MARKER_PATH)
local returning = previousMarker ~= nil and previousMarker ~= CACHE_MARKER
local android = love.system.getOS() == "Android"
local CacheFs = require("src.import.CacheFs")
local self = setmetatable({
onComplete = onComplete,
launcher = opts.launcher or false,
forceImport = opts.forceImport or false,
android = android,
logo = love.graphics.newImage("assets/logo/logo.png"),
bcg = love.graphics.newImage("assets/logo/bcg.png"),
state = opts.ready and "ready" or "waiting",
romName = opts.romName or "pokemon_red.gb",
returning = returning,
android = android,
status = returning and "More assets are needed from your ROM"
or "Choose or drop a Pokemon Red ROM",
detail = returning
and "This update pulls a few more things from your ROM. "
.. "Please re-import it to continue (it's quick)."
or "The ROM is verified before any files are created.",
progress = 0,
stageCurrent = 0,
stageTotal = 1,
pulse = 0,
ready = {}, returning = {}, romName = {},
importing = nil, -- the version currently extracting, or nil
workState = nil, -- "working" / "complete" / "error" for that import
errorVersion = nil, -- which column shows the current error
notice = nil, -- { version, status, detail } transient hint (Android)
status = "", detail = "", progress = 0,
stageCurrent = 0, stageTotal = 1, pulse = 0,
}, RomImporter)
-- Only hunt for a ROM when one is actually needed; an already-imported
-- game opens straight on Play.
if android and self.state ~= "ready" then
self.status = returning and "More ROM assets needed" or "Get your Pokemon Red ROM (.gb) in"
self.detail = "Tap Choose ROM to pick your file"
for _, version in ipairs(GameVersion.ORDER) do
local info = GameVersion.info(version)
local ready = RomImporter.isReady(version) and not self.forceImport
self.ready[version] = ready
-- a marker present but for an older cache generation / different ROM means
-- "update required" (re-import) rather than a clean first-run choose
local saved = CacheFs.prefix
CacheFs.prefix = info.cachePrefix
local marker = CacheFs.read(MARKER_PATH)
CacheFs.prefix = saved
self.returning[version] =
(not ready) and marker ~= nil and marker ~= markerFor(version)
self.romName[version] = "pokemon_" .. info.id .. ".gb"
end
-- Android has no native picker until the player copies a ROM into the
-- external folder; import any .gb already there (routed by SHA-1) unless
-- both games are already imported.
if android and not (self.ready.red and self.ready.blue) then
local name = scanForRom()
if name then
self:startData(love.filesystem.read(name), name)
end
if name then self:startData(love.filesystem.read(name), name) end
end
return self
@@ -292,18 +332,18 @@ end
-- it into the folder scanForRom checks, so a rescan on refocus picks it up
-- without the player needing to tap the button again.
function RomImporter:focus(f)
if not (f and self.android
and (self.state == "waiting" or self.state == "error")) then
return
end
if not (f and self.android and self.workState ~= "working") then return end
if self.ready.red and self.ready.blue then return end
local name = scanForRom()
if name then
self:startData(love.filesystem.read(name), name)
end
if name then self:startData(love.filesystem.read(name), name) end
end
function RomImporter:setError(message)
self.state = "error"
function RomImporter:setError(message, version)
require("src.import.CacheFs").prefix = ""
self.workState = "error"
self.errorVersion = version or self.importing or self.chooseVersion or "red"
self.importing = nil
self.notice = nil
self.status = "That ROM could not be imported"
self.detail = tostring(message)
self.progress = 0
@@ -311,42 +351,52 @@ function RomImporter:setError(message)
self.romData = nil
end
-- Verify + extract a ROM. The version is decided by the ROM's own SHA-1, so
-- dropping a Red or Blue cart into either column always lands in the right one.
function RomImporter:startData(data, displayName)
if self.state == "working" then return end
if self.workState == "working" then return end
if type(data) ~= "string" then
self:setError("The selected file could not be read.")
return
end
if #data ~= 1024 * 1024 then
self:setError(("Expected a 1 MiB Pokemon Red ROM; this file is %.2f MiB.")
self:setError(("Expected a 1 MiB Game Boy ROM; this file is %.2f MiB.")
:format(#data / 1024 / 1024))
return
end
local actualHash = sha1(data)
local version = GameVersion.forSha1(actualHash)
if not version then
self:setError(("Unsupported ROM (SHA-1 %s). Use an unmodified US Pokemon "
.. "Red or Blue ROM."):format(actualHash))
return
end
local info = GameVersion.info(version)
self.state = "working"
self.status = "Verifying ROM"
self.detail = displayName or "Pokemon Red"
self.importing = version
self.workState = "working"
self.notice = nil
self.status = "Verifying " .. info.displayName
self.detail = displayName or info.displayName
self.progress = 0
self.romData = data
self.worker = coroutine.create(function()
local actualHash = sha1(self.romData)
if actualHash ~= ROM_SHA1 then
error(("Unsupported ROM (SHA-1 %s). Use an unmodified US Pokemon Red ROM.")
:format(actualHash))
end
self.status = "Preparing private game data"
coroutine.yield()
-- Clear any previous cache from both possible homes: the save directory
-- (removeTree) and, for a portable install, the game folder (CacheFs).
-- Redirect every cache write to this version's subtree, then clear only
-- that version's previous cache from both homes (save directory and, for
-- a portable install, the game folder). The other version is untouched.
local CacheFs = require("src.import.CacheFs")
removeTree("data/generated")
removeTree("assets/generated")
love.filesystem.remove(MARKER_PATH)
local prefix = info.cachePrefix
CacheFs.prefix = prefix
removeTree(prefix .. "data/generated")
removeTree(prefix .. "assets/generated")
love.filesystem.remove(prefix .. MARKER_PATH)
CacheFs.removeTree("data/generated")
CacheFs.removeTree("assets/generated")
CacheFs.remove(MARKER_PATH)
local manifest = decodeManifest()
local manifest = decodeManifest(version)
local RomExtractor = require("src.import.RomExtractor")
local extractor = RomExtractor.new(self.romData, manifest,
function(progress, total, stage, current, stageTotal)
@@ -360,24 +410,25 @@ function RomImporter:startData(data, displayName)
self.romData = nil
collectgarbage("collect")
-- Written last: the marker is what isReady() checks, so it must only
-- appear once every required file is in place. CacheFs puts it beside
-- the cache -- the game folder for a portable install, else the save
-- directory.
local ok, writeError = CacheFs.write(MARKER_PATH, CACHE_MARKER)
-- appear once every required file is in place.
local ok, writeError = CacheFs.write(MARKER_PATH, markerFor(version))
CacheFs.prefix = "" -- restore the default so later writes stay at the root
if not ok then error("could not finish the private cache: " .. tostring(writeError)) end
self.state = "complete"
self.ready[version] = true
self.returning[version] = false
self.romName[version] = (displayName
and (displayName:match("[^/\\]+$") or displayName)) or self.romName[version]
self.importing = nil
self.workState = "complete"
self.completeVersion = version
self.status = "Ready"
self.detail = "Starting Pokemon Red..."
self.detail = "Starting " .. info.displayName .. "..."
self.progress = 1
if self.launcher then
-- Stay on the launcher and show Play for the game just imported; the
-- player presses Play to boot it.
self.romName = (displayName and (displayName:match("[^/\\]+$") or displayName))
or self.romName
self.state = "ready"
-- Stay on the launcher; the player presses Play to boot the new game.
return
end
if self.onComplete then self.onComplete() end
if self.onComplete then self.onComplete(version) end
end)
end
@@ -392,7 +443,7 @@ function RomImporter:startPath(path)
end
function RomImporter:filedropped(file)
if self.state == "working" then return end
if self.workState == "working" then return end
local data, readError = readDroppedFile(file)
if not data then
self:setError("Could not read the dropped file: " .. tostring(readError))
@@ -401,25 +452,30 @@ function RomImporter:filedropped(file)
self:startData(data, file:getFilename())
end
function RomImporter:choose()
if self.state == "working" then return end
-- Open a picker (or, on Android, scan the external folder) for a column. The
-- version argument only titles the dialog and steers error/notice text; the
-- picked ROM is still routed by its SHA-1, so choosing a Blue cart in the Red
-- column imports Blue.
function RomImporter:choose(version)
if self.workState == "working" then return end
self.chooseVersion = version or "red"
if self.android then
local name = scanForRom()
if name then
self:startData(love.filesystem.read(name), name)
elseif not love.system.pickFile() then
-- Picker unavailable (API < 19, or no document-picker app installed):
-- fall back to the USB folder-drop path. Not setError(): that status
-- text ("could not be imported") reads as a rejected file, not "none
-- found yet" -- and detail only renders 3 wrapped lines, so the path
-- again gets the line to itself.
self.state = "waiting"
self.status = "No picker available, copy your ROM into:"
self.detail = love.filesystem.getSaveDirectory()
-- fall back to the USB folder-drop path as a friendly notice, not an
-- error (which would read as a rejected file).
self.notice = {
version = self.chooseVersion,
status = "No picker available, copy your ROM into:",
detail = love.filesystem.getSaveDirectory(),
}
end
return
end
local path = chooseRom()
local path = chooseRom(GameVersion.info(self.chooseVersion).displayName)
if path then
self:startPath(path)
elseif love.system.getOS() ~= "OS X"
@@ -431,7 +487,7 @@ end
function RomImporter:update(dt)
self.pulse = self.pulse + dt
if self.state ~= "working" or not self.worker then return end
if self.workState ~= "working" or not self.worker then return end
local started = love.timer.getTime()
repeat
local ok, workerError = coroutine.resume(self.worker)
@@ -448,16 +504,20 @@ function RomImporter:update(dt)
end
-- Player pressed Play on a game whose ROM is imported: hand off to boot.
function RomImporter:play()
if self.onComplete then self.onComplete() end
function RomImporter:play(version)
if self.workState == "working" then return end
if not self.ready[version] then return end
if self.onComplete then self.onComplete(version) end
end
-- "re-import" from the Play state: drop back to the choose/drop UI so a fresh
-- ROM can be selected (the extract itself replaces the old cache).
function RomImporter:reimport()
if self.state ~= "ready" then return end
self.state = "waiting"
self.returning = false
-- "re-import" a column: drop it back to the choose/drop state so a fresh ROM
-- can be selected (the extract replaces that version's cache).
function RomImporter:reimport(version)
if self.workState == "working" then return end
if not self.ready[version] then return end
self.ready[version] = false
self.returning[version] = false
self.chooseVersion = version
end
local function clamp(v, lo, hi)
@@ -711,48 +771,60 @@ function RomImporter:draw()
+ buttonH + gapButton + hintH + padBot
end
-- Red column: live, driven by the import state machine.
local redHint = self.android and "or copy the .gb via USB" or "or drop the .gb file here"
local redSpec = {
accent = PAL.red, interior = PAL.cardRed,
alpha = 1, glowScale = 1, period = 2.6,
}
if self.state == "ready" then
redSpec.heading = "Red ROM ready"
redSpec.detail = "Your ROM is verified. Press Play to start."
redSpec.button = { kind = "play", text = "Play Red" }
redSpec.link = { name = self.romName }
elseif self.state == "working" or self.state == "complete" then
redSpec.heading = self.status
redSpec.detail = self.detail
redSpec.progress = self.progress or 0
elseif self.state == "error" then
redSpec.heading = "That ROM could not be imported"
redSpec.detail = self.detail
redSpec.button = { kind = "choose", text = "Choose ROM" }
redSpec.hint = redHint
else -- waiting
if self.android then
redSpec.heading = self.status
redSpec.detail = self.detail
elseif self.returning then
redSpec.heading = "Update required"
redSpec.detail = "This build needs a few more things from your ROM. Re-import to continue."
-- Red and Blue are each live: a column shows Play once its ROM is imported,
-- Choose ROM / drag-drop before that, and a progress bar while extracting.
local dropHint = self.android and "or copy the .gb via USB"
or "or drop the .gb file here"
local function columnSpec(version, style)
local info = GameVersion.info(version)
local spec = {
version = version,
accent = style.accent, interior = style.interior,
alpha = 1, glowScale = 1, period = style.period,
}
local importing = self.importing == version
local erroring = self.workState == "error" and self.errorVersion == version
local notice = self.notice and self.notice.version == version and self.notice
if importing and
(self.workState == "working" or self.workState == "complete") then
spec.heading = self.status
spec.detail = self.detail
spec.progress = self.progress or 0
elseif self.ready[version] then
spec.heading = info.label .. " ROM ready"
spec.detail = "Your ROM is verified. Press Play to start."
spec.button = { kind = "play", text = "Play " .. info.label }
spec.link = { name = self.romName[version] }
elseif erroring then
spec.heading = "That ROM could not be imported"
spec.detail = self.detail
spec.button = { kind = "choose", text = "Choose ROM" }
spec.hint = dropHint
elseif notice then
spec.heading = notice.status
spec.detail = notice.detail
spec.button = { kind = "choose", text = "Choose ROM" }
spec.hint = dropHint
elseif self.returning[version] then
spec.heading = "Update required"
spec.detail = "This build needs a few more things from your "
.. info.label .. " ROM. Re-import to continue."
spec.button = { kind = "choose", text = "Choose ROM" }
spec.hint = dropHint
else
redSpec.heading = "Choose or drop a Red ROM"
redSpec.detail = "The ROM is verified before any files are created."
spec.heading = "Choose or drop a " .. info.label .. " ROM"
spec.detail = "The ROM is verified before any files are created."
spec.button = { kind = "choose", text = "Choose ROM" }
spec.hint = dropHint
end
redSpec.button = { kind = "choose", text = "Choose ROM" }
redSpec.hint = redHint
return spec
end
-- Blue and Yellow columns: lit placeholders until those games are supported.
local blueSpec = {
accent = PAL.blue, interior = PAL.cardBlue,
alpha = 0.92, glowScale = 0.5, period = 3.4,
heading = "Choose or drop a Blue ROM", detail = "Blue support is on the way.",
button = { kind = "disabled", text = "Coming soon" }, hint = "not yet available",
}
local redSpec = columnSpec("red",
{ accent = PAL.red, interior = PAL.cardRed, period = 2.6 })
local blueSpec = columnSpec("blue",
{ accent = PAL.blue, interior = PAL.cardBlue, period = 3.4 })
-- Yellow stays a lit placeholder until that game is supported.
local yellowSpec = {
accent = PAL.gold, interior = PAL.cardGold,
alpha = 0.92, glowScale = 0.5, period = 3.8,
@@ -885,9 +957,20 @@ function RomImporter:draw()
return buttonRect, linkRect
end
self.redButton, self.reimportRect = drawCard(0, redSpec, top)
drawCard(third, blueSpec, top)
drawCard(2 * third, yellowSpec, top)
-- Per-column hit rects, rebuilt each frame and keyed by version so clicks
-- route to the right game (Yellow has none -- it is inert).
self.buttons = {}
self.reimportRects = {}
local function place(colX, spec)
local b, l = drawCard(colX, spec, top)
if spec.version then
self.buttons[spec.version] = b
self.reimportRects[spec.version] = l
end
end
place(0, redSpec)
place(third, blueSpec)
place(2 * third, yellowSpec)
-- logo, centred over the split, with a gentle bob + gold glow
local bob = math.sin(pulse * (2 * math.pi / 4)) * 6 * s
@@ -978,19 +1061,28 @@ function RomImporter:mousepressed(x, y, button)
love.system.openURL(COMMUNITY_URL)
return
end
if self.state == "working" or self.state == "complete" then return end
if self.state == "ready" then
if inside(self.reimportRect, x, y) then self:reimport()
elseif inside(self.redButton, x, y) then self:play() end
return
if self.workState == "working" then return end
local buttons = self.buttons or {}
local reimports = self.reimportRects or {}
for _, version in ipairs(GameVersion.ORDER) do
if self.ready[version] then
if inside(reimports[version], x, y) then self:reimport(version); return
elseif inside(buttons[version], x, y) then self:play(version); return end
elseif inside(buttons[version], x, y) then
self:choose(version); return
end
end
if inside(self.redButton, x, y) then self:choose() end
end
function RomImporter:keypressed(key)
if self.state == "working" or self.state == "complete" then return end
if self.workState == "working" then return end
if key == "return" or key == "space" or key == "kpenter" then
if self.state == "ready" then self:play() else self:choose() end
-- Keyboard is ambiguous across columns: Play the first imported game,
-- otherwise open the Red picker.
for _, version in ipairs(GameVersion.ORDER) do
if self.ready[version] then self:play(version); return end
end
self:choose("red")
end
end
+29 -3
View File
@@ -10,6 +10,8 @@
-- RED++ swaps the named-palette pack for pokered-gbc SuperPalettes
-- (data/palettes_gbc.lua), including per-species mon colors.
local GameVersion = require("src.core.GameVersion")
local PaletteFX = {}
local shader -- false = unavailable (headless / no shader support)
@@ -47,6 +49,26 @@ PaletteFX.GBC_OBJ = {
{ 255, 255, 255 }, { 123, 255, 49 }, { 0, 132, 0 }, { 0, 0, 0 },
}
-- OG BLUE: Pokemon Blue's Game Boy Color boot-ROM auto-palette. Same
-- one-global-pair scheme as OG RED (Blue also ships no CGB code), but the
-- boot ROM colorizes the background blue instead of red -- so "OG RED" for a
-- Blue playthrough is white -> light blue -> dark blue -> black, mirroring
-- GBC_BG channel-for-channel so the blue reads at the same brightness. The
-- OBJ (sprite) palette stays the same green, matching how Red and Blue share
-- the green-character look on a Game Boy Color.
PaletteFX.GBC_BG_BLUE = {
{ 255, 255, 255 }, { 132, 132, 255 }, { 58, 58, 148 }, { 0, 0, 0 },
}
-- The active game's OG boot-ROM background palette: blue for a Blue
-- playthrough, red otherwise. White (index 1) and black (index 4) are
-- identical across versions, so callers that only touch the endpoints
-- (e.g. BattleState's zone white/black snap) need no version branch.
function PaletteFX.ogBg()
if GameVersion.isBlue() then return PaletteFX.GBC_BG_BLUE end
return PaletteFX.GBC_BG
end
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
function PaletteFX.shader()
@@ -228,7 +250,7 @@ end
-- background tile drew. Objects do not come through here (they bake
-- GBC_OBJ green), so this stays a BG-only hook.
function PaletteFX.pal(data, name)
if PaletteFX.mode == "ogred" then return PaletteFX.GBC_BG end
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
local p = PaletteFX.pack(data)
local c = p and p.palettes[name]
if c then return c end
@@ -248,7 +270,7 @@ function PaletteFX.monPal(data, species, transformed)
-- the tilemap, colored by BGP), so it wears the global red BG palette, not
-- a per-species one -- matching the hardware capture where both mons are
-- red/pink on the white field.
if PaletteFX.mode == "ogred" then return PaletteFX.GBC_BG end
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
local p = PaletteFX.pack(data)
if not p then return nil end
if transformed then
@@ -490,7 +512,11 @@ function PaletteFX.applyOptions(opts)
end
function PaletteFX.modeLabel(mode)
return PaletteFX.MODE_LABELS[mode or PaletteFX.mode] or "GBC"
mode = mode or PaletteFX.mode
-- The GBC boot-ROM mode wears the running game's name: it is red for Red and
-- blue for Blue (see ogBg), so a Blue playthrough shows "OG BLUE".
if mode == "ogred" and GameVersion.isBlue() then return "OG BLUE" end
return PaletteFX.MODE_LABELS[mode] or "GBC"
end
-- When a state exposes no SGB zones but COLORS needs a forced palette
+32 -9
View File
@@ -5,6 +5,7 @@
local Font = require("src.render.Font")
local Music = require("src.core.Music")
local GameVersion = require("src.core.GameVersion")
local TitleState = {}
TitleState.__index = TitleState
@@ -30,6 +31,13 @@ local CYCLE_SPECIES = {
"PIKACHU", "CLEFAIRY", "RHYDON", "ABRA", "GASTLY", "DITTO",
"PIDGEOTTO", "ONIX", "PONYTA", "MAGIKARP",
}
-- Blue's TitleMons (data/pokemon/title_mons.asm, _BLUE branch): STARTER2 is
-- Squirtle, STARTER1 Charmander, STARTER3 Bulbasaur.
local BLUE_CYCLE_SPECIES = {
"SQUIRTLE", "CHARMANDER", "BULBASAUR", "MANKEY", "HITMONLEE",
"VULPIX", "CHANSEY", "AERODACTYL", "JOLTEON", "SNORLAX",
"GLOOM", "POLIWAG", "DODUO", "PORYGON", "GENGAR", "RAICHU",
}
local CYCLE_FRAMES = 240 -- the original waits ~4s between picks
local function tryImage(path)
@@ -61,9 +69,13 @@ function TitleState.new(game, opts)
self.version = tryImage(imagePath(title.versionRibbon or title.version)
or "assets/generated/title/red_version.png")
self.player = tryImage("assets/generated/title/player.png")
self.blue = GameVersion.isBlue()
-- Blue cycles its own title mons and prints its ribbon contiguously; a
-- field.title.cycleSpecies override (mods / total conversions) still wins.
local defaultCycle = self.blue and BLUE_CYCLE_SPECIES or CYCLE_SPECIES
self.cycleSpecies = (type(title.cycleSpecies) == "table"
and #title.cycleSpecies > 0)
and title.cycleSpecies or CYCLE_SPECIES
and title.cycleSpecies or defaultCycle
self.sprites = {} -- species -> image or false (load failed)
self.cycleIndex = 1
self.timer = 0
@@ -92,8 +104,10 @@ end
local function hasSave()
local ok, info = pcall(function()
-- the active game's save file (save.lua for Red, save_blue.lua for Blue)
local name = require("src.core.SaveData").saveFilename(GameVersion.get())
return love.filesystem and love.filesystem.getInfo
and love.filesystem.getInfo("save.lua") or nil
and love.filesystem.getInfo(name) or nil
end)
return ok and info ~= nil
end
@@ -211,17 +225,26 @@ function TitleState:draw()
love.graphics.draw(self.logo, 16, 8)
else
love.graphics.setColor(0, 0, 0, 1)
Font.draw("POKéMON RED", (160 - 11 * 8) / 2, 24)
Font.draw(self.blue and "POKéMON BLUE" or "POKéMON RED",
(160 - 12 * 8) / 2, 24)
love.graphics.setColor(1, 1, 1, 1)
end
if self.version then
-- the strip holds Red+Green+Version glyphs; the tilemap prints
-- tiles $60,$61 ("Red"), a space, then $65-$69 ("Version")
local iw, ih = self.version:getDimensions()
love.graphics.draw(self.version,
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56, 64)
love.graphics.draw(self.version,
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64)
if self.blue then
-- Blue prints its ribbon contiguously ("Blue Version", hlcoord 7,8).
-- The extracted strip packs those eight glyph tiles into image tiles
-- 0..7 (tiles 8..9 are blank), so draw that 64px run at px (56, 64).
love.graphics.draw(self.version,
love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56, 64)
else
-- Red's strip holds Red+Green+Version glyphs; the tilemap prints
-- tiles $60,$61 ("Red"), a space, then $65-$69 ("Version").
love.graphics.draw(self.version,
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56, 64)
love.graphics.draw(self.version,
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64)
end
end
local sprite = self:currentSprite()
if sprite then
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""Derive the Pokemon Blue import manifest from the shipped Red manifest.
Red and Blue are assembled from one pokered source tree; the two ROMs are
byte-identical except for a small set of version-gated regions (wild
encounters, SGB/GBC palettes, the title ribbon, credits text, preset names).
Everything the manifest carries is either identical between the versions or
derivable, so rather than re-parsing pokered from scratch -- which would drag
in unrelated drift between the checked-out source and the *shipped* Red
manifest -- we take the shipped Red manifest verbatim and override only the
fields that genuinely differ for Blue:
* romSha1 -- Blue's ROM hash.
* symbols -- the 23 map-header symbols in bank $1D that shift by one
byte in Blue (everything else, audio included, is at the
same address). Re-sourced from pokeblue.sym.
* field.presetNames -- player/rival name presets swap (Blue's player is
BLUE/GARY/JOHN, rival RED/ASH/JACK).
* field.credits -- the "BLUE VERSION STAFF" title line (and any staff
reordering) parsed with _BLUE defined.
Every other field -- maps, trainers, text, audio addresses, trainer party
overrides, tilesets, etc. -- is inherited unchanged, so Blue behaves exactly
like Red wherever the two games are identical. The version-specific *content*
that is not in the manifest (wild Pokemon, palette colours, the ribbon
graphic, credits strings) is decoded from the Blue ROM at import time, which
works because the symbol addresses above now point at Blue's data.
"""
from __future__ import annotations
import argparse
import copy
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from extract import field, util # noqa: E402
from rom_data import CANONICAL_BLUE_SHA1, SymbolTable # noqa: E402
DEV = "/Users/bryanbassett/Documents/development"
DEFAULT_RED = os.path.join(os.path.dirname(__file__), "rom_manifest.json")
DEFAULT_OUT = os.path.join(os.path.dirname(__file__), "rom_manifest_blue.json")
DEFAULT_POKERED = os.path.join(DEV, "pokered")
DEFAULT_SYMBOLS = os.path.join(DEV, "decprep/pokered-symbols/pokeblue.sym")
def derive(red, pokered, symbols_path):
"""Return the Blue manifest derived from the Red manifest dict."""
blue = copy.deepcopy(red)
blue["romSha1"] = CANONICAL_BLUE_SHA1
# Re-source every symbol the manifest already references from Blue's .sym.
# The name set is identical between versions; only 23 bank-$1D map headers
# actually move, but resolving the whole set keeps this robust to future
# shifts and fails loudly if pokeblue.sym is ever missing a name.
blue_symbols = SymbolTable(symbols_path)
resolved, missing = {}, []
for name in red["symbols"]:
symbol = blue_symbols.by_name.get(name)
if symbol is None:
missing.append(name)
continue
resolved[name] = [symbol.bank, symbol.address]
if missing:
raise SystemExit(
"pokeblue.sym is missing symbols the manifest needs: "
+ ", ".join(sorted(missing)[:10])
+ (" ..." if len(missing) > 10 else ""))
blue["symbols"] = resolved
# Version-gated field bits. Calling the parsers directly (rather than
# through field_metadata) sidesteps field_metadata's Red-only sanity
# checks, which is exactly what we want for a Blue build.
saved = util.ASM_DEFINES
util.ASM_DEFINES = {"_BLUE"}
try:
blue["field"]["presetNames"] = field.parse_preset_names(pokered)
blue["field"]["credits"] = field.parse_credits(pokered)
finally:
util.ASM_DEFINES = saved
# Sanity: Blue's presets must be the swapped set, and the credits banner
# must say BLUE. A silent Red-through here would be a hard-to-spot bug.
presets = blue["field"]["presetNames"]
if "BLUE" not in presets["player"] or "RED" not in presets["rival"]:
raise SystemExit("Blue preset-name parse did not swap player/rival")
banner = blue["field"]["credits"]["screens"][0]["lines"][1]["text"]
if banner != "BLUE VERSION STAFF":
raise SystemExit(f"Blue credits banner is {banner!r}, expected BLUE")
return blue
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--red", default=DEFAULT_RED,
help="shipped Red manifest to derive from")
parser.add_argument("--pokered", default=DEFAULT_POKERED,
help="pokered source checkout (for _BLUE field bits)")
parser.add_argument("--symbols", default=DEFAULT_SYMBOLS,
help="pokeblue.sym symbol file")
parser.add_argument("--out", default=DEFAULT_OUT)
args = parser.parse_args()
pokered = os.path.abspath(args.pokered)
if not os.path.isfile(os.path.join(pokered, "main.asm")):
raise SystemExit(f"{pokered} is not a pokered checkout")
with open(args.red, encoding="utf-8") as f:
red = json.load(f)
blue = derive(red, pokered, os.path.abspath(args.symbols))
with open(args.out, "w", encoding="utf-8", newline="\n") as f:
json.dump(blue, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"wrote {args.out}")
if __name__ == "__main__":
main()
+1
View File
@@ -9,6 +9,7 @@ from dataclasses import dataclass
CANONICAL_RED_SHA1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a"
CANONICAL_BLUE_SHA1 = "d7037c83e1ae5b39bde3c30787637ba1d4c48ce2"
ROM_BANK_SIZE = 0x4000
File diff suppressed because it is too large Load Diff