mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-21 13:09:54 +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)
|
-- 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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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" \
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 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
@@ -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
|
||||||
|
|||||||
+257
-165
@@ -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
|
||||||
|
-- "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()
|
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
|
||||||
|
|
||||||
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)
|
||||||
if self.state == "ready" then
|
local spec = {
|
||||||
redSpec.heading = "Red ROM ready"
|
version = version,
|
||||||
redSpec.detail = "Your ROM is verified. Press Play to start."
|
accent = style.accent, interior = style.interior,
|
||||||
redSpec.button = { kind = "play", text = "Play Red" }
|
alpha = 1, glowScale = 1, period = style.period,
|
||||||
redSpec.link = { name = self.romName }
|
}
|
||||||
elseif self.state == "working" or self.state == "complete" then
|
local importing = self.importing == version
|
||||||
redSpec.heading = self.status
|
local erroring = self.workState == "error" and self.errorVersion == version
|
||||||
redSpec.detail = self.detail
|
local notice = self.notice and self.notice.version == version and self.notice
|
||||||
redSpec.progress = self.progress or 0
|
if importing and
|
||||||
elseif self.state == "error" then
|
(self.workState == "working" or self.workState == "complete") then
|
||||||
redSpec.heading = "That ROM could not be imported"
|
spec.heading = self.status
|
||||||
redSpec.detail = self.detail
|
spec.detail = self.detail
|
||||||
redSpec.button = { kind = "choose", text = "Choose ROM" }
|
spec.progress = self.progress or 0
|
||||||
redSpec.hint = redHint
|
elseif self.ready[version] then
|
||||||
else -- waiting
|
spec.heading = info.label .. " ROM ready"
|
||||||
if self.android then
|
spec.detail = "Your ROM is verified. Press Play to start."
|
||||||
redSpec.heading = self.status
|
spec.button = { kind = "play", text = "Play " .. info.label }
|
||||||
redSpec.detail = self.detail
|
spec.link = { name = self.romName[version] }
|
||||||
elseif self.returning then
|
elseif erroring then
|
||||||
redSpec.heading = "Update required"
|
spec.heading = "That ROM could not be imported"
|
||||||
redSpec.detail = "This build needs a few more things from your ROM. Re-import to continue."
|
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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+32
-9
@@ -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,17 +225,26 @@ 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()
|
||||||
love.graphics.draw(self.version,
|
if self.blue then
|
||||||
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56, 64)
|
-- Blue prints its ribbon contiguously ("Blue Version", hlcoord 7,8).
|
||||||
love.graphics.draw(self.version,
|
-- The extracted strip packs those eight glyph tiles into image tiles
|
||||||
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64)
|
-- 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
|
end
|
||||||
local sprite = self:currentSprite()
|
local sprite = self:currentSprite()
|
||||||
if sprite then
|
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_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
Reference in New Issue
Block a user