mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 07:41:21 +02:00
dfeacc36b0
- fixed issue with wayland users drag and drop causing CTD
318 lines
12 KiB
Lua
318 lines
12 KiB
Lua
-- Manifest v2: a strict superset of v1, so every shipped v1 manifest stays
|
|
-- valid. Pure (no filesystem): the loader's validate phase owns the checks
|
|
-- that need to stat a file, this owns shape, vocabulary and range grammar.
|
|
local Logger = require("src.core.Logger")
|
|
local ModTargets = require("src.mods.ModTargets")
|
|
local Semver = require("src.mods.Semver")
|
|
local Version = require("src.core.Version")
|
|
|
|
local Manifest = {}
|
|
|
|
Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true }
|
|
Manifest.PERMISSIONS = { network = true, filesystem = true, engine_internals = true }
|
|
|
|
-- link-relevant registries; a mod that writes into one of these while
|
|
-- declaring affects_link = false gets an attributed warning from the loader
|
|
Manifest.LINK_REGISTRIES = {
|
|
pokemon = true, moves = true, type_chart = true,
|
|
statuses = true, move_effects = true,
|
|
}
|
|
|
|
local function array(value)
|
|
if value == nil then return {} end
|
|
assert(type(value) == "table", "manifest arrays must be tables")
|
|
return value
|
|
end
|
|
|
|
-- api 2 treats vocabulary violations as load errors; api 1 keeps loading and
|
|
-- gets an attributed warning so v1 mods never break on a field they predate
|
|
local function violation(strict, id, message)
|
|
if strict then error(message, 0) end
|
|
Logger.warn("[%s] %s", tostring(id), message)
|
|
end
|
|
|
|
-- Optional GitHub repo for launcher auto-update / other-versions.
|
|
-- Accepts "owner/repo" or a github.com URL; empty/absent means no updates.
|
|
function Manifest.parseGithub(value)
|
|
if value == nil or value == "" then return nil end
|
|
assert(type(value) == "string", "github must be a string")
|
|
local trimmed = value:match("^%s*(.-)%s*$") or value
|
|
if trimmed == "" then return nil end
|
|
local owner, repo = trimmed:match(
|
|
"^https?://github%.com/([%w%._%-]+)/([%w%._%-]+)/?$")
|
|
if not owner then
|
|
owner, repo = trimmed:match(
|
|
"^https?://github%.com/([%w%._%-]+)/([%w%._%-]+)%.git/?$")
|
|
end
|
|
if not owner then
|
|
owner, repo = trimmed:match("^([%w%._%-]+)/([%w%._%-]+)$")
|
|
end
|
|
assert(owner and repo and owner ~= "" and repo ~= "",
|
|
"github must be owner/repo or a github.com URL")
|
|
repo = repo:gsub("%.git$", "")
|
|
return owner .. "/" .. repo
|
|
end
|
|
|
|
-- "id", "id@<range>", "id@<range>#<github>", "id#<github>", or table entry
|
|
local function parseSpecs(list, field, sources)
|
|
local specs = {}
|
|
sources = type(sources) == "table" and sources or {}
|
|
for _, entry in ipairs(list) do
|
|
local id, range, ghHint, gamesRaw, gameVersion
|
|
if type(entry) == "table" then
|
|
id = entry.id
|
|
range = entry.range or entry.version
|
|
ghHint = entry.github or entry.repo
|
|
gamesRaw = entry.games or entry.game
|
|
gameVersion = entry.game_version
|
|
elseif type(entry) == "string" and entry ~= "" then
|
|
local main, hashRepo = entry:match("^([^#]+)#(.*)$")
|
|
if main then
|
|
entry = main
|
|
ghHint = hashRepo
|
|
end
|
|
id, range = entry:match("^([%w_%-]+)@(.+)$")
|
|
if not id then
|
|
id = entry:match("^([%w_%-]+)$")
|
|
assert(id, ("malformed %s entry %q"):format(field, entry))
|
|
range = nil
|
|
end
|
|
else
|
|
error(field .. " entries must be non-empty strings or tables")
|
|
end
|
|
assert(id, ("malformed %s entry"):format(field))
|
|
local ok, err = Semver.validRange(range)
|
|
assert(ok, ("malformed %s range in %q: %s"):format(field, tostring(entry), tostring(err)))
|
|
|
|
if gameVersion then
|
|
local okV, errV = Semver.validRange(gameVersion)
|
|
assert(okV, ("malformed %s game_version range in %q: %s"):format(field, tostring(entry), tostring(errV)))
|
|
end
|
|
|
|
local parsedGames = nil
|
|
if gamesRaw ~= nil then
|
|
if type(gamesRaw) == "string" then gamesRaw = { gamesRaw } end
|
|
assert(type(gamesRaw) == "table", "dependency games must be a string or table")
|
|
local normalized, unknown = ModTargets.normalize(gamesRaw)
|
|
assert(#unknown == 0, ("unknown game in %s: %s"):format(field, table.concat(unknown, ", ")))
|
|
parsedGames = normalized
|
|
end
|
|
|
|
local parsedGh = nil
|
|
local rawGh = ghHint or sources[id]
|
|
if rawGh then
|
|
local okGh, cleanGh = pcall(Manifest.parseGithub, rawGh)
|
|
if okGh and cleanGh then parsedGh = cleanGh end
|
|
end
|
|
|
|
specs[#specs + 1] = {
|
|
id = id,
|
|
range = range,
|
|
github = parsedGh,
|
|
games = parsedGames,
|
|
game_version = gameVersion,
|
|
}
|
|
end
|
|
return specs
|
|
end
|
|
|
|
-- conflicts + incompatible (alias) merged, first-wins on duplicate ids
|
|
local function mergeConflictLists(conflicts, incompatible)
|
|
local seen, out = {}, {}
|
|
for _, list in ipairs({ array(conflicts), array(incompatible) }) do
|
|
for _, entry in ipairs(list) do
|
|
if not seen[entry] then
|
|
seen[entry] = true
|
|
out[#out + 1] = entry
|
|
end
|
|
end
|
|
end
|
|
return out
|
|
end
|
|
|
|
-- Drop bytes that are not valid UTF-8 (malformed sequences, overlongs,
|
|
-- surrogates, > U+10FFFF) and a leading BOM. LÖVE's text renderer raises
|
|
-- "Invalid UTF-8" from love.graphics.print/printf, so any manifest string a
|
|
-- panel may draw must be scrubbed here -- the one place every mod manifest
|
|
-- passes through -- or a single mangled description crashes the whole MODS
|
|
-- panel instead of misrendering one card.
|
|
local function scrubUtf8(s)
|
|
if type(s) ~= "string" then return s end
|
|
s = s:gsub("^\239\187\191", "")
|
|
local out, i, n = {}, 1, #s
|
|
while i <= n do
|
|
local b = s:byte(i)
|
|
local len
|
|
if b < 0x80 then len = 1
|
|
elseif b >= 0xC2 and b <= 0xDF then len = 2
|
|
elseif b >= 0xE0 and b <= 0xEF then len = 3
|
|
elseif b >= 0xF0 and b <= 0xF4 then len = 4
|
|
end
|
|
local ok = len ~= nil and i + len - 1 <= n
|
|
if ok and len > 1 then
|
|
for j = i + 1, i + len - 1 do
|
|
local c = s:byte(j)
|
|
if c < 0x80 or c > 0xBF then ok = false; break end
|
|
end
|
|
if ok then
|
|
-- boundary lead bytes narrow their second byte: no overlongs
|
|
-- (E0/F0), no surrogates (ED), nothing past U+10FFFF (F4)
|
|
local b2 = s:byte(i + 1)
|
|
if (b == 0xE0 and b2 < 0xA0) or (b == 0xED and b2 > 0x9F)
|
|
or (b == 0xF0 and b2 < 0x90) or (b == 0xF4 and b2 > 0x8F) then
|
|
ok = false
|
|
end
|
|
end
|
|
end
|
|
if ok then
|
|
out[#out + 1] = s:sub(i, i + len - 1)
|
|
i = i + len
|
|
else
|
|
i = i + 1
|
|
end
|
|
end
|
|
return table.concat(out)
|
|
end
|
|
|
|
function Manifest.validate(raw, path)
|
|
assert(type(raw) == "table", "manifest must be an object")
|
|
-- scrubbed in place so every later reader agrees, including the launcher's
|
|
-- badge derivation, which reads raw.category rather than the validated copy
|
|
raw.name = scrubUtf8(raw.name)
|
|
raw.version = scrubUtf8(raw.version)
|
|
raw.description = scrubUtf8(raw.description)
|
|
raw.category = scrubUtf8(raw.category)
|
|
assert(type(raw.id) == "string" and raw.id:match("^[%w_%-]+$"),
|
|
"manifest id must contain only letters, numbers, _ or -")
|
|
assert(type(raw.name) == "string" and raw.name ~= "", "manifest name is required")
|
|
assert(type(raw.version) == "string" and raw.version ~= "", "manifest version is required")
|
|
assert(type(raw.entry) == "string" and raw.entry ~= "", "manifest entry is required")
|
|
|
|
-- absent means 1: full v1 compat, schema violations downgrade to warnings
|
|
assert(raw.api == nil or tonumber(raw.api) ~= nil, "manifest api must be a number")
|
|
local api = tonumber(raw.api) or 1
|
|
assert(api >= 1 and api % 1 == 0, "manifest api must be a positive integer")
|
|
assert(api <= Version.modApi, ("requires mod API %d; this engine provides %d")
|
|
:format(api, Version.modApi))
|
|
local strict = api >= 2
|
|
|
|
local profile = raw.profile or "content"
|
|
if not Manifest.PROFILES[profile] then
|
|
violation(strict, raw.id, ("unknown profile %q"):format(tostring(profile)))
|
|
profile = "content"
|
|
end
|
|
|
|
local permissions, permissionSet = {}, {}
|
|
for _, name in ipairs(array(raw.permissions)) do
|
|
if Manifest.PERMISSIONS[name] then
|
|
permissions[#permissions + 1] = name
|
|
permissionSet[name] = true
|
|
else
|
|
violation(strict, raw.id, ("unknown permission %q"):format(tostring(name)))
|
|
end
|
|
end
|
|
|
|
local gameVersionOk, gameVersionErr = Semver.validRange(raw.game_version)
|
|
assert(gameVersionOk, ("malformed game_version %q: %s")
|
|
:format(tostring(raw.game_version), tostring(gameVersionErr)))
|
|
|
|
local github = Manifest.parseGithub(raw.github)
|
|
|
|
assert(raw.experimental == nil or type(raw.experimental) == "boolean",
|
|
"experimental must be a boolean")
|
|
local experimental = raw.experimental == true
|
|
|
|
-- #501: a translation declares itself here. Neither the ROM's dialogue
|
|
-- nor the engine's own strings are hashed into the link surface
|
|
-- (src/link/Fingerprint.lua header), so an English install and a Spanish
|
|
-- one run the same lockstep simulation, exactly as two regional carts on
|
|
-- a real cable did. The flag is only the author's claim;
|
|
-- Handshake.onlineBlockers checks it against the ops the mod actually
|
|
-- appended before online play trusts it.
|
|
assert(raw.language == nil or type(raw.language) == "boolean",
|
|
"language must be a boolean")
|
|
local language = raw.language == true
|
|
|
|
-- Gen 2 is opt-in and never inferred. The hook/event names and the
|
|
-- registry names are shared across generations on purpose, so a Gen 1 mod
|
|
-- LOOKS like it would work under Gold; what it actually gets is a subset
|
|
-- (Gold has its own battle, world, script VM and save), and half-running is
|
|
-- worse than not running. A mod author claims Gen 2 only after testing
|
|
-- there, and until then the loader leaves the mod out of a Gold boot
|
|
-- entirely (Loader:_gateGeneration) rather than letting it half-apply.
|
|
-- Absent means false: every mod written before this field existed is Gen 1
|
|
-- only, which is exactly what it was tested as.
|
|
assert(raw.gen2compat == nil or type(raw.gen2compat) == "boolean",
|
|
"gen2compat must be a boolean")
|
|
|
|
-- `games` is the same statement made per game: version ids ("red"),
|
|
-- generations ("gen1"), or "all" (src/mods/ModTargets.lua). gen2compat is
|
|
-- kept as its Gen 2 spelling and only ever ADDS Gen 2, so no shipped
|
|
-- manifest loses a game it already ran on, and gen2compat below is derived
|
|
-- from the resolved list -- the loader's gate reads that one field.
|
|
assert(raw.games == nil or type(raw.games) == "table",
|
|
"games must be an array")
|
|
local games, unknownGames = ModTargets.normalize(raw.games)
|
|
for _, token in ipairs(unknownGames) do
|
|
violation(strict, raw.id, ("unknown game %q"):format(token))
|
|
end
|
|
if raw.games ~= nil and #games == 0 then
|
|
violation(strict, raw.id, "games names no game this engine knows")
|
|
end
|
|
if #games == 0 then
|
|
games = ModTargets.legacy(raw.gen2compat == true)
|
|
elseif raw.gen2compat == true then
|
|
games = ModTargets.union(games, ModTargets.generationVersions(2))
|
|
end
|
|
local gen2compat = ModTargets.covers(games, 2)
|
|
|
|
-- overhauls and total conversions are assumed to move the link
|
|
-- fingerprint unless the manifest says otherwise; content packs and
|
|
-- declared translations are not
|
|
local affectsLink = profile ~= "content" and not language
|
|
if type(raw.affects_link) == "boolean" then affectsLink = raw.affects_link end
|
|
|
|
local function optionalFile(value, field)
|
|
if value == nil then return nil end
|
|
assert(type(value) == "string" and value ~= "", field .. " must be a file path")
|
|
return value
|
|
end
|
|
|
|
local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible)
|
|
|
|
return {
|
|
id = raw.id,
|
|
name = raw.name,
|
|
version = raw.version,
|
|
entry = raw.entry,
|
|
api = api,
|
|
priority = tonumber(raw.priority) or 0,
|
|
dependencies = array(raw.dependencies),
|
|
optional_dependencies = array(raw.optional_dependencies),
|
|
conflicts = conflicts,
|
|
incompatible = array(raw.incompatible),
|
|
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies", raw.dependency_sources),
|
|
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies", raw.dependency_sources),
|
|
conflictSpecs = parseSpecs(conflicts, "conflicts"),
|
|
category = raw.category or "OTHER",
|
|
game_version = raw.game_version,
|
|
description = raw.description or "",
|
|
github = github,
|
|
experimental = experimental,
|
|
profile = profile,
|
|
language = language,
|
|
games = games,
|
|
gen2compat = gen2compat,
|
|
affects_link = affectsLink,
|
|
permissions = permissions,
|
|
permissionSet = permissionSet,
|
|
options_schema = optionalFile(raw.options_schema, "options_schema"),
|
|
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
|
|
force_enable_env = optionalFile(raw.force_enable_env, "force_enable_env"),
|
|
path = path,
|
|
raw = raw,
|
|
}
|
|
end
|
|
|
|
return Manifest
|