mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-09 01:59:15 -04:00
386 lines
12 KiB
Lua
386 lines
12 KiB
Lua
-- Launch options: boot straight into a game, skipping the launcher.
|
|
--
|
|
-- love . --game=red -- boot Red
|
|
-- love . --game=yellow --slot=2 -- boot Yellow on save slot 2
|
|
-- love . --game=gold -- boot Gold (src/core/Game2.lua)
|
|
-- love . --game=firered -- boot FireRed (src/core/game3.lua)
|
|
-- love . --game=red --launcher -- open the launcher anyway (a shortcut
|
|
-- the player wants to edit)
|
|
-- POKEPORT_GAME=blue love . -- same, for launchers that only pass env
|
|
--
|
|
-- The "--flag value" spelling parses here (argValue reads argv[i + 1]), but it
|
|
-- does not survive LOVE: boot.lua takes the first bare argument as a path to a
|
|
-- game to run, so `--game red` dies with "Cannot load game at path .../red"
|
|
-- before love.load is ever called, fused or not. Only the "=" spelling is
|
|
-- reachable, so that is the one the docs quote.
|
|
--
|
|
-- This exists for the click-once cases: a desktop shortcut per game, a Steam
|
|
-- entry, an EmulationStation/Playnite entry, a handheld frontend. Those all
|
|
-- want "start the thing" and treat any menu in between as a defect.
|
|
--
|
|
-- Everything here is pure resolution and validation -- no love.* beyond the
|
|
-- filesystem read that slot selection needs -- so the engine test tier can
|
|
-- cover the parsing without a window.
|
|
|
|
local GameVersion = require("src.core.GameVersion")
|
|
|
|
local LaunchOptions = {}
|
|
local normalizeVersion
|
|
local argValue
|
|
local argFlag
|
|
|
|
local function trim(value)
|
|
if type(value) ~= "string" then return nil end
|
|
value = value:gsub("^%s+", ""):gsub("%s+$", "")
|
|
return value ~= "" and value or nil
|
|
end
|
|
|
|
local function normalizeCart(value)
|
|
value = trim(value)
|
|
if not value or #value > 64 or not value:match("^[%w_%-]+$") then
|
|
return nil
|
|
end
|
|
return value
|
|
end
|
|
|
|
local function encodeURIComponent(value)
|
|
return tostring(value):gsub("([^%w%-%._~])", function(char)
|
|
return ("%%%02X"):format(char:byte())
|
|
end)
|
|
end
|
|
|
|
local function decodeURIComponent(value)
|
|
local result = {}
|
|
local index = 1
|
|
while index <= #value do
|
|
local char = value:sub(index, index)
|
|
if char == "%" then
|
|
local hex = value:sub(index + 1, index + 2)
|
|
if not hex:match("^%x%x$") then return nil end
|
|
result[#result + 1] = string.char(tonumber(hex, 16))
|
|
index = index + 3
|
|
else
|
|
result[#result + 1] = char
|
|
index = index + 1
|
|
end
|
|
end
|
|
return table.concat(result)
|
|
end
|
|
|
|
local function booleanValue(value)
|
|
if value == nil then return nil end
|
|
value = trim(value)
|
|
if not value then return true end
|
|
value = value:lower()
|
|
if value == "1" or value == "true" or value == "yes" or value == "on" then
|
|
return true
|
|
end
|
|
if value == "0" or value == "false" or value == "no" or value == "off" then
|
|
return false
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Set by main.lua when a requested game turns out not to be importable yet:
|
|
-- the launcher opens on that tab instead of booting.
|
|
LaunchOptions.pendingTab = nil
|
|
|
|
normalizeVersion = function(v)
|
|
if type(v) ~= "string" then return nil end
|
|
v = v:lower():gsub("^%s+", ""):gsub("%s+$", "")
|
|
if v == "" then return nil end
|
|
-- Accept the aliases people actually type.
|
|
local alias = {
|
|
r = "red", red = "red",
|
|
b = "blue", blue = "blue",
|
|
y = "yellow", yellow = "yellow",
|
|
g = "gold", gold = "gold",
|
|
s = "silver", silver = "silver",
|
|
c = "crystal", crystal = "crystal",
|
|
firered = "firered", fr = "firered", fire_red = "firered",
|
|
leafgreen = "leafgreen", lg = "leafgreen", leaf_green = "leafgreen",
|
|
emerald = "emerald", em = "emerald",
|
|
}
|
|
v = alias[v] or v
|
|
if GameVersion.VERSIONS and not GameVersion.VERSIONS[v] then return nil end
|
|
return v
|
|
end
|
|
|
|
-- Pull "--flag value" (and "--flag=value") out of LOVE's arg table.
|
|
argValue = function(argv, name)
|
|
if type(argv) ~= "table" then return nil end
|
|
for i = 1, #argv do
|
|
local a = argv[i]
|
|
if a == "--" .. name then
|
|
return argv[i + 1]
|
|
end
|
|
local inline = type(a) == "string" and a:match("^%-%-" .. name .. "=(.*)$")
|
|
if inline then return inline end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
argFlag = function(argv, name)
|
|
if type(argv) ~= "table" then return false end
|
|
for i = 1, #argv do
|
|
local a = argv[i]
|
|
if a == "--" .. name or a == "-" .. name then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local cachedIntentGame = nil
|
|
|
|
local function uriAuthority(uri)
|
|
if type(uri) ~= "string" or uri == "" then return nil end
|
|
|
|
local withoutFragment = uri:match("^([^#]*)")
|
|
local queryStart = withoutFragment:find("?", 1, true)
|
|
local authority = queryStart and withoutFragment:sub(1, queryStart - 1)
|
|
or withoutFragment
|
|
local scheme, host, path = authority:match(
|
|
"^([%a][%w+%-%.]*):%/%/([^/]*)(.*)$")
|
|
if not scheme or scheme:lower() ~= "gen1recomp++"
|
|
or host:lower() ~= "launch" or (path ~= "" and path ~= "/") then
|
|
return nil
|
|
end
|
|
return withoutFragment, queryStart
|
|
end
|
|
|
|
function LaunchOptions.isLaunchURI(uri)
|
|
return uriAuthority(uri) ~= nil
|
|
end
|
|
|
|
function LaunchOptions.parseURI(uri)
|
|
local withoutFragment, queryStart = uriAuthority(uri)
|
|
if not withoutFragment then return nil end
|
|
local query = queryStart and withoutFragment:sub(queryStart + 1) or ""
|
|
|
|
local values = {}
|
|
for pair in query:gmatch("[^&]+") do
|
|
local equals = pair:find("=", 1, true)
|
|
local rawKey = equals and pair:sub(1, equals - 1) or pair
|
|
local rawValue = equals and pair:sub(equals + 1) or ""
|
|
local key = decodeURIComponent(rawKey)
|
|
local value = decodeURIComponent(rawValue)
|
|
if not key or not value then return nil end
|
|
key = key:lower()
|
|
if key ~= "" and values[key] == nil then
|
|
values[key] = value
|
|
end
|
|
end
|
|
|
|
local request = {
|
|
source = "uri",
|
|
game = normalizeVersion(values.game),
|
|
cart = normalizeCart(values.cart),
|
|
slot = trim(values.slot),
|
|
gameSpecified = values.game ~= nil,
|
|
cartSpecified = values.cart ~= nil,
|
|
}
|
|
request.launcher = booleanValue(values.launcher)
|
|
request.sync = booleanValue(values.sync)
|
|
request.update = booleanValue(values.update)
|
|
request.updateMods = booleanValue(values.update_mods)
|
|
return request
|
|
end
|
|
|
|
local function launchURIRaw(method)
|
|
if type(love) ~= "table" or type(love.system) ~= "table"
|
|
or type(love.system[method]) ~= "function" then
|
|
return nil
|
|
end
|
|
local ok, uri = pcall(love.system[method])
|
|
if not ok or type(uri) ~= "string" or uri == "" then return nil end
|
|
return uri
|
|
end
|
|
|
|
local function launchURIRequest()
|
|
return LaunchOptions.parseURI(launchURIRaw("getLaunchURI"))
|
|
end
|
|
|
|
local function intentGame()
|
|
if cachedIntentGame == nil then
|
|
if type(love) == "table" and type(love.system) == "table"
|
|
and type(love.system.getOS) == "function"
|
|
and love.system.getOS() == "Android"
|
|
and type(love.system.getLaunchGame) == "function" then
|
|
cachedIntentGame = normalizeVersion(love.system.getLaunchGame()) or false
|
|
else
|
|
cachedIntentGame = false
|
|
end
|
|
end
|
|
return cachedIntentGame or nil
|
|
end
|
|
|
|
local function request(argv, rawArgv, uri)
|
|
local game = normalizeVersion(argValue(argv, "game"))
|
|
if not game then
|
|
if uri and uri.gameSpecified then
|
|
game = uri.game
|
|
else
|
|
game = intentGame()
|
|
or normalizeVersion(os.getenv("POKEPORT_GAME"))
|
|
or normalizeVersion(os.getenv("POKEPORT_LAUNCH"))
|
|
end
|
|
end
|
|
local slot = trim(argValue(argv, "slot"))
|
|
or (uri and uri.slot)
|
|
or trim(os.getenv("POKEPORT_SLOT"))
|
|
local rawCart = argValue(argv, "cart")
|
|
local cart = normalizeCart(rawCart)
|
|
local cartSpecified = rawCart ~= nil
|
|
if rawCart == nil and uri and uri.cartSpecified then
|
|
cart = uri.cart
|
|
cartSpecified = true
|
|
end
|
|
local launcher
|
|
if argFlag(argv, "launcher") or argFlag(rawArgv, "launcher") then
|
|
launcher = true
|
|
elseif uri and uri.launcher ~= nil then
|
|
launcher = uri.launcher
|
|
else
|
|
launcher = os.getenv("POKEPORT_FORCE_LAUNCHER") == "1"
|
|
end
|
|
return {
|
|
game = game,
|
|
cart = cart,
|
|
slot = slot,
|
|
cartSpecified = cartSpecified,
|
|
launcher = launcher,
|
|
uri = uri,
|
|
}
|
|
end
|
|
|
|
-- Returns version, slotId (either may be nil). Command line wins over env,
|
|
-- so a shortcut can override a machine-wide default.
|
|
function LaunchOptions.resolve(argv)
|
|
local resolved = LaunchOptions.resolveRequest(argv, nil)
|
|
return resolved.game, resolved.slot
|
|
end
|
|
|
|
function LaunchOptions.forceLauncher(argv)
|
|
return LaunchOptions.resolveRequest(argv, nil).launcher
|
|
end
|
|
|
|
local function taskFlag(argv, rawArgv, names, env, uriValue)
|
|
if type(names) ~= "table" then names = { names } end
|
|
for _, name in ipairs(names) do
|
|
if argFlag(argv, "no-" .. name) or argFlag(rawArgv, "no-" .. name) then
|
|
return false
|
|
end
|
|
end
|
|
for _, name in ipairs(names) do
|
|
if argFlag(argv, name) or argFlag(rawArgv, name) then return true end
|
|
end
|
|
if uriValue ~= nil then return uriValue end
|
|
local v = os.getenv(env)
|
|
if v == "1" then return true end
|
|
if v == "0" then return false end
|
|
return nil
|
|
end
|
|
|
|
function LaunchOptions.tasks(argv, rawArgv, uri)
|
|
uri = uri or launchURIRequest()
|
|
return {
|
|
sync = taskFlag(argv, rawArgv, "sync", "POKEPORT_LAUNCH_SYNC",
|
|
uri and uri.sync),
|
|
update = taskFlag(argv, rawArgv, "update", "POKEPORT_LAUNCH_UPDATE",
|
|
uri and uri.update) == true,
|
|
mods = taskFlag(argv, rawArgv, { "update-mods", "updatemods" },
|
|
"POKEPORT_LAUNCH_UPDATE_MODS", uri and uri.updateMods) == true,
|
|
}
|
|
end
|
|
|
|
function LaunchOptions.resolveRequest(argv, rawArgv)
|
|
local uri = launchURIRequest()
|
|
local resolved = request(argv, rawArgv, uri)
|
|
resolved.tasks = LaunchOptions.tasks(argv, rawArgv, uri)
|
|
return resolved
|
|
end
|
|
|
|
function LaunchOptions.pollURI()
|
|
return launchURIRaw("pollLaunchURI")
|
|
end
|
|
|
|
function LaunchOptions.fromGame(game)
|
|
local normalized = normalizeVersion(game)
|
|
if not normalized then return nil end
|
|
local tasks = LaunchOptions.tasks({}, {})
|
|
tasks.update = false
|
|
tasks.mods = false
|
|
return {
|
|
source = "intent",
|
|
game = normalized,
|
|
cart = nil,
|
|
cartSpecified = false,
|
|
slot = nil,
|
|
launcher = false,
|
|
tasks = tasks,
|
|
}
|
|
end
|
|
|
|
function LaunchOptions.uriFor(version, options)
|
|
options = options or {}
|
|
version = normalizeVersion(version)
|
|
if not version then return nil end
|
|
local query = { "game=" .. encodeURIComponent(version) }
|
|
local cart = normalizeCart(options.cart)
|
|
if cart then query[#query + 1] = "cart=" .. encodeURIComponent(cart) end
|
|
local slot = trim(options.slot)
|
|
if slot then query[#query + 1] = "slot=" .. encodeURIComponent(slot) end
|
|
if options.launcher ~= nil then
|
|
query[#query + 1] = "launcher=" .. (options.launcher and "1" or "0")
|
|
end
|
|
if options.sync ~= nil then
|
|
query[#query + 1] = "sync=" .. (options.sync and "1" or "0")
|
|
end
|
|
if options.update ~= nil then
|
|
query[#query + 1] = "update=" .. (options.update and "1" or "0")
|
|
end
|
|
if options.updateMods ~= nil then
|
|
query[#query + 1] = "update_mods=" .. (options.updateMods and "1" or "0")
|
|
end
|
|
return "gen1recomp++://launch?" .. table.concat(query, "&")
|
|
end
|
|
|
|
-- Point a version at a save slot before it boots. Accepts either a slot id
|
|
-- ("slot2") or a 1-based index ("2"), because a shortcut author should not
|
|
-- have to know the internal id scheme. A slot that does not exist is
|
|
-- ignored: booting the game on its previous slot beats refusing to start.
|
|
-- Returns the id actually selected, or nil.
|
|
function LaunchOptions.selectSlot(version, slot)
|
|
local ok, SaveData = pcall(require, "src.core.SaveData")
|
|
if not ok then return nil end
|
|
local listed = SaveData.listSlots and SaveData.listSlots(version) or nil
|
|
if type(listed) ~= "table" or #listed == 0 then return nil end
|
|
|
|
local target
|
|
local index = tonumber(slot)
|
|
if index and listed[index] then
|
|
target = listed[index].id
|
|
else
|
|
for _, s in ipairs(listed) do
|
|
if s.id == slot then target = s.id break end
|
|
end
|
|
end
|
|
if not target then return nil end
|
|
pcall(SaveData.setActiveSlot, version, target)
|
|
return target
|
|
end
|
|
|
|
-- The shortcut command a player would use for this game, for the launcher to
|
|
-- show and for docs to quote.
|
|
function LaunchOptions.commandFor(version, slot, tasks)
|
|
local cmd = "--game " .. tostring(version)
|
|
if slot then cmd = cmd .. " --slot " .. tostring(slot) end
|
|
if type(tasks) == "table" then
|
|
if tasks.update then cmd = cmd .. " --update" end
|
|
if tasks.mods then cmd = cmd .. " --update-mods" end
|
|
if tasks.sync == false then cmd = cmd .. " --no-sync" end
|
|
end
|
|
return cmd
|
|
end
|
|
|
|
return LaunchOptions
|