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) -- Game Corner prize lists (data/events/prizes.asm, prize_mon_levels.asm).
local PRIZES = { -- 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 = "ABRA", level = 9, cost = 180 },
{ kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 }, { kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 },
{ kind = "mon", species = "NIDORINA", level = 17, cost = 1200 }, { kind = "mon", species = "NIDORINA", level = 17, cost = 1200 },
{ kind = "mon", species = "DRATINI", level = 18, cost = 2800 }, { kind = "mon", species = "DRATINI", level = 18, cost = 2800 },
{ kind = "mon", species = "SCYTHER", level = 25, cost = 5500 }, { kind = "mon", species = "SCYTHER", level = 25, cost = 5500 },
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 }, { kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
{ kind = "item", item = "TM_DRAGON_RAGE", cost = 3300 }, PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
{ kind = "item", item = "TM_HYPER_BEAM", cost = 5500 },
{ kind = "item", item = "TM_SUBSTITUTE", cost = 7700 },
} }
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 function prizeCounter(game, ow, npc, done)
local ListMenu = require("src.ui.ListMenu") local ListMenu = require("src.ui.ListMenu")
local Commands = require("src.script.Commands") local Commands = require("src.script.Commands")
local items = {} local items = {}
for _, p in ipairs(PRIZES) do for _, p in ipairs(activePrizes()) do
local label local label
if p.kind == "mon" then if p.kind == "mon" then
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level) 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))) return math.max(1, math.floor(require("src.core.GameSpeed").clamp(speedOverride)))
end 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 = require("src.core.Game")
Game:load() Game:load()
if os.getenv("POKEPORT_AUTOPILOT") then if os.getenv("POKEPORT_AUTOPILOT") then
@@ -68,9 +80,12 @@ function love.load(args)
end end
local RomImporter = require("src.import.RomImporter") local RomImporter = require("src.import.RomImporter")
local ready = RomImporter.isReady()
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1" local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
local importPath = os.getenv("POKEPORT_IMPORT_ROM") 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 -- 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 -- 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 -- explicit ROM path all bypass the interactive launcher and keep today's
@@ -80,28 +95,31 @@ function love.load(args)
if scripted then if scripted then
if forceImport or not ready 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 if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then
love.event.quit() love.event.quit()
return return
end end
Importer = nil Importer = nil
bootGame() bootGame(version or scriptedVersion)
end) end)
if importPath then Importer:startPath(importPath) end if importPath then Importer:startPath(importPath) end
return return
end end
bootGame() bootGame(scriptedVersion)
return return
end end
-- Interactive: the launcher always runs. Its Red column shows Play when the -- Interactive: the launcher always runs. Red and Blue are each live: a
-- ROM is already imported or Choose ROM / drag-drop when it is not (Blue and -- column shows Play when that game's ROM is already imported, or Choose ROM
-- Yellow are placeholders); pressing Play boots the chosen game. -- / drag-drop when it is not (Yellow is still a placeholder). Any dropped
Importer = RomImporter.new(function() -- .gb is routed to Red or Blue by its SHA-1; pressing Play boots that game.
Importer = RomImporter.new(function(version)
Importer = nil Importer = nil
bootGame() bootGame(version)
end, { ready = ready and not forceImport, launcher = true }) end, { launcher = true, forceImport = forceImport })
end end
function love.update(dt) function love.update(dt)
+1 -1
View File
@@ -60,7 +60,7 @@ say "packing game.love"
LOVE_FILE="$WORK/game.love" LOVE_FILE="$WORK/game.love"
rm -f "$LOVE_FILE" rm -f "$LOVE_FILE"
(cd "$ROOT" && zip -q -9 -r "$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/*') -x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \ if unzip -Z1 "$LOVE_FILE" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then | grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
+1 -1
View File
@@ -137,7 +137,7 @@ pack_game_love() {
mkdir -p "$EMBED_ASSETS" mkdir -p "$EMBED_ASSETS"
rm -f "$LOVE_FILE" rm -f "$LOVE_FILE"
(cd "$ROOT" && zip -q -9 -r "$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 '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*') -x 'data/generated/*' -x 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \ if unzip -Z1 "$LOVE_FILE" \
+1 -1
View File
@@ -166,7 +166,7 @@ pack_game_love() {
rm -f "$LOVE_FILE" rm -f "$LOVE_FILE"
# Same payload as scripts/build.sh / build_android.sh: game sources only. # Same payload as scripts/build.sh / build_android.sh: game sources only.
(cd "$ROOT" && zip -q -9 -r "$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 '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*') -x 'data/generated/*' -x 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \ if unzip -Z1 "$LOVE_FILE" \
+6 -1
View File
@@ -99,7 +99,12 @@ end
-- total conversion overrides. Threaded into SaveData so persistence stays -- total conversion overrides. Threaded into SaveData so persistence stays
-- free of a Data dependency. -- free of a Data dependency.
function Game:bootConfig() 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 end
-- the title screen with its NEW GAME / CONTINUE wiring; used at boot -- 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 Boxes = require("src.pokemon.Boxes")
local Bag = require("src.inventory.Bag") local Bag = require("src.inventory.Bag")
local GameVersion = require("src.core.GameVersion")
local SaveData = {} 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" 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 -- Main / backup / staged-witness names for a version (defaults to the active
local BACKUP_FILENAME = FILENAME .. ".bak" -- one). The backup is a rolling copy and .tmp is the staged-write witness;
local TMP_FILENAME = FILENAME .. ".tmp" -- 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 -- ------- portable mode
-- LÖVE's save directory is always the OS per-user path derived from the -- 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 -- 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. -- a .tmp witness before the swap, so a crash mid-write is recoverable.
function SaveData.save(data, mods) 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 if data.options then
SaveData.saveOptions(data.options) SaveData.saveOptions(data.options)
end end
@@ -508,7 +527,10 @@ end
-- returns the parsed save plus "tmp"/"bak" when the main file was gone -- 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 -- or corrupt and a staged/backup copy was promoted; Game surfaces the
-- recovery on the load report -- 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 fs = persistFs(nil)
local data, err = readTable(fs, FILENAME) local data, err = readTable(fs, FILENAME)
local recovered local recovered
+55 -4
View File
@@ -31,6 +31,20 @@ local CacheFs = {}
local SEP = package.config:sub(1, 1) 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 -- lazily-resolved windowless mkdir: function(absolutePath) or false when
-- FFI is unavailable (the cache then stays on the save directory) -- FFI is unavailable (the cache then stays on the save directory)
local mkdirFn = nil local mkdirFn = nil
@@ -86,8 +100,9 @@ local function resolveMount()
if okl and lib then if okl and lib then
local oks, fn = pcall(function() return lib.PHYSFS_mount end) local oks, fn = pcall(function() return lib.PHYSFS_mount end)
if oks and fn then if oks and fn then
physfsMountFn = function(d) physfsMountFn = function(d, append)
local okr, ret = pcall(fn, d, "", 1) if append == nil then append = true end
local okr, ret = pcall(fn, d, "", append and 1 or 0)
return okr and ret ~= 0 return okr and ret ~= 0
end end
break break
@@ -97,10 +112,14 @@ local function resolveMount()
return physfsMountFn return physfsMountFn
end 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() local fn = resolveMount()
if not fn then return false end if not fn then return false end
return fn(dir) return fn(dir, append)
end end
-- The portable game folder when the cache should live there, else nil. -- 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; -- write cache-relative `rel` (forward-slash path) with the given bytes;
-- returns ok, err like love.filesystem.write -- returns ok, err like love.filesystem.write
function CacheFs.write(rel, data) function CacheFs.write(rel, data)
rel = withPrefix(rel)
local root = CacheFs.root() local root = CacheFs.root()
if root then if root then
ensureParents(root, rel) ensureParents(root, rel)
@@ -173,6 +193,7 @@ end
-- read cache-relative `rel`; returns the bytes or nil -- read cache-relative `rel`; returns the bytes or nil
function CacheFs.read(rel) function CacheFs.read(rel)
rel = withPrefix(rel)
local root = CacheFs.root() local root = CacheFs.root()
if root then if root then
local f = io.open(realPath(root, rel), "rb") local f = io.open(realPath(root, rel), "rb")
@@ -186,6 +207,7 @@ end
-- does cache-relative `rel` exist as a file? -- does cache-relative `rel` exist as a file?
function CacheFs.exists(rel) function CacheFs.exists(rel)
rel = withPrefix(rel)
local root = CacheFs.root() local root = CacheFs.root()
if root then if root then
local f = io.open(realPath(root, rel), "rb") local f = io.open(realPath(root, rel), "rb")
@@ -198,6 +220,7 @@ end
-- remove a single cache-relative file -- remove a single cache-relative file
function CacheFs.remove(rel) function CacheFs.remove(rel)
rel = withPrefix(rel)
local root = CacheFs.root() local root = CacheFs.root()
if root then if root then
os.remove(realPath(root, rel)) os.remove(realPath(root, rel))
@@ -213,6 +236,7 @@ end
-- love.filesystem (the game folder is mounted) and the real files deleted -- love.filesystem (the game folder is mounted) and the real files deleted
-- with os.remove; empty directories are harmless and left in place. -- with os.remove; empty directories are harmless and left in place.
function CacheFs.removeTree(rel) function CacheFs.removeTree(rel)
rel = withPrefix(rel)
local root = CacheFs.root() local root = CacheFs.root()
if not root then return end if not root then return end
local function walk(r) local function walk(r)
@@ -229,4 +253,31 @@ function CacheFs.removeTree(rel)
walk(rel) walk(rel)
end 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 return CacheFs
+256 -164
View File
@@ -1,9 +1,19 @@
local GameVersion = require("src.core.GameVersion")
local RomImporter = {} local RomImporter = {}
RomImporter.__index = RomImporter RomImporter.__index = RomImporter
local ROM_SHA1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a" -- Cache generation tag; bump to force every imported version to re-extract.
local CACHE_MARKER = "rom-cache-v7:" .. ROM_SHA1 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" 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 COMMUNITY_URL = "https://bois.icu"
local TRUST_WARNING = "if you did not get this from bryanthaboi's github " .. 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 " .. "or a link from the discord that bryanthaboi himself posted, just know " ..
@@ -58,18 +68,27 @@ local PAL = {
disabledInk = { 149, 161, 189 }, -- #95a1bd disabledInk = { 149, 161, 189 }, -- #95a1bd
} }
local function allRequiredFilesExist() -- CacheFs.exists checks the game folder directly for a portable install,
-- CacheFs.exists checks the game folder directly for a portable install, -- otherwise the save directory through love.filesystem. It honors
-- otherwise the save directory through love.filesystem. -- 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 CacheFs = require("src.import.CacheFs")
local saved = CacheFs.prefix
CacheFs.prefix = GameVersion.cachePrefix(version)
local ok = true
for _, path in ipairs(REQUIRED_FILES) do 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 end
return true CacheFs.prefix = saved
return ok
end 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() local function sourceTreeHasData()
if not allRequiredFilesExist() or not love.filesystem.getRealDirectory then if not allRequiredFilesExist("red") or not love.filesystem.getRealDirectory then
return false return false
end end
local real = love.filesystem.getRealDirectory(REQUIRED_FILES[1]) local real = love.filesystem.getRealDirectory(REQUIRED_FILES[1])
@@ -127,38 +146,49 @@ local function purgeSaveDirCache()
f:close() f:close()
return true return true
end end
if not (saveDirHas(MARKER_PATH) or saveDirHas(REQUIRED_FILES[1])) then -- Purge each version's stale save-directory copy (Red at the root, Blue
return -- 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 end
removeTree("data/generated")
removeTree("assets/generated")
love.filesystem.remove(MARKER_PATH)
end 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") local CacheFs = require("src.import.CacheFs")
if CacheFs.root() then if CacheFs.root() then
-- Portable: the cache lives in the game folder next to the executable -- Portable: the cache lives in the game folder next to the executable
-- (mounted onto the read path for a fused build). Drop any stale -- (mounted onto the read path for a fused build). Drop any stale
-- save-directory copy that would otherwise shadow it at runtime -- and, -- save-directory copy that would otherwise shadow it at runtime.
-- for a source run, hide the game folder from sourceTreeHasData below.
purgeSaveDirCache() purgeSaveDirCache()
end end
-- Generated data sitting in the physfs source -- a developer checkout, a -- Red generated data in the physfs source (developer checkout / Python
-- Python/bootstrap build, or a source-run portable import -- is always -- build) is always current; Blue is import-only and falls through to the
-- current (as it has always been). A fused portable install is not the -- version-marker gate.
-- source, so it falls through to the version-marker gate. if version == "red" and sourceTreeHasData() then return true end
if sourceTreeHasData() then return true end local saved = CacheFs.prefix
return CacheFs.read(MARKER_PATH) == CACHE_MARKER and allRequiredFilesExist() CacheFs.prefix = GameVersion.cachePrefix(version)
local marker = CacheFs.read(MARKER_PATH)
CacheFs.prefix = saved
return marker == markerFor(version) and allRequiredFilesExist(version)
end end
local function decodeManifest() -- Load the import manifest for a version and confirm it matches that ROM.
local raw, readError = love.filesystem.read("tools/rom_manifest.json") 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 if not raw then error("ROM import metadata is missing: " .. tostring(readError)) end
local Json = require("src.link.Json") local Json = require("src.link.Json")
local manifest, decodeError = Json.decode(raw) local manifest, decodeError = Json.decode(raw)
if not manifest then error("ROM import metadata is invalid: " .. tostring(decodeError)) end 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 return manifest
end end
@@ -215,16 +245,19 @@ local function scanForRom()
return nil return nil
end end
local function chooseRom() local function chooseRom(promptName)
promptName = promptName or "Pokemon"
local prompt = "Choose your " .. promptName .. " ROM"
local platform = love.system.getOS() local platform = love.system.getOS()
if platform == "OS X" then if platform == "OS X" then
return commandOutput( 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 elseif platform == "Windows" then
local script = table.concat({ local script = table.concat({
"Add-Type -AssemblyName System.Windows.Forms;", "Add-Type -AssemblyName System.Windows.Forms;",
"$d=New-Object System.Windows.Forms.OpenFileDialog;", "$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 (*.*)|*.*';", "$d.Filter='Game Boy ROM (*.gb)|*.gb|All files (*.*)|*.*';",
"if($d.ShowDialog() -eq 'OK'){[Console]::Write($d.FileName)}", "if($d.ShowDialog() -eq 'OK'){[Console]::Write($d.FileName)}",
}) })
@@ -232,7 +265,8 @@ local function chooseRom()
'powershell -NoProfile -STA -Command "' .. script .. '"') 'powershell -NoProfile -STA -Command "' .. script .. '"')
elseif platform == "Linux" then elseif platform == "Linux" then
local path = commandOutput( 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 if path then return path end
return commandOutput( return commandOutput(
[[kdialog --getopenfilename "$HOME" "*.gb|Game Boy ROM" 2>/dev/null]]) [[kdialog --getopenfilename "$HOME" "*.gb|Game Boy ROM" 2>/dev/null]])
@@ -240,47 +274,53 @@ local function chooseRom()
return nil return nil
end end
-- onComplete hands off to the game (boot). opts: -- The launcher runs Red and Blue as two independent columns. Each dropped or
-- ready -- this game's ROM is already imported: open on the Play state -- chosen ROM is routed to its version by SHA-1, extracted into that version's
-- launcher -- interactive launcher: a fresh import lands on the Play state -- own cache (Red at the root, Blue under blue/), so both can be imported and
-- instead of auto-booting (headless callers omit this and keep -- played side by side. onComplete(version) hands the chosen game off to boot.
-- the old import-then-boot behavior) -- opts: launcher (a fresh import stays on the launcher instead of auto-booting),
-- romName -- filename shown next to Play when already imported -- forceImport (treat every version as not-yet-imported, so re-import is forced).
function RomImporter.new(onComplete, opts) function RomImporter.new(onComplete, opts)
opts = opts or {} 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 android = love.system.getOS() == "Android"
local CacheFs = require("src.import.CacheFs")
local self = setmetatable({ local self = setmetatable({
onComplete = onComplete, onComplete = onComplete,
launcher = opts.launcher or false, launcher = opts.launcher or false,
forceImport = opts.forceImport or false,
android = android,
logo = love.graphics.newImage("assets/logo/logo.png"), logo = love.graphics.newImage("assets/logo/logo.png"),
bcg = love.graphics.newImage("assets/logo/bcg.png"), bcg = love.graphics.newImage("assets/logo/bcg.png"),
state = opts.ready and "ready" or "waiting", ready = {}, returning = {}, romName = {},
romName = opts.romName or "pokemon_red.gb", importing = nil, -- the version currently extracting, or nil
returning = returning, workState = nil, -- "working" / "complete" / "error" for that import
android = android, errorVersion = nil, -- which column shows the current error
status = returning and "More assets are needed from your ROM" notice = nil, -- { version, status, detail } transient hint (Android)
or "Choose or drop a Pokemon Red ROM", status = "", detail = "", progress = 0,
detail = returning stageCurrent = 0, stageTotal = 1, pulse = 0,
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,
}, RomImporter) }, RomImporter)
-- Only hunt for a ROM when one is actually needed; an already-imported for _, version in ipairs(GameVersion.ORDER) do
-- game opens straight on Play. local info = GameVersion.info(version)
if android and self.state ~= "ready" then local ready = RomImporter.isReady(version) and not self.forceImport
self.status = returning and "More ROM assets needed" or "Get your Pokemon Red ROM (.gb) in" self.ready[version] = ready
self.detail = "Tap Choose ROM to pick your file" -- a marker present but for an older cache generation / different ROM means
local name = scanForRom() -- "update required" (re-import) rather than a clean first-run choose
if name then local saved = CacheFs.prefix
self:startData(love.filesystem.read(name), name) 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 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
end end
return self return self
@@ -292,18 +332,18 @@ end
-- it into the folder scanForRom checks, so a rescan on refocus picks it up -- it into the folder scanForRom checks, so a rescan on refocus picks it up
-- without the player needing to tap the button again. -- without the player needing to tap the button again.
function RomImporter:focus(f) function RomImporter:focus(f)
if not (f and self.android if not (f and self.android and self.workState ~= "working") then return end
and (self.state == "waiting" or self.state == "error")) then if self.ready.red and self.ready.blue then return end
return
end
local name = scanForRom() local name = scanForRom()
if name then if name then self:startData(love.filesystem.read(name), name) end
self:startData(love.filesystem.read(name), name)
end
end end
function RomImporter:setError(message) function RomImporter:setError(message, version)
self.state = "error" 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.status = "That ROM could not be imported"
self.detail = tostring(message) self.detail = tostring(message)
self.progress = 0 self.progress = 0
@@ -311,42 +351,52 @@ function RomImporter:setError(message)
self.romData = nil self.romData = nil
end 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) function RomImporter:startData(data, displayName)
if self.state == "working" then return end if self.workState == "working" then return end
if type(data) ~= "string" then if type(data) ~= "string" then
self:setError("The selected file could not be read.") self:setError("The selected file could not be read.")
return return
end end
if #data ~= 1024 * 1024 then 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)) :format(#data / 1024 / 1024))
return return
end 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.importing = version
self.status = "Verifying ROM" self.workState = "working"
self.detail = displayName or "Pokemon Red" self.notice = nil
self.status = "Verifying " .. info.displayName
self.detail = displayName or info.displayName
self.progress = 0 self.progress = 0
self.romData = data self.romData = data
self.worker = coroutine.create(function() 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" self.status = "Preparing private game data"
coroutine.yield() coroutine.yield()
-- Clear any previous cache from both possible homes: the save directory -- Redirect every cache write to this version's subtree, then clear only
-- (removeTree) and, for a portable install, the game folder (CacheFs). -- 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") local CacheFs = require("src.import.CacheFs")
removeTree("data/generated") local prefix = info.cachePrefix
removeTree("assets/generated") CacheFs.prefix = prefix
love.filesystem.remove(MARKER_PATH) removeTree(prefix .. "data/generated")
removeTree(prefix .. "assets/generated")
love.filesystem.remove(prefix .. MARKER_PATH)
CacheFs.removeTree("data/generated") CacheFs.removeTree("data/generated")
CacheFs.removeTree("assets/generated") CacheFs.removeTree("assets/generated")
CacheFs.remove(MARKER_PATH) CacheFs.remove(MARKER_PATH)
local manifest = decodeManifest() local manifest = decodeManifest(version)
local RomExtractor = require("src.import.RomExtractor") local RomExtractor = require("src.import.RomExtractor")
local extractor = RomExtractor.new(self.romData, manifest, local extractor = RomExtractor.new(self.romData, manifest,
function(progress, total, stage, current, stageTotal) function(progress, total, stage, current, stageTotal)
@@ -360,24 +410,25 @@ function RomImporter:startData(data, displayName)
self.romData = nil self.romData = nil
collectgarbage("collect") collectgarbage("collect")
-- Written last: the marker is what isReady() checks, so it must only -- Written last: the marker is what isReady() checks, so it must only
-- appear once every required file is in place. CacheFs puts it beside -- appear once every required file is in place.
-- the cache -- the game folder for a portable install, else the save local ok, writeError = CacheFs.write(MARKER_PATH, markerFor(version))
-- directory. CacheFs.prefix = "" -- restore the default so later writes stay at the root
local ok, writeError = CacheFs.write(MARKER_PATH, CACHE_MARKER)
if not ok then error("could not finish the private cache: " .. tostring(writeError)) end 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.status = "Ready"
self.detail = "Starting Pokemon Red..." self.detail = "Starting " .. info.displayName .. "..."
self.progress = 1 self.progress = 1
if self.launcher then if self.launcher then
-- Stay on the launcher and show Play for the game just imported; the -- Stay on the launcher; the player presses Play to boot the new game.
-- player presses Play to boot it.
self.romName = (displayName and (displayName:match("[^/\\]+$") or displayName))
or self.romName
self.state = "ready"
return return
end end
if self.onComplete then self.onComplete() end if self.onComplete then self.onComplete(version) end
end) end)
end end
@@ -392,7 +443,7 @@ function RomImporter:startPath(path)
end end
function RomImporter:filedropped(file) function RomImporter:filedropped(file)
if self.state == "working" then return end if self.workState == "working" then return end
local data, readError = readDroppedFile(file) local data, readError = readDroppedFile(file)
if not data then if not data then
self:setError("Could not read the dropped file: " .. tostring(readError)) self:setError("Could not read the dropped file: " .. tostring(readError))
@@ -401,25 +452,30 @@ function RomImporter:filedropped(file)
self:startData(data, file:getFilename()) self:startData(data, file:getFilename())
end end
function RomImporter:choose() -- Open a picker (or, on Android, scan the external folder) for a column. The
if self.state == "working" then return end -- 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 if self.android then
local name = scanForRom() local name = scanForRom()
if name then if name then
self:startData(love.filesystem.read(name), name) self:startData(love.filesystem.read(name), name)
elseif not love.system.pickFile() then elseif not love.system.pickFile() then
-- Picker unavailable (API < 19, or no document-picker app installed): -- Picker unavailable (API < 19, or no document-picker app installed):
-- fall back to the USB folder-drop path. Not setError(): that status -- fall back to the USB folder-drop path as a friendly notice, not an
-- text ("could not be imported") reads as a rejected file, not "none -- error (which would read as a rejected file).
-- found yet" -- and detail only renders 3 wrapped lines, so the path self.notice = {
-- again gets the line to itself. version = self.chooseVersion,
self.state = "waiting" status = "No picker available, copy your ROM into:",
self.status = "No picker available, copy your ROM into:" detail = love.filesystem.getSaveDirectory(),
self.detail = love.filesystem.getSaveDirectory() }
end end
return return
end end
local path = chooseRom() local path = chooseRom(GameVersion.info(self.chooseVersion).displayName)
if path then if path then
self:startPath(path) self:startPath(path)
elseif love.system.getOS() ~= "OS X" elseif love.system.getOS() ~= "OS X"
@@ -431,7 +487,7 @@ end
function RomImporter:update(dt) function RomImporter:update(dt)
self.pulse = self.pulse + 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() local started = love.timer.getTime()
repeat repeat
local ok, workerError = coroutine.resume(self.worker) local ok, workerError = coroutine.resume(self.worker)
@@ -448,16 +504,20 @@ function RomImporter:update(dt)
end end
-- Player pressed Play on a game whose ROM is imported: hand off to boot. -- Player pressed Play on a game whose ROM is imported: hand off to boot.
function RomImporter:play() function RomImporter:play(version)
if self.onComplete then self.onComplete() end if self.workState == "working" then return end
if not self.ready[version] then return end
if self.onComplete then self.onComplete(version) end
end end
-- "re-import" from the Play state: drop back to the choose/drop UI so a fresh -- "re-import" a column: drop it back to the choose/drop state so a fresh ROM
-- ROM can be selected (the extract itself replaces the old cache). -- can be selected (the extract replaces that version's cache).
function RomImporter:reimport() function RomImporter:reimport(version)
if self.state ~= "ready" then return end if self.workState == "working" then return end
self.state = "waiting" if not self.ready[version] then return end
self.returning = false self.ready[version] = false
self.returning[version] = false
self.chooseVersion = version
end end
local function clamp(v, lo, hi) local function clamp(v, lo, hi)
@@ -711,48 +771,60 @@ function RomImporter:draw()
+ buttonH + gapButton + hintH + padBot + buttonH + gapButton + hintH + padBot
end end
-- Red column: live, driven by the import state machine. -- Red and Blue are each live: a column shows Play once its ROM is imported,
local redHint = self.android and "or copy the .gb via USB" or "or drop the .gb file here" -- Choose ROM / drag-drop before that, and a progress bar while extracting.
local redSpec = { local dropHint = self.android and "or copy the .gb via USB"
accent = PAL.red, interior = PAL.cardRed, or "or drop the .gb file here"
alpha = 1, glowScale = 1, period = 2.6, 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,
} }
if self.state == "ready" then local importing = self.importing == version
redSpec.heading = "Red ROM ready" local erroring = self.workState == "error" and self.errorVersion == version
redSpec.detail = "Your ROM is verified. Press Play to start." local notice = self.notice and self.notice.version == version and self.notice
redSpec.button = { kind = "play", text = "Play Red" } if importing and
redSpec.link = { name = self.romName } (self.workState == "working" or self.workState == "complete") then
elseif self.state == "working" or self.state == "complete" then spec.heading = self.status
redSpec.heading = self.status spec.detail = self.detail
redSpec.detail = self.detail spec.progress = self.progress or 0
redSpec.progress = self.progress or 0 elseif self.ready[version] then
elseif self.state == "error" then spec.heading = info.label .. " ROM ready"
redSpec.heading = "That ROM could not be imported" spec.detail = "Your ROM is verified. Press Play to start."
redSpec.detail = self.detail spec.button = { kind = "play", text = "Play " .. info.label }
redSpec.button = { kind = "choose", text = "Choose ROM" } spec.link = { name = self.romName[version] }
redSpec.hint = redHint elseif erroring then
else -- waiting spec.heading = "That ROM could not be imported"
if self.android then spec.detail = self.detail
redSpec.heading = self.status spec.button = { kind = "choose", text = "Choose ROM" }
redSpec.detail = self.detail spec.hint = dropHint
elseif self.returning then elseif notice then
redSpec.heading = "Update required" spec.heading = notice.status
redSpec.detail = "This build needs a few more things from your ROM. Re-import to continue." 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 else
redSpec.heading = "Choose or drop a Red ROM" spec.heading = "Choose or drop a " .. info.label .. " ROM"
redSpec.detail = "The ROM is verified before any files are created." spec.detail = "The ROM is verified before any files are created."
spec.button = { kind = "choose", text = "Choose ROM" }
spec.hint = dropHint
end end
redSpec.button = { kind = "choose", text = "Choose ROM" } return spec
redSpec.hint = redHint
end end
-- Blue and Yellow columns: lit placeholders until those games are supported. local redSpec = columnSpec("red",
local blueSpec = { { accent = PAL.red, interior = PAL.cardRed, period = 2.6 })
accent = PAL.blue, interior = PAL.cardBlue, local blueSpec = columnSpec("blue",
alpha = 0.92, glowScale = 0.5, period = 3.4, { accent = PAL.blue, interior = PAL.cardBlue, period = 3.4 })
heading = "Choose or drop a Blue ROM", detail = "Blue support is on the way.", -- Yellow stays a lit placeholder until that game is supported.
button = { kind = "disabled", text = "Coming soon" }, hint = "not yet available",
}
local yellowSpec = { local yellowSpec = {
accent = PAL.gold, interior = PAL.cardGold, accent = PAL.gold, interior = PAL.cardGold,
alpha = 0.92, glowScale = 0.5, period = 3.8, alpha = 0.92, glowScale = 0.5, period = 3.8,
@@ -885,9 +957,20 @@ function RomImporter:draw()
return buttonRect, linkRect return buttonRect, linkRect
end end
self.redButton, self.reimportRect = drawCard(0, redSpec, top) -- Per-column hit rects, rebuilt each frame and keyed by version so clicks
drawCard(third, blueSpec, top) -- route to the right game (Yellow has none -- it is inert).
drawCard(2 * third, yellowSpec, top) 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 -- logo, centred over the split, with a gentle bob + gold glow
local bob = math.sin(pulse * (2 * math.pi / 4)) * 6 * s 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) love.system.openURL(COMMUNITY_URL)
return return
end end
if self.state == "working" or self.state == "complete" then return end if self.workState == "working" then return end
if self.state == "ready" then local buttons = self.buttons or {}
if inside(self.reimportRect, x, y) then self:reimport() local reimports = self.reimportRects or {}
elseif inside(self.redButton, x, y) then self:play() end for _, version in ipairs(GameVersion.ORDER) do
return 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 end
if inside(self.redButton, x, y) then self:choose() end
end end
function RomImporter:keypressed(key) 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 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
end end
+29 -3
View File
@@ -10,6 +10,8 @@
-- RED++ swaps the named-palette pack for pokered-gbc SuperPalettes -- RED++ swaps the named-palette pack for pokered-gbc SuperPalettes
-- (data/palettes_gbc.lua), including per-species mon colors. -- (data/palettes_gbc.lua), including per-species mon colors.
local GameVersion = require("src.core.GameVersion")
local PaletteFX = {} local PaletteFX = {}
local shader -- false = unavailable (headless / no shader support) 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 }, { 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 } local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
function PaletteFX.shader() function PaletteFX.shader()
@@ -228,7 +250,7 @@ end
-- background tile drew. Objects do not come through here (they bake -- background tile drew. Objects do not come through here (they bake
-- GBC_OBJ green), so this stays a BG-only hook. -- GBC_OBJ green), so this stays a BG-only hook.
function PaletteFX.pal(data, name) 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 p = PaletteFX.pack(data)
local c = p and p.palettes[name] local c = p and p.palettes[name]
if c then return c end 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 -- 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 -- a per-species one -- matching the hardware capture where both mons are
-- red/pink on the white field. -- 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) local p = PaletteFX.pack(data)
if not p then return nil end if not p then return nil end
if transformed then if transformed then
@@ -490,7 +512,11 @@ function PaletteFX.applyOptions(opts)
end end
function PaletteFX.modeLabel(mode) 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 end
-- When a state exposes no SGB zones but COLORS needs a forced palette -- When a state exposes no SGB zones but COLORS needs a forced palette
+28 -5
View File
@@ -5,6 +5,7 @@
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Music = require("src.core.Music") local Music = require("src.core.Music")
local GameVersion = require("src.core.GameVersion")
local TitleState = {} local TitleState = {}
TitleState.__index = TitleState TitleState.__index = TitleState
@@ -30,6 +31,13 @@ local CYCLE_SPECIES = {
"PIKACHU", "CLEFAIRY", "RHYDON", "ABRA", "GASTLY", "DITTO", "PIKACHU", "CLEFAIRY", "RHYDON", "ABRA", "GASTLY", "DITTO",
"PIDGEOTTO", "ONIX", "PONYTA", "MAGIKARP", "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 CYCLE_FRAMES = 240 -- the original waits ~4s between picks
local function tryImage(path) local function tryImage(path)
@@ -61,9 +69,13 @@ function TitleState.new(game, opts)
self.version = tryImage(imagePath(title.versionRibbon or title.version) self.version = tryImage(imagePath(title.versionRibbon or title.version)
or "assets/generated/title/red_version.png") or "assets/generated/title/red_version.png")
self.player = tryImage("assets/generated/title/player.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" self.cycleSpecies = (type(title.cycleSpecies) == "table"
and #title.cycleSpecies > 0) and #title.cycleSpecies > 0)
and title.cycleSpecies or CYCLE_SPECIES and title.cycleSpecies or defaultCycle
self.sprites = {} -- species -> image or false (load failed) self.sprites = {} -- species -> image or false (load failed)
self.cycleIndex = 1 self.cycleIndex = 1
self.timer = 0 self.timer = 0
@@ -92,8 +104,10 @@ end
local function hasSave() local function hasSave()
local ok, info = pcall(function() 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 return love.filesystem and love.filesystem.getInfo
and love.filesystem.getInfo("save.lua") or nil and love.filesystem.getInfo(name) or nil
end) end)
return ok and info ~= nil return ok and info ~= nil
end end
@@ -211,18 +225,27 @@ function TitleState:draw()
love.graphics.draw(self.logo, 16, 8) love.graphics.draw(self.logo, 16, 8)
else else
love.graphics.setColor(0, 0, 0, 1) 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) love.graphics.setColor(1, 1, 1, 1)
end end
if self.version then 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() local iw, ih = self.version:getDimensions()
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.draw(self.version,
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56, 64) love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56, 64)
love.graphics.draw(self.version, love.graphics.draw(self.version,
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64) love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64)
end end
end
local sprite = self:currentSprite() local sprite = self:currentSprite()
if sprite then if sprite then
local w, h = sprite:getDimensions() local w, h = sprite:getDimensions()
+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_RED_SHA1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a"
CANONICAL_BLUE_SHA1 = "d7037c83e1ae5b39bde3c30787637ba1d4c48ce2"
ROM_BANK_SIZE = 0x4000 ROM_BANK_SIZE = 0x4000
File diff suppressed because it is too large Load Diff