mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-21 21:16:28 +02:00
add blue
This commit is contained in:
+24
-6
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
Reference in New Issue
Block a user