mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-18 03:35:56 +02:00
G2 support
This commit is contained in:
+112
-13
@@ -21,7 +21,25 @@ Builtins.OWNER = Schemas.ENGINE
|
||||
-- registry name -> the module that owns its vanilla records. Each exposes
|
||||
-- registerInto(registry, data, owner).
|
||||
local REGISTRANTS = {
|
||||
{ name = "type_chart", from = "src.battle.TypeChart" },
|
||||
-- The one registrant that owns its whole target: R.type_chart rebuilds
|
||||
-- Data.type_chart.matchups AND .types from the op log, so whatever is not
|
||||
-- registered here is not in the merged chart. TypeChart.registerInto
|
||||
-- already reads the matchup rows out of the dataset, but its TYPE records
|
||||
-- are the module's Gen 1 literals -- and on a Gold boot those replaced
|
||||
-- Gold's own 19 type records with Red's 15, dropping DARK, STEEL, BIRD and
|
||||
-- CURSE_TYPE outright and reverting every category to the Gen 1 split
|
||||
-- (src/battle/gen2/Damage.lua:isPhysical reads exactly this table). A
|
||||
-- dataset that ships its own type records is authoritative over the
|
||||
-- module's fallback; Gen 1's extractor writes no `types`, so there the
|
||||
-- override loop finds nothing and behavior is unchanged.
|
||||
{ name = "type_chart", modules = { "src.battle.TypeChart" },
|
||||
install = function(registry, modules, owner, data)
|
||||
modules[1].registerInto(registry, data, owner)
|
||||
local chart = data and data.type_chart
|
||||
for id, record in pairs(chart and chart.types or {}) do
|
||||
registry:override(id, record, owner)
|
||||
end
|
||||
end },
|
||||
{ name = "statuses", from = "src.battle.Status" },
|
||||
{ name = "move_effects", from = "src.battle.MoveEffects" },
|
||||
{ name = "balls", from = "src.battle.Catching" },
|
||||
@@ -50,20 +68,94 @@ local REGISTRANTS = {
|
||||
end },
|
||||
}
|
||||
|
||||
-- Gen 2 (Gold) reimplements the systems behind these registries, and since
|
||||
-- Schemas.GEN2 routes them to their own Data paths the vanilla records that
|
||||
-- land there have to be GOLD's, not Red's. Seeding Red's would be worse than
|
||||
-- seeding nothing: the ids collide (both games call it GREAT_BALL, and Red's
|
||||
-- record carries no `multiplier`, so src/battle/gen2/Catching.lua would read
|
||||
-- nil and quietly drop the x1.5), and Ai.layersFor walks the merged table for
|
||||
-- mod-registered layers, so Red's LAYER_1..LAYER_3 would join Gold's scoring
|
||||
-- passes. A name mapped to `false` is seeded by nothing on Gold, which is the
|
||||
-- right answer for `commands`: the Gen 2 VM's verb table is the mod verbs
|
||||
-- alone (src/script/gen2/Vm.lua:runModCommand), and a Gen 1 row-list verb
|
||||
-- handed Gold's ctx would find no runner on it.
|
||||
--
|
||||
-- Same registry NAMES throughout -- only the records differ, exactly as only
|
||||
-- the target path differs in Schemas.GEN2.
|
||||
local GEN2_REGISTRANTS = {
|
||||
-- src/battle/gen2/Battle.lua owns two registries, so it names its entry
|
||||
-- points rather than exposing one registerInto
|
||||
statuses = { from = "src.battle.gen2.Battle", fn = "registerStatusesInto" },
|
||||
move_effects = { from = "src.battle.gen2.Battle",
|
||||
fn = "registerMoveEffectsInto" },
|
||||
item_effects = { from = "src.core.gen2.ItemEffects" },
|
||||
balls = { from = "src.battle.gen2.Catching" },
|
||||
ai_classes = { from = "src.battle.gen2.Ai" },
|
||||
evolution_methods = { from = "src.core.gen2.Evolution" },
|
||||
-- Gold's curves are coefficient rows in the extracted pokemon.lua, not
|
||||
-- records; the Gen 2 registrant wraps each one as the { expForLevel } record
|
||||
-- Gen 1's registry uses, so the registry keeps ONE record shape across both
|
||||
-- games and a mod writes a custom curve once.
|
||||
growth_rates = { from = "src.battle.gen2.Mon" },
|
||||
commands = false,
|
||||
-- The Gen 2-only content registries (Schemas.GEN1 gates every one of these
|
||||
-- under Gen 1, which is the mirror of the `false` rows in Schemas.GEN2).
|
||||
-- Four of the six are seeded here, from the module that holds the cart's own
|
||||
-- table; the other two merge onto a table that already exists when
|
||||
-- mods:load runs and so have no registrant, exactly as `maps` has none --
|
||||
-- `landmarks` onto the cache's gen2Landmarks.landmarks, and `held_items`
|
||||
-- onto the view src/core/Game2.lua builds from data.items.
|
||||
phone_contacts = { from = "src.core.gen2.Phone" },
|
||||
decorations = { from = "src.core.gen2.Decorations" },
|
||||
apricorns = { from = "src.core.gen2.Apricorns" },
|
||||
radio_channels = { from = "src.ui.gen2.MapRadio" },
|
||||
}
|
||||
|
||||
-- the registrant list for one generation, in registration order: the Gen 1
|
||||
-- entries with the reimplemented ones swapped out, then the Gen 2-only ones
|
||||
-- (item_effects and the content five have no Gen 1 registrant to swap) in a
|
||||
-- fixed order so two boots seed the same registries the same way
|
||||
local GEN2_ONLY_ORDER = { "item_effects", "phone_contacts", "decorations",
|
||||
"apricorns", "radio_channels" }
|
||||
|
||||
local function registrantsFor(generation)
|
||||
if generation ~= 2 then return REGISTRANTS end
|
||||
local out, taken = {}, {}
|
||||
for _, entry in ipairs(REGISTRANTS) do
|
||||
local swap = GEN2_REGISTRANTS[entry.name]
|
||||
if swap == nil then
|
||||
out[#out + 1] = entry
|
||||
elseif swap then
|
||||
taken[entry.name] = true
|
||||
out[#out + 1] = { name = entry.name, from = swap.from, fn = swap.fn }
|
||||
end
|
||||
end
|
||||
for _, name in ipairs(GEN2_ONLY_ORDER) do
|
||||
local swap = GEN2_REGISTRANTS[name]
|
||||
if swap and not taken[name] then
|
||||
out[#out + 1] = { name = name, from = swap.from, fn = swap.fn }
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- the registries the engine seeds, in registration order; the parity tests
|
||||
-- read this to tell an engine-owned namespace from a stray one
|
||||
function Builtins.registries()
|
||||
function Builtins.registries(generation)
|
||||
local names = {}
|
||||
for i, entry in ipairs(REGISTRANTS) do names[i] = entry.name end
|
||||
for i, entry in ipairs(registrantsFor(generation)) do names[i] = entry.name end
|
||||
return names
|
||||
end
|
||||
|
||||
-- the top-level Data keys those registrations bring into existence: the
|
||||
-- only namespaces a mod-free boot is allowed to add
|
||||
function Builtins.namespaceRoots()
|
||||
-- only namespaces a mod-free boot is allowed to add. Routed per generation
|
||||
-- for the same reason the merge is (Schemas.GEN2): on Gold the engine's own
|
||||
-- statuses land in data.gen2Statuses, so gen2Statuses is the root that appears.
|
||||
function Builtins.namespaceRoots(generation)
|
||||
local roots = {}
|
||||
for _, name in ipairs(Builtins.registries()) do
|
||||
local target = Schemas.REGISTRIES[name] and Schemas.REGISTRIES[name].target
|
||||
for _, name in ipairs(Builtins.registries(generation)) do
|
||||
local spec = Schemas.REGISTRIES[name]
|
||||
local target = spec and Schemas.targetFor(name, spec, generation)
|
||||
if target then roots[target:match("^[^%.]+")] = true end
|
||||
end
|
||||
return roots
|
||||
@@ -95,8 +187,8 @@ local function isolate(registry)
|
||||
}, { __index = registry })
|
||||
end
|
||||
|
||||
function Builtins.install(content, data)
|
||||
for _, entry in ipairs(REGISTRANTS) do
|
||||
function Builtins.install(content, data, generation)
|
||||
for _, entry in ipairs(registrantsFor(generation)) do
|
||||
local registry = content[entry.name] and isolate(content[entry.name])
|
||||
if registry then
|
||||
if entry.install then
|
||||
@@ -105,12 +197,19 @@ function Builtins.install(content, data)
|
||||
modules[i] = load(path)
|
||||
if modules[i] == nil then complete = false end
|
||||
end
|
||||
if complete then entry.install(registry, modules, Builtins.OWNER) end
|
||||
-- data is the fourth argument, not the second, so the existing
|
||||
-- installers keep their signature; only a registrant that seeds from
|
||||
-- the loaded dataset (type_chart) reaches for it
|
||||
if complete then
|
||||
entry.install(registry, modules, Builtins.OWNER, data)
|
||||
end
|
||||
else
|
||||
local module = load(entry.from)
|
||||
if module and module.registerInto then
|
||||
module.registerInto(registry, data, Builtins.OWNER)
|
||||
end
|
||||
-- entry.fn names the entry point for a module that owns more than one
|
||||
-- registry (Gold's Battle owns statuses and move_effects); the default
|
||||
-- is the registerInto every single-registry module exposes
|
||||
local into = module and module[entry.fn or "registerInto"]
|
||||
if into then into(registry, data, Builtins.OWNER) end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+70
-34
@@ -32,6 +32,7 @@
|
||||
|
||||
local Manifest = require("src.mods.Manifest")
|
||||
local ManagerState = require("src.mods.ManagerState")
|
||||
local ModTargets = require("src.mods.ModTargets")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Version = require("src.core.Version")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
@@ -45,8 +46,20 @@ local LauncherMods = {}
|
||||
-- the id -> validated-manifest map resolveToggle reads (its dependencySpecs,
|
||||
-- conflictSpecs, version and game_version are exactly the fields the loader's
|
||||
-- Manifest.validate produced); enabledSet is the current desired enable-set.
|
||||
local function statusFor(mods, id, enabledSet, enabled)
|
||||
local function statusFor(mods, id, enabledSet, enabled, version, forcedFor)
|
||||
local m = mods[id]
|
||||
local forced = forcedFor(id)
|
||||
-- The game this mod is for outranks everything below it: a mod that is not
|
||||
-- going to run here has no useful conflict or dependency verdict. Same
|
||||
-- source as the in-game manager (src/mods/ModTargets.lua), so the two
|
||||
-- surfaces cannot disagree about the same mod.
|
||||
if version and not ModTargets.supports(m, version) then
|
||||
if forced then
|
||||
return "warn", "Forced onto " .. ModTargets.gameLabel(version)
|
||||
.. " by you (untested)"
|
||||
end
|
||||
return "other_game", ModTargets.detail(m, version)
|
||||
end
|
||||
-- conflict only bites an enabled mod: resolveToggle's conflict list is
|
||||
-- bidirectional (this mod's conflicts spec vs an enabled other, and an
|
||||
-- enabled other's spec vs this mod), which is exactly the launcher chip.
|
||||
@@ -59,8 +72,10 @@ local function statusFor(mods, id, enabledSet, enabled)
|
||||
"Conflicts with " .. ((other and other.name) or otherId)
|
||||
end
|
||||
end
|
||||
-- warn: the engine is outside the mod's game_version range
|
||||
if m.game_version
|
||||
-- warn: the engine is outside the mod's game_version range. The dev
|
||||
-- placeholder is skipped here exactly as Loader.devEngine skips it, so the
|
||||
-- launcher and the loader cannot disagree about the same mod.
|
||||
if m.game_version and Version.engine:match("^0%.0%.0%-") == nil
|
||||
and not Semver.satisfies(Version.engine, m.game_version) then
|
||||
return "warn", "Needs engine " .. m.game_version
|
||||
.. " (have " .. Version.engine .. ")"
|
||||
@@ -74,6 +89,13 @@ local function statusFor(mods, id, enabledSet, enabled)
|
||||
return "warn", "Needs " .. spec.id .. " (not installed)"
|
||||
elseif not enabledSet[spec.id] then
|
||||
return "warn", "Needs " .. spec.id .. " (disabled)"
|
||||
-- installed and on, but not for THIS game: the loader skips the
|
||||
-- dependency and the skip is contagious (Loader:_enforceDependencies),
|
||||
-- so a mod that runs everywhere still does not run here
|
||||
elseif version
|
||||
and not ModTargets.runsHere(dep, version, nil, forcedFor(spec.id)) then
|
||||
return "warn", "Needs " .. spec.id .. " (not for "
|
||||
.. ModTargets.gameLabel(version) .. ")"
|
||||
elseif spec.range
|
||||
and not Semver.satisfies(dep.version, spec.range) then
|
||||
return "warn", "Needs " .. spec.id .. " " .. spec.range
|
||||
@@ -82,34 +104,44 @@ local function statusFor(mods, id, enabledSet, enabled)
|
||||
return "ok", "Ready"
|
||||
end
|
||||
|
||||
-- deriveList(manifests, options) -> the panel row list, pure.
|
||||
-- deriveList(manifests, options [, version]) -> the panel row list, pure.
|
||||
-- manifests is an array of validated manifests (Manifest.validate output);
|
||||
-- options is the options table (only options.mods is read). Rows come back
|
||||
-- sorted by id so the panel order is stable.
|
||||
function LauncherMods.deriveList(manifests, options)
|
||||
local mods = options and options.mods or {}
|
||||
-- options is the options table (options.mods, options.modsByVersion and
|
||||
-- options.modsGen2 are read). `version` is the game the panel is showing:
|
||||
-- nil keeps the pre-per-game view, where the shared flag is the whole answer.
|
||||
-- Rows come back sorted by id so the panel order is stable.
|
||||
function LauncherMods.deriveList(manifests, options, version)
|
||||
local ordered = {}
|
||||
for _, m in ipairs(manifests) do ordered[#ordered + 1] = m end
|
||||
table.sort(ordered, function(a, b) return a.id < b.id end)
|
||||
|
||||
-- the override is one answer per game (SaveData.modForced), the same scope
|
||||
-- the loader resolves it under
|
||||
local forcedFor = function(id)
|
||||
return version and SaveData.modForced(options, id, version) or false
|
||||
end
|
||||
local byId, enabledSet = {}, {}
|
||||
for _, m in ipairs(ordered) do
|
||||
byId[m.id] = m
|
||||
-- missing entry means enabled, matching the loader -- except experimental
|
||||
-- mods, which stay off until the player opts in
|
||||
if mods[m.id] == false then
|
||||
-- stay off
|
||||
elseif mods[m.id] == true then
|
||||
enabledSet[m.id] = true
|
||||
elseif not m.experimental then
|
||||
enabledSet[m.id] = true
|
||||
end
|
||||
-- this game's choice, then the shared flag, then the default: enabled,
|
||||
-- matching the loader -- except experimental mods, which stay off until
|
||||
-- the player opts in. Scoped through modScope, so this reads exactly what
|
||||
-- setEnabled writes and the loader loads: while per-game flags are a
|
||||
-- preview the shared flag is the whole answer on every surface.
|
||||
local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version))
|
||||
if decided == nil then decided = not m.experimental end
|
||||
if decided then enabledSet[m.id] = true end
|
||||
end
|
||||
|
||||
local out = {}
|
||||
for _, m in ipairs(ordered) do
|
||||
local enabled = enabledSet[m.id] == true
|
||||
local status, detail = statusFor(byId, m.id, enabledSet, enabled)
|
||||
local forced = forcedFor(m.id)
|
||||
local status, detail =
|
||||
statusFor(byId, m.id, enabledSet, enabled, version, forcedFor)
|
||||
-- nil, not false, when the panel is showing every game at once
|
||||
local here = nil
|
||||
if version then here = ModTargets.runsHere(m, version, nil, forced) end
|
||||
local raw = m.raw or {}
|
||||
local badge = tostring(raw.category or m.profile or "MOD"):upper()
|
||||
if m.experimental then badge = "EXPERIMENTAL" end
|
||||
@@ -124,6 +156,10 @@ function LauncherMods.deriveList(manifests, options)
|
||||
statusDetail = detail,
|
||||
github = m.github,
|
||||
experimental = m.experimental == true,
|
||||
-- what game this mod is for, and whether it will run on the one the
|
||||
-- panel is showing (src/mods/ModTargets.lua)
|
||||
targets = ModTargets.chip(m),
|
||||
targetsHere = here,
|
||||
}
|
||||
end
|
||||
return out
|
||||
@@ -239,13 +275,13 @@ local function discover()
|
||||
return out
|
||||
end
|
||||
|
||||
-- list() -> the mods-panel rows for the current install. Reads the same
|
||||
-- options.mods enable-state the loader persists, so a toggle here is what the
|
||||
-- game sees on its next boot.
|
||||
function LauncherMods.list()
|
||||
-- list([version]) -> the mods-panel rows for the current install. Reads the
|
||||
-- same enable-state the loader persists, so a toggle here is what the game
|
||||
-- sees on its next boot; `version` narrows that to one game's answers.
|
||||
function LauncherMods.list(version)
|
||||
local ok, result = pcall(function()
|
||||
local options = SaveData.loadOptions()
|
||||
return LauncherMods.deriveList(discover(), options)
|
||||
return LauncherMods.deriveList(discover(), options, version)
|
||||
end)
|
||||
if not ok then
|
||||
-- a single bad options/mod file must not blank the launcher
|
||||
@@ -328,27 +364,27 @@ function LauncherMods.translationStrings()
|
||||
return merged
|
||||
end
|
||||
|
||||
-- setEnabled(id, enabled): persist options.mods[id] in the exact shape
|
||||
-- Loader:_saveState writes (a plain boolean), so the running game and the
|
||||
-- in-game ManagerState pick it up unchanged.
|
||||
function LauncherMods.setEnabled(id, enabled)
|
||||
-- setEnabled(id, enabled [, version]): persist options.mods[id] in the exact
|
||||
-- shape Loader:_saveState writes (a plain boolean), so the running game and
|
||||
-- the in-game ManagerState pick it up unchanged. With `version` the choice
|
||||
-- lands in that game's overlay instead and no other game moves.
|
||||
function LauncherMods.setEnabled(id, enabled, version)
|
||||
local options = SaveData.loadOptions()
|
||||
options.mods = options.mods or {}
|
||||
options.mods[id] = enabled and true or false
|
||||
SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version))
|
||||
SaveData.saveOptions(options)
|
||||
return true
|
||||
end
|
||||
|
||||
-- setAllEnabled(ids, enabled): the launcher's Enable all / Disable all buttons
|
||||
-- (#647). Writes exactly the options.mods shape setEnabled does, but loads and
|
||||
-- setAllEnabled(ids, enabled [, version]): the launcher's Enable all / Disable
|
||||
-- all buttons (#647). Writes what setEnabled writes, but loads and
|
||||
-- saves once for the whole list: saveOptions rewrites the whole options file per
|
||||
-- call, so looping setEnabled over a big mods folder is one disk write per mod
|
||||
-- and leaves a half-applied state behind if one of them fails.
|
||||
function LauncherMods.setAllEnabled(ids, enabled)
|
||||
function LauncherMods.setAllEnabled(ids, enabled, version)
|
||||
local options = SaveData.loadOptions()
|
||||
options.mods = options.mods or {}
|
||||
local scope = SaveData.modScope(version)
|
||||
for _, id in ipairs(ids or {}) do
|
||||
options.mods[id] = enabled and true or false
|
||||
SaveData.setModEnabled(options, id, enabled, scope)
|
||||
end
|
||||
SaveData.saveOptions(options)
|
||||
return true
|
||||
|
||||
+337
-17
@@ -2,16 +2,19 @@ local Json = require("src.link.Json")
|
||||
local Logger = require("src.core.Logger")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Data = require("src.core.Data")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Version = require("src.core.Version")
|
||||
local Assets = require("src.render.Assets")
|
||||
local ModUI = require("src.ui.ModUI")
|
||||
local AssetTransform = require("src.mods.AssetTransform")
|
||||
local Manifest = require("src.mods.Manifest")
|
||||
local Merge = require("src.mods.Merge")
|
||||
local ModTargets = require("src.mods.ModTargets")
|
||||
local Registry = require("src.mods.Registry")
|
||||
local Schemas = require("src.mods.Schemas")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Events = require("src.mods.Events")
|
||||
local Gen2Compat = require("src.mods.Gen2Compat")
|
||||
local Hooks = require("src.mods.Hooks")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
@@ -20,6 +23,17 @@ Loader.__index = Loader
|
||||
|
||||
local MOD_STATE_FILE = "mod_state.lua" -- legacy migration only
|
||||
|
||||
-- The working tree's engine version is the "0.0.0-dev" placeholder that CI
|
||||
-- restamps into the packed game.love (src/core/Version.lua:7), and it sorts
|
||||
-- BELOW every release, so a checkout would fail every mod that names a
|
||||
-- floor. A placeholder is not a compatibility statement: skip the range
|
||||
-- check rather than answer it wrong. A stamped build checks as it always did.
|
||||
-- Read at call time, not captured: a build stamps Version before this loads
|
||||
-- and a test stamps it after.
|
||||
local function devEngine()
|
||||
return Version.engine:match("^0%.0%.0%-") ~= nil
|
||||
end
|
||||
|
||||
-- walk a dotted target path without creating anything; the base view a
|
||||
-- registry folds against must never perturb Data on a mod-free boot
|
||||
local function resolvePath(root, path)
|
||||
@@ -62,6 +76,37 @@ end
|
||||
|
||||
local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 }
|
||||
|
||||
-- The Gen 1 engine modules a Gold boot never instantiates. Each one still
|
||||
-- LOADS under Gen 2 -- require finds the file and hands back a module table --
|
||||
-- so a mod that captures src.core.Game and reads Game.overworld gets nil for
|
||||
-- the life of the process and its patches land on code nothing runs. That is
|
||||
-- the failure the generation gate exists to prevent, and it is worth naming
|
||||
-- when a forced or gen2compat mod reaches for one anyway. Gold's own
|
||||
-- counterparts are src/core/Game2.lua and the src/*/gen2/ trees; the live
|
||||
-- owner is in the game.ready payload and mod.world resolves per generation.
|
||||
local GEN1_ONLY_MODULES = {
|
||||
["src.core.Game"] = true,
|
||||
["src.world.OverworldController"] = true,
|
||||
["src.world.PikachuFollower"] = true,
|
||||
["src.world.NPC"] = true,
|
||||
["src.world.Collision"] = true,
|
||||
["src.world.WorldAPI"] = true,
|
||||
["src.world.Map"] = true,
|
||||
["src.battle.BattleState"] = true,
|
||||
["src.script.ScriptRunner"] = true,
|
||||
-- Not a dead patch but a dead SCRIPT: Gold's registry carries mod verbs
|
||||
-- only (src/mods/Builtins.lua:100), so every Gen 1 built-in in this table
|
||||
-- resolves here and then runs as nothing.
|
||||
["src.script.Commands"] = true,
|
||||
-- Loads fine under Gold and paints Red's chrome over Gold's options screen,
|
||||
-- whose layout is one 18x16 box rather than four 20x4 ones.
|
||||
["src.ui.OptionRows"] = true,
|
||||
["src.ui.PartyMenu"] = true,
|
||||
["src.ui.BoxMenu"] = true,
|
||||
["src.ui.StartMenu"] = true,
|
||||
["src.ui.OptionsMenu"] = true,
|
||||
}
|
||||
|
||||
-- the src.* modules the mod surface points authors at: another mod's
|
||||
-- exports carry a version string that wants range-checking before use, and
|
||||
-- ChipAsm is the authoring path for chip music and sfx
|
||||
@@ -71,6 +116,20 @@ local SUPPORTED_REQUIRES = {
|
||||
["src.pokemon.Stats"] = true, -- Stats.isShiny / calc for indicator mods
|
||||
}
|
||||
|
||||
-- Where this file lives, so the shim can tell an engine require from a mod's:
|
||||
-- a mod chunk is named after its own directory, and this is the only test that
|
||||
-- survives a lazy require made long after Runtime.currentMod went back to nil.
|
||||
local ENGINE_PREFIX = (debug.getinfo(1, "S").source or "")
|
||||
:gsub("^@", ""):gsub("mods[/\\]Loader%.lua$", "")
|
||||
|
||||
local function callerIsMod(level)
|
||||
if ENGINE_PREFIX == "" then return false end
|
||||
local info = debug.getinfo(level, "S")
|
||||
local source = info and info.source
|
||||
if not source or source:sub(1, 1) ~= "@" then return false end
|
||||
return source:sub(2, 1 + #ENGINE_PREFIX) ~= ENGINE_PREFIX
|
||||
end
|
||||
|
||||
local function scanRequire(name)
|
||||
local modId = Runtime.currentMod
|
||||
if not modId or type(name) ~= "string" then return end
|
||||
@@ -81,6 +140,23 @@ local function scanRequire(name)
|
||||
devShim.warned[key] = true
|
||||
Logger.warn("[%s] undeclared %s require: %s", modId, permission, name)
|
||||
end
|
||||
-- A Gen 1-only module on a Gold boot is not a permissions question, it is a
|
||||
-- dead patch: reported once, attributed, and onto the boot error feed the
|
||||
-- manager shows the player rather than a dev-only log line.
|
||||
if devShim.generation ~= 1 and GEN1_ONLY_MODULES[name]
|
||||
and not Gen2Compat.serves(name) then
|
||||
local key = modId .. "|gen2|" .. name
|
||||
if not devShim.warned[key] then
|
||||
devShim.warned[key] = true
|
||||
local message = ("%s: requires %s, which a Gen 2 game never runs and "
|
||||
.. "src/mods/Gen2Compat.lua has no adapter for; take the game from "
|
||||
.. "the game.ready payload and mod.world")
|
||||
:format(modId, name)
|
||||
local errors = devShim.errors
|
||||
if errors then errors[#errors + 1] = message end
|
||||
Logger.error("%s", message)
|
||||
end
|
||||
end
|
||||
-- link modules are the one place a mod can reach the wire, so network is
|
||||
-- the permission that governs them
|
||||
if name:match("^src%.link%.") then
|
||||
@@ -115,7 +191,25 @@ function Loader:_installDevShim()
|
||||
_G.require = function(name, ...)
|
||||
-- only the mod's own call is the mod's doing; whatever that module
|
||||
-- requires in turn is the engine wiring itself up
|
||||
if devShim.depth == 0 then scanRequire(name) end
|
||||
if devShim.depth == 0 then
|
||||
scanRequire(name)
|
||||
-- The Gen 1 name a mod asked for, answered by the Gen 2 arm behind it.
|
||||
-- Engine code keeps the real module: src/render/PaletteFX.lua:776
|
||||
-- requires src.core.Game on both generations and means it.
|
||||
if devShim.generation ~= 1 and Gen2Compat.serves(name)
|
||||
and callerIsMod(3) then
|
||||
local adapter = Gen2Compat.resolve(name, Runtime.currentMod)
|
||||
if adapter then
|
||||
local key = "adapter|" .. name
|
||||
if not devShim.warned[key] then
|
||||
devShim.warned[key] = true
|
||||
Logger.info("gen2 facade: %s -> %s", name,
|
||||
tostring(Gen2Compat.ADAPTERS[name]))
|
||||
end
|
||||
return adapter
|
||||
end
|
||||
end
|
||||
end
|
||||
devShim.depth = devShim.depth + 1
|
||||
local ok, result = pcall(delegate, name, ...)
|
||||
devShim.depth = devShim.depth - 1
|
||||
@@ -141,20 +235,67 @@ function Loader.new(opts)
|
||||
modInput = {},
|
||||
fs = (opts and opts.fs) or (love and love.filesystem),
|
||||
dev = dev,
|
||||
-- Which generation this boot is (1 or 2). Fixed at construction: the
|
||||
-- active version is set once in main.lua's bootGame before anything
|
||||
-- builds a loader, and a run never changes generation underneath one.
|
||||
-- opts.generation is the test seam.
|
||||
generation = (opts and opts.generation) or GameVersion.generation(),
|
||||
}, Loader)
|
||||
assert(self.fs, "Loader.new requires opts.fs when love is unavailable")
|
||||
-- Schemas.shapeFor, not the catalog spec: a registry whose Gen 2 records are
|
||||
-- shaped differently (a species' specialAttack/specialDefense, an encounter
|
||||
-- table keyed by kind, a trainer CLASS hanging off .classes) carries its Gen
|
||||
-- 2 shape beside the Gen 1 one, and resolving it once here is what makes
|
||||
-- every reader downstream generation-blind: Schemas.check off registry.spec,
|
||||
-- Registry's fold/baseAt/baseIds, _mergeOrder's depth and _merge's
|
||||
-- spec.write / spec.semantics all read this one spec and never ask again.
|
||||
-- Gen 1 and any registry with no Gen 2 shape get the catalog table itself.
|
||||
for name, spec in pairs(Schemas.REGISTRIES) do
|
||||
self.content[name] = Registry.new(name, spec)
|
||||
self.content[name] = Registry.new(name, Schemas.shapeFor(name, spec, self.generation))
|
||||
end
|
||||
self.disabled = {}
|
||||
self.gen2Forced = {}
|
||||
return self
|
||||
end
|
||||
|
||||
-- The game this boot is, or nil when a harness injected a generation the
|
||||
-- running version disagrees with (only the generation can be trusted then).
|
||||
function Loader:_targetVersion()
|
||||
local version = GameVersion.get and GameVersion.get()
|
||||
if not (version and GameVersion.VERSIONS[version]) then return nil end
|
||||
if GameVersion.generation(version) ~= self.generation then return nil end
|
||||
return version
|
||||
end
|
||||
|
||||
-- The version an enable flag is read and written under: this game once
|
||||
-- per-game flags are live, nil (the shared flag) while they are a preview.
|
||||
-- Reads and writes go through the same answer so the two can never drift.
|
||||
function Loader:_enableScope()
|
||||
return SaveData.modScope(self:_targetVersion())
|
||||
end
|
||||
|
||||
function Loader:_loadState()
|
||||
self.disabled = {}
|
||||
local options = SaveData.loadOptions(self.fs)
|
||||
for id, enabled in pairs(options.mods or {}) do
|
||||
if enabled == false then self.disabled[id] = true end
|
||||
local scope = self:_enableScope()
|
||||
local ids = {}
|
||||
for id in pairs(options.mods or {}) do ids[id] = true end
|
||||
local bucket = scope and (options.modsByVersion or {})[scope]
|
||||
if type(bucket) == "table" then
|
||||
for id in pairs(bucket) do ids[id] = true end
|
||||
end
|
||||
for id in pairs(ids) do
|
||||
if SaveData.modEnabled(options, id, scope) == false then
|
||||
self.disabled[id] = true
|
||||
end
|
||||
end
|
||||
-- the player's target override, resolved for THIS game: forcing a mod onto
|
||||
-- Gold never changes whether it runs on Red (SaveData.modForced)
|
||||
self.gen2Forced = {}
|
||||
for id in pairs(options.modsGen2 or {}) do
|
||||
if SaveData.modForced(options, id, self:_targetVersion(), self.generation) then
|
||||
self.gen2Forced[id] = true
|
||||
end
|
||||
end
|
||||
-- mod.options reads through this; M11 owns writing it back
|
||||
self.modOptions = options.modOptions or {}
|
||||
@@ -185,8 +326,14 @@ function Loader:_saveState()
|
||||
if not self.fs.write then return end
|
||||
local options = SaveData.loadOptions(self.fs)
|
||||
options.mods = options.mods or {}
|
||||
local scope = self:_enableScope()
|
||||
local version = self:_targetVersion()
|
||||
for id in pairs(self.mods) do
|
||||
options.mods[id] = not self.disabled[id]
|
||||
SaveData.setModEnabled(options, id, not self.disabled[id], scope)
|
||||
-- only the games this boot can answer for: another version's overrides
|
||||
-- are not this run's to rewrite. With no version (an injected-generation
|
||||
-- harness) the override stays in memory for this boot only.
|
||||
SaveData.setModForced(options, id, self.gen2Forced[id] == true, version)
|
||||
end
|
||||
SaveData.saveOptions(options, self.fs)
|
||||
end
|
||||
@@ -199,6 +346,25 @@ function Loader:setEnabled(id, enabled)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Takes effect on the next boot, like every other load-time decision: the
|
||||
-- gate runs once, before any entry chunk. Second return is false when the
|
||||
-- choice could not be persisted for a game, so the caller does not promise a
|
||||
-- restart will honour it.
|
||||
function Loader:setGen2Forced(id, forced)
|
||||
if not self.mods[id] then return false, false end
|
||||
self.gen2Forced[id] = forced or nil
|
||||
self:_saveState()
|
||||
local persisted = self:_targetVersion() ~= nil and self.fs.write ~= nil
|
||||
if not persisted then
|
||||
Logger.warn("mod %s: target override kept for this boot only", id)
|
||||
end
|
||||
return true, persisted
|
||||
end
|
||||
|
||||
function Loader:isGen2Forced(id)
|
||||
return self.gen2Forced[id] == true
|
||||
end
|
||||
|
||||
function Loader:_discover()
|
||||
if not self.fs.getDirectoryItems then return end
|
||||
local roots = { "mods" }
|
||||
@@ -240,10 +406,73 @@ function Loader:_fail(mod, state, reason)
|
||||
Logger.error("mod %s failed: %s", mod.manifest.id, reason)
|
||||
end
|
||||
|
||||
-- left out rather than broken: inactive like a failure, but off the boot
|
||||
-- error list and rendered with its own manager row state (ManagerState:264)
|
||||
function Loader:_skip(mod, state, reason)
|
||||
if mod.failed then return end
|
||||
mod.failed, mod.state, mod.skipReason = true, state, reason
|
||||
Logger.info("mod %s skipped: %s", mod.manifest.id, reason)
|
||||
end
|
||||
|
||||
local function isActive(mod)
|
||||
return mod.enabled and not mod.failed
|
||||
end
|
||||
|
||||
-- the Data path a registry merges into for THIS boot's generation, or nil
|
||||
-- when it has no home here (Schemas.GEN2)
|
||||
function Loader:_target(name, spec)
|
||||
return Schemas.targetFor(name, spec, self.generation)
|
||||
end
|
||||
|
||||
-- Which games a mod runs on is opt-in per manifest (`games`, and the legacy
|
||||
-- gen2compat it subsumes). A mod that did not claim THIS game is left out of
|
||||
-- the boot whole: not loaded, no registrations, no subscriptions. The
|
||||
-- alternative is what this replaces -- the mod loads, the manager shows it
|
||||
-- enabled, and roughly four of its hooks out of a hundred actually fire --
|
||||
-- which reads as a broken mod rather than an absent one. This is a skip and
|
||||
-- not a failure: it is not the mod's bug, so it stays off the boot error list
|
||||
-- and out of the log's error stream, and the manager gives it its own row
|
||||
-- state.
|
||||
--
|
||||
-- The gate is per VERSION, not only per generation: `games: ["blue"]` is a
|
||||
-- claim about Blue, and the two mod UIs already say "For Blue, not Red" off
|
||||
-- the same ModTargets answer, so enforcing it here is what makes that line a
|
||||
-- verdict instead of a decoration.
|
||||
--
|
||||
-- The player owns the override. The manifest is the AUTHOR's claim, and a mod
|
||||
-- written before the field existed can never carry it, so `options.modsGen2`
|
||||
-- (the manager's TRY HERE ANYWAY toggle, scoped to one game) forces one on for
|
||||
-- this boot; a forced mod loads normally and keeps a note saying it was never
|
||||
-- verified here.
|
||||
function Loader:_gateGeneration()
|
||||
local version = self:_targetVersion()
|
||||
for _, id in ipairs(orderedIds(self.mods, isActive)) do
|
||||
local mod = self.mods[id]
|
||||
if ModTargets.supports(mod.manifest, version, self.generation) then
|
||||
-- nothing to say: the author claimed this game
|
||||
elseif self.gen2Forced[id] then
|
||||
mod.forcedGen2 = true
|
||||
mod.skipReason = ("forced onto this Gen %d game; not verified by its author")
|
||||
:format(self.generation)
|
||||
Logger.warn("mod %s: %s", id, mod.skipReason)
|
||||
elseif self.generation == 2 and not mod.manifest.gen2compat then
|
||||
-- the whole-generation miss keeps its own wording: gen2compat is the
|
||||
-- field the author has to add, so the skip line names it
|
||||
self:_skip(mod, "wrong_generation",
|
||||
("not marked gen2compat; this is a Gen %d game"):format(self.generation))
|
||||
elseif version then
|
||||
-- claimed some game, just not this one (ModTargets.detail)
|
||||
self:_skip(mod, "wrong_generation", ModTargets.detail(mod.manifest, version))
|
||||
else
|
||||
-- worded from the loader's own generation, not from GameVersion's
|
||||
-- current id: the two agree in a real boot, and a harness that injects
|
||||
-- a generation should not produce a sentence naming the wrong game
|
||||
self:_skip(mod, "wrong_generation",
|
||||
("not made for a Gen %d game"):format(self.generation))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Loader:_exists(path)
|
||||
if not self.fs.getInfo then return true end
|
||||
return self.fs.getInfo(path) ~= nil
|
||||
@@ -264,7 +493,7 @@ function Loader:_validate()
|
||||
elseif manifest.assets_transforms
|
||||
and not self:_exists(mod.path .. "/" .. manifest.assets_transforms) then
|
||||
reason = "assets_transforms file missing: " .. manifest.assets_transforms
|
||||
elseif manifest.game_version then
|
||||
elseif manifest.game_version and not devEngine() then
|
||||
local ok, err = Semver.satisfies(Version.engine, manifest.game_version)
|
||||
if not ok then
|
||||
reason = ("needs game version %s, engine is %s")
|
||||
@@ -286,11 +515,20 @@ function Loader:_enforceDependencies()
|
||||
local mod = self.mods[id]
|
||||
for _, spec in ipairs(mod.manifest.dependencySpecs) do
|
||||
local dep = self.mods[spec.id]
|
||||
local reason
|
||||
local reason, skip
|
||||
if not dep then
|
||||
reason = "missing dependency: " .. spec.id
|
||||
elseif not dep.enabled then
|
||||
reason = ("dependency %s is disabled"):format(spec.id)
|
||||
elseif dep.state == "wrong_generation" then
|
||||
-- the gate's skip is contagious as a SKIP, not as a failure: the
|
||||
-- dependency has no bug to report and neither does this mod, so
|
||||
-- nothing here lands on the boot error list
|
||||
skip = true
|
||||
-- carry the dependency's own reason: it names the game or the
|
||||
-- missing gen2compat, and a guess here would name the wrong one
|
||||
reason = ("depends on %s, which does not run here (%s)")
|
||||
:format(spec.id, dep.skipReason or "not made for this game")
|
||||
elseif dep.failed then
|
||||
reason = ("dependency %s failed to load"):format(spec.id)
|
||||
elseif spec.range
|
||||
@@ -299,7 +537,11 @@ function Loader:_enforceDependencies()
|
||||
:format(spec.id, spec.range, dep.manifest.version)
|
||||
end
|
||||
if reason then
|
||||
self:_fail(mod, "blocked_dependency", reason)
|
||||
if skip then
|
||||
self:_skip(mod, "wrong_generation", reason)
|
||||
else
|
||||
self:_fail(mod, "blocked_dependency", reason)
|
||||
end
|
||||
changed = true
|
||||
break
|
||||
end
|
||||
@@ -443,7 +685,9 @@ function Loader:_mergeOrder()
|
||||
for name, registry in pairs(self.content) do
|
||||
names[#names + 1] = name
|
||||
local segments = 0
|
||||
for _ in (registry.spec.target or ""):gmatch("[^%.]+") do
|
||||
-- the routed path, not spec.target: nesting is a property of where the
|
||||
-- content actually lands, and that is per generation (Schemas.GEN2)
|
||||
for _ in (self:_target(name, registry.spec) or ""):gmatch("[^%.]+") do
|
||||
segments = segments + 1
|
||||
end
|
||||
depth[name] = segments
|
||||
@@ -484,27 +728,57 @@ function Loader:_contentApi(mod, registry, deprecation)
|
||||
if apiLevel >= 2 then error(err, 0) end
|
||||
Logger.warn("[%s] %s", modId, err)
|
||||
end
|
||||
-- A registry with no home in this generation (Schemas.routing) takes the
|
||||
-- write and drops it. Reported once per mod per registry, into the same feed
|
||||
-- the manager shows, because a mod that declared gen2compat and then wrote
|
||||
-- here is owed the reason -- but NOT fatal: a mod that supports both
|
||||
-- generations registers its content unconditionally and should still load
|
||||
-- the half that does apply.
|
||||
--
|
||||
-- Worded from loader.generation, the way _gateGeneration's skipReason is,
|
||||
-- because the gating runs BOTH ways now: Schemas.GEN1 gates the six Gen
|
||||
-- 2-only registries (held_items, phone_contacts, decorations, apricorns,
|
||||
-- landmarks, radio_channels), so a Red boot rejecting a write to
|
||||
-- `decorations` must not claim it has "no Gen 2 target".
|
||||
local gated = Schemas.gatedFor(registry.name, loader.generation)
|
||||
local toldGated = false
|
||||
local function dropped()
|
||||
if not gated then return false end
|
||||
if not toldGated then
|
||||
toldGated = true
|
||||
local message = ("%s: the %s registry has no Gen %d target; those "
|
||||
.. "registrations do not apply here")
|
||||
:format(modId, registry.name, loader.generation)
|
||||
loader.errors[#loader.errors + 1] = message
|
||||
Logger.warn("%s", message)
|
||||
end
|
||||
return true
|
||||
end
|
||||
return {
|
||||
register = function(_, id, value)
|
||||
note()
|
||||
if dropped() then return nil end
|
||||
validate("register", id, value)
|
||||
loader:_journal(registry.name)
|
||||
return registry:register(id, value, modId)
|
||||
end,
|
||||
override = function(_, id, value)
|
||||
note()
|
||||
if dropped() then return nil end
|
||||
validate("override", id, value)
|
||||
loader:_journal(registry.name)
|
||||
return registry:override(id, value, modId)
|
||||
end,
|
||||
patch = function(_, id, partial)
|
||||
note()
|
||||
if dropped() then return nil end
|
||||
validate("patch", id, partial)
|
||||
loader:_journal(registry.name)
|
||||
return registry:patch(id, partial, modId)
|
||||
end,
|
||||
remove = function(_, id)
|
||||
note()
|
||||
if dropped() then return nil end
|
||||
loader:_journal(registry.name)
|
||||
return registry:remove(id, modId)
|
||||
end,
|
||||
@@ -767,10 +1041,21 @@ function Loader:_api(mod)
|
||||
-- acts on is still being wired when the entry chunk runs
|
||||
local world
|
||||
setmetatable(api, { __index = function(_, key)
|
||||
-- mod.game is the live service owner, resolved per generation the way
|
||||
-- mod.world is: src/core/Game.lua's singleton under Gen 1, the Game2
|
||||
-- INSTANCE Gold injected under Gen 2. Read on every touch rather than
|
||||
-- cached, because the Gen 1 singleton's stack and save fill in after the
|
||||
-- entry chunk runs. This is what a mod should hold instead of requiring
|
||||
-- src.core.Game, which under Gold hands back a table nothing instantiated.
|
||||
if key == "game" then return loader:_game() end
|
||||
if key ~= "world" then return nil end
|
||||
if world then return world end
|
||||
local game = loader:_game()
|
||||
local module = game and engineRequire("src.world.WorldAPI")
|
||||
-- one facade name, one arm per generation: Gold's world is not a stack
|
||||
-- state and its flags are a bitfield, so the resolution differs even
|
||||
-- where the method set does not (src/world/gen2/WorldAPI.lua)
|
||||
local module = game and engineRequire(loader.generation == 2
|
||||
and "src.world.gen2.WorldAPI" or "src.world.WorldAPI")
|
||||
if not module then return nil end
|
||||
world = module.new(game, modId)
|
||||
return world
|
||||
@@ -781,8 +1066,16 @@ end
|
||||
-- the live Game. An injected reference wins so a headless caller can hand
|
||||
-- over a stub; otherwise the boot singleton, whose stack and overworld fill
|
||||
-- in after this loader returns -- holding the table keeps the facade live.
|
||||
--
|
||||
-- Gen 2 has no fallback to reach for: src/core/Game.lua is the Gen 1 service
|
||||
-- owner and a Gold boot never loads it, so returning it would hand mod.world a
|
||||
-- live-looking object with no stack, no save and no overworld. Gold injects
|
||||
-- itself (src/core/Game2.lua), and nil here is the honest answer if it
|
||||
-- somehow did not.
|
||||
function Loader:_game()
|
||||
return self.game or engineRequire("src.core.Game")
|
||||
if self.game then return self.game end
|
||||
if self.generation ~= 1 then return nil end
|
||||
return engineRequire("src.core.Game")
|
||||
end
|
||||
|
||||
function Loader:_loadMod(mod)
|
||||
@@ -926,8 +1219,8 @@ function Loader:load(data)
|
||||
self.baseData = data
|
||||
-- every registry folds against the pristine view of its Data target;
|
||||
-- resolution is lazy so optional namespaces may appear later
|
||||
for _, registry in pairs(self.content) do
|
||||
local target = registry.spec.target
|
||||
for name, registry in pairs(self.content) do
|
||||
local target = self:_target(name, registry.spec)
|
||||
if target then
|
||||
registry.base = function()
|
||||
return data and resolvePath(data, target)
|
||||
@@ -936,7 +1229,10 @@ function Loader:load(data)
|
||||
end
|
||||
-- vanilla content is registrations too, and they land before discovery so
|
||||
-- a mod's register collides with the engine's and has to say override
|
||||
require("src.mods.Builtins").install(self.content, data)
|
||||
-- the generation decides WHICH module owns a registry's vanilla records:
|
||||
-- Gold reimplements the battle rules, so its own statuses/balls/AI records
|
||||
-- go in instead of Red's, not beside them (src/mods/Builtins.lua)
|
||||
require("src.mods.Builtins").install(self.content, data, self.generation)
|
||||
self:_loadState()
|
||||
self:_discover()
|
||||
-- Experimental mods stay off until the player opts in: a missing
|
||||
@@ -968,9 +1264,26 @@ function Loader:load(data)
|
||||
-- engine call sites reach these buses -- and this error feed, for failures
|
||||
-- that only surface at play time -- through Runtime from here on
|
||||
Runtime.install(self.events, self.hooks, self.errors)
|
||||
-- before _validate: a mod that is not running on this generation should not
|
||||
-- also be reported for a missing entry file it will never be asked for
|
||||
self:_gateGeneration()
|
||||
self:_validate()
|
||||
local ordered = self:_resolve()
|
||||
if self.dev then self:_installDevShim() end
|
||||
-- The shim is a process singleton, so whichever loader is running owns these
|
||||
-- two: a harness that builds a Gen 1 loader after a Gen 2 one must not keep
|
||||
-- reporting against the old generation or the old error feed.
|
||||
devShim.generation = self.generation
|
||||
devShim.errors = self.errors
|
||||
-- The Gen 1 Game facade proxies THIS loader's live game, and reads it on
|
||||
-- every touch: a mod captures the facade at file scope, before Game2 has a
|
||||
-- save or a world (src/mods/Gen2Compat.lua).
|
||||
Gen2Compat.bind(function() return self:_game() end)
|
||||
-- Dev mode wants the permissions tripwire; a Gold boot with mods on it wants
|
||||
-- the Gen 1-only require report, which is the difference between "the mod
|
||||
-- does nothing" and knowing why. A Gold boot with no mods pays nothing.
|
||||
if self.dev or (self.generation ~= 1 and next(self.mods) ~= nil) then
|
||||
self:_installDevShim()
|
||||
end
|
||||
for _, mod in ipairs(ordered) do
|
||||
-- a mod ahead of this one may have failed and taken its dependents with
|
||||
-- it, so the order list is filtered as it is walked
|
||||
@@ -1005,8 +1318,9 @@ function Loader:load(data)
|
||||
for _, name in ipairs(self:_mergeOrder()) do
|
||||
local registry = self.content[name]
|
||||
local spec = registry.spec
|
||||
if data and spec.target and next(registry.ops) ~= nil then
|
||||
local target = Data.ensure(data, spec.target)
|
||||
local path = self:_target(name, spec)
|
||||
if data and path and next(registry.ops) ~= nil then
|
||||
local target = Data.ensure(data, path)
|
||||
if spec.write then
|
||||
-- ids that do not map one-to-one onto target keys (type_chart's
|
||||
-- ordered rows, battle_anims' per-kind subtables) place themselves
|
||||
@@ -1104,6 +1418,12 @@ function Loader:status()
|
||||
manifest.enabled = mod.enabled ~= false
|
||||
manifest.state = mod.state or (manifest.enabled and "loaded" or "disabled")
|
||||
manifest.error = mod.failure
|
||||
-- set instead of `error` when the mod was left out for a reason that is
|
||||
-- not a fault of the mod (today: the gen2compat gate)
|
||||
manifest.note = mod.skipReason
|
||||
-- the player's override, which the manager offers on a Gen 2 boot for a
|
||||
-- mod whose author never claimed one
|
||||
manifest.gen2Forced = self.gen2Forced[mod.manifest.id] == true
|
||||
available[#available + 1] = manifest
|
||||
if manifest.state == "loaded" then loaded[#loaded + 1] = manifest end
|
||||
end
|
||||
|
||||
+100
-10
@@ -5,7 +5,10 @@
|
||||
-- before they land, edits stage until one apply/restart, and safe mode is
|
||||
-- read from Runtime.safeMode (19 owns the detection).
|
||||
local Font = require("src.render.Font")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local ModTargets = require("src.mods.ModTargets")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Version = require("src.core.Version")
|
||||
local Theme = require("src.ui.Theme")
|
||||
@@ -28,7 +31,10 @@ end
|
||||
|
||||
-- the charmap has no * ~ + < > glyphs, so the status gutter uses what it
|
||||
-- does have: staged-awaiting-restart, disabled, errored, dep-unhealthy
|
||||
local GLYPH = { staged = ".", disabled = "-", errored = "!", blocked = "?" }
|
||||
-- `skipped` is not a fault: the mod is enabled and intact, this game is just
|
||||
-- not the generation it declared (Loader:_gateGeneration)
|
||||
local GLYPH = { staged = ".", disabled = "-", errored = "!", blocked = "?",
|
||||
skipped = "-" }
|
||||
|
||||
local TABS = { "MODS", "PROFILES", "ERRORS" }
|
||||
local TAB_LINE = { "[MODS] PROF ERRS", "MODS [PROF] ERRS", "MODS PROF [ERRS]" }
|
||||
@@ -208,7 +214,9 @@ function ManagerState:refresh()
|
||||
if self.currentMod then
|
||||
self.currentMod = self.byId[self.currentMod.id]
|
||||
end
|
||||
self.restartPending = #self:stagedList() > 0
|
||||
-- gen2Pending is not in stagedList: the Gen 2 override is not an enable flag
|
||||
-- and there is nothing in `available` to diff it against
|
||||
self.restartPending = #self:stagedList() > 0 or self.gen2Pending == true
|
||||
-- a live set that drifted off the named profile reverts to ad-hoc
|
||||
local opts = self:optionsTable()
|
||||
if opts.activeProfile then
|
||||
@@ -255,9 +263,29 @@ function ManagerState:stagedList()
|
||||
return out
|
||||
end
|
||||
|
||||
-- The game this manager judges targets against: the running version, unless a
|
||||
-- harness injected a loader generation that disagrees with it (Loader.new
|
||||
-- opts.generation), where only the generation can be trusted.
|
||||
function ManagerState:targetGame()
|
||||
local loader = self.game and self.game.mods
|
||||
local gen = loader and loader.generation
|
||||
local version = GameVersion.get()
|
||||
if gen and GameVersion.generation(version) ~= gen then return nil, gen end
|
||||
return version, GameVersion.generation(version)
|
||||
end
|
||||
|
||||
-- will this mod run on this game at all (src/mods/ModTargets.lua, the same
|
||||
-- derivation the launcher panel shows)
|
||||
function ManagerState:runsHere(m)
|
||||
local version, gen = self:targetGame()
|
||||
return ModTargets.runsHere(m, version, gen, m.gen2Forced)
|
||||
end
|
||||
|
||||
function ManagerState:glyphFor(m)
|
||||
if self:isStaged(m) then return GLYPH.staged end
|
||||
if not m.enabled then return GLYPH.disabled end
|
||||
if m.state == "wrong_generation" then return GLYPH.skipped end
|
||||
if not self:runsHere(m) then return GLYPH.skipped end
|
||||
if m.state == "blocked_dependency" then return GLYPH.blocked end
|
||||
if m.error then return GLYPH.errored end
|
||||
return " "
|
||||
@@ -345,6 +373,22 @@ function ManagerState:detailRows(m)
|
||||
rows[#rows + 1] = { label = Strings("PERMISSIONS.."),
|
||||
action = function() self:goTo("permissions") end }
|
||||
end
|
||||
-- The manifest's games list is the AUTHOR's claim, and a mod written before
|
||||
-- the field existed can never carry one, so the player gets the override
|
||||
-- here rather than being told to edit a manifest they do not own. Exactly
|
||||
-- the answer the loader gates on (Loader:_gateGeneration reads the same
|
||||
-- ModTargets.supports), so the row appears only where a restart can change
|
||||
-- what this mod does.
|
||||
local loader = self.game.mods
|
||||
local version, gen = self:targetGame()
|
||||
if loader and loader.setGen2Forced and not ModTargets.supports(m, version, gen) then
|
||||
rows[#rows + 1] = {
|
||||
label = m.gen2Forced and Strings("DON'T TRY HERE") or Strings("TRY HERE ANYWAY"),
|
||||
action = function() self:toggleGen2Force(m) end }
|
||||
end
|
||||
-- which games the mod says it is for, in the one place the player is
|
||||
-- already looking when they wonder why it did not run
|
||||
rows[#rows + 1] = { inert = true, label = "FOR " .. ModTargets.chip(m) }
|
||||
if m.github then
|
||||
rows[#rows + 1] = { inert = true, label = "GH " .. m.github }
|
||||
end
|
||||
@@ -660,15 +704,52 @@ function ManagerState:beginToggle(m)
|
||||
proceed()
|
||||
end
|
||||
|
||||
-- The gate runs once, before any entry chunk, so this can only take effect on
|
||||
-- the next boot: it stages a restart the way an enable toggle does. The
|
||||
-- override is scoped to THIS game, and a boot that cannot name one keeps it in
|
||||
-- memory only, which the notice says rather than promising a restart.
|
||||
function ManagerState:toggleGen2Force(m)
|
||||
local loader = self.game.mods
|
||||
if not (loader and loader.setGen2Forced) then return end
|
||||
local want = not m.gen2Forced
|
||||
local function apply()
|
||||
local _, persisted = loader:setGen2Forced(m.id, want)
|
||||
self.gen2Pending = persisted ~= false
|
||||
if loader.status then self.game.modStatus = loader:status() end
|
||||
self:refresh()
|
||||
if persisted == false then
|
||||
-- the gate already ran, so an unsaved override changes no boot at all
|
||||
self:notify("COULD NOT SAVE")
|
||||
else
|
||||
self:notify(want and "WILL TRY ON RESTART" or "WILL BE SKIPPED")
|
||||
end
|
||||
end
|
||||
if not want then
|
||||
apply()
|
||||
return
|
||||
end
|
||||
self:openConfirm({
|
||||
"NOT MADE FOR",
|
||||
"THIS GAME.",
|
||||
"TRY IT ANYWAY?",
|
||||
}, apply)
|
||||
end
|
||||
|
||||
-- Where the loader persisted an enable flag: this game's slot once it keeps
|
||||
-- them per game, the shared flag until then (SaveData.modScope).
|
||||
function ManagerState:enableScope()
|
||||
return SaveData.modScope((self:targetGame()))
|
||||
end
|
||||
|
||||
function ManagerState:commitToggle(apply)
|
||||
local loader = self.game.mods
|
||||
local opts = self:optionsTable()
|
||||
local scope = self:enableScope()
|
||||
for id, en in pairs(apply) do
|
||||
if loader and loader.setEnabled then loader:setEnabled(id, en) end
|
||||
-- mirror into the live options so a later writeOptions cannot revert
|
||||
-- what setEnabled just persisted
|
||||
opts.mods = opts.mods or {}
|
||||
opts.mods[id] = en
|
||||
SaveData.setModEnabled(opts, id, en, scope)
|
||||
end
|
||||
if loader and loader.status then self.game.modStatus = loader:status() end
|
||||
self:refresh()
|
||||
@@ -677,11 +758,11 @@ end
|
||||
function ManagerState:discardChanges()
|
||||
local loader = self.game.mods
|
||||
local opts = self:optionsTable()
|
||||
local scope = self:enableScope()
|
||||
for _, m in ipairs(self:stagedList()) do
|
||||
local en = bootEnabled(m)
|
||||
if loader and loader.setEnabled then loader:setEnabled(m.id, en) end
|
||||
opts.mods = opts.mods or {}
|
||||
opts.mods[m.id] = en
|
||||
SaveData.setModEnabled(opts, m.id, en, scope)
|
||||
end
|
||||
if loader and loader.status then self.game.modStatus = loader:status() end
|
||||
self:refresh()
|
||||
@@ -710,7 +791,9 @@ function ManagerState:matchesProfile(p)
|
||||
local want = p.enabled[m.id] ~= false
|
||||
if (m.enabled and true or false) ~= want then return false end
|
||||
end
|
||||
return true
|
||||
-- the per-game answers count too, or a profile that only differs on Gold
|
||||
-- would read as still active after the player changed it
|
||||
return ModProfile.matchesVersions(p, self:optionsTable())
|
||||
end
|
||||
|
||||
function ManagerState:persistOptions()
|
||||
@@ -749,6 +832,8 @@ function ManagerState:applyProfile(p)
|
||||
for _, move in ipairs(ModProfile.slotMoves(p)) do
|
||||
require("src.core.SaveData").setActiveSlot(move[1], move[2])
|
||||
end
|
||||
-- the per-game half of the setup, restored beside the shared enable set
|
||||
ModProfile.restoreVersions(p, self:optionsTable())
|
||||
self:optionsTable().activeProfile = p.name
|
||||
self:persistOptions()
|
||||
local missing = ModProfile.missingIds(p, self.byId)
|
||||
@@ -768,11 +853,12 @@ function ManagerState:saveCurrentAs()
|
||||
local opts = self:optionsTable()
|
||||
opts.modProfiles = opts.modProfiles or {}
|
||||
local snap = ModProfile.capture(self.status.available,
|
||||
self:modOptionsTable())
|
||||
self:modOptionsTable(), opts.modsByVersion)
|
||||
local existing = self:findProfile(name)
|
||||
if existing then
|
||||
existing.enabled, existing.options, existing.slots =
|
||||
snap.enabled, snap.options, snap.slots
|
||||
existing.enabledByVersion = snap.enabledByVersion
|
||||
else
|
||||
snap.name = name
|
||||
opts.modProfiles[#opts.modProfiles + 1] = snap
|
||||
@@ -1082,7 +1168,10 @@ function ManagerState:drawDetail()
|
||||
local title = wrap(m.name or m.id, 14)
|
||||
drawTruncated(title[1] .. " " .. (m.version or ""), 16, 2 * 8, 17)
|
||||
local statusLine = m.enabled and "ENABLED" or "DISABLED"
|
||||
if m.state == "blocked_dependency" then
|
||||
if m.state == "wrong_generation" or not self:runsHere(m) then
|
||||
-- enabled and fine, just not for this game; the detail body says why
|
||||
statusLine = statusLine .. " (NOT THIS GAME)"
|
||||
elseif m.state == "blocked_dependency" then
|
||||
statusLine = statusLine .. " ?"
|
||||
elseif m.error then
|
||||
statusLine = statusLine .. " !"
|
||||
@@ -1091,7 +1180,8 @@ function ManagerState:drawDetail()
|
||||
drawTruncated(statusLine, 16, 3 * 8, 17)
|
||||
drawTruncated((m.category or "OTHER") .. " / " .. (m.profile or "content"),
|
||||
16, 4 * 8, 17)
|
||||
local lines = wrap(m.error and ("FAILED: " .. m.error) or m.description, 16)
|
||||
local lines = wrap(m.error and ("FAILED: " .. m.error)
|
||||
or (m.note and ("SKIPPED: " .. m.note)) or m.description, 16)
|
||||
local visible = 5
|
||||
for i = 1, visible do
|
||||
local line = lines[self.descScroll + i - 1]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
-- 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")
|
||||
|
||||
@@ -189,6 +190,39 @@ function Manifest.validate(raw, path)
|
||||
"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
|
||||
@@ -224,6 +258,8 @@ function Manifest.validate(raw, path)
|
||||
experimental = experimental,
|
||||
profile = profile,
|
||||
language = language,
|
||||
games = games,
|
||||
gen2compat = gen2compat,
|
||||
affects_link = affectsLink,
|
||||
permissions = permissions,
|
||||
permissionSet = permissionSet,
|
||||
|
||||
+70
-9
@@ -26,17 +26,34 @@ ModProfile.FORMAT = "g1rmodlist"
|
||||
ModProfile.FORMAT_VERSION = 1
|
||||
|
||||
-- deterministic order; GameVersion.VERSIONS is a map, and a profile file has
|
||||
-- to encode the same way twice for a diff to mean anything
|
||||
local VERSION_ORDER = { "red", "blue", "yellow" }
|
||||
-- to encode the same way twice for a diff to mean anything. GameVersion.ORDER
|
||||
-- rather than a literal so the next version added is captured with the rest:
|
||||
-- decode already validates against GameVersion.VERSIONS, so a gold slot in an
|
||||
-- imported profile survived the read and was then dropped by capture.
|
||||
local VERSION_ORDER = GameVersion.ORDER
|
||||
|
||||
local function fsOr(fs)
|
||||
return fs or (love and love.filesystem) or nil
|
||||
end
|
||||
|
||||
-- one version's per-game enable overlay, copied flat (SaveData.modsByVersion)
|
||||
local function copyFlags(bucket)
|
||||
if type(bucket) ~= "table" then return nil end
|
||||
local copy, any = {}, false
|
||||
for id, on in pairs(bucket) do
|
||||
if type(id) == "string" then
|
||||
copy[id] = on and true or false
|
||||
any = true
|
||||
end
|
||||
end
|
||||
return any and copy or nil
|
||||
end
|
||||
|
||||
-- Capture the live setup. `available` is ManagerState.status.available (the
|
||||
-- loader's status manifests, m.enabled = the desired set including staged
|
||||
-- flips); `modOptions` is options.modOptions.
|
||||
function ModProfile.capture(available, modOptions)
|
||||
-- flips); `modOptions` is options.modOptions; `byVersion` is
|
||||
-- options.modsByVersion, the per-game answers that differ from it.
|
||||
function ModProfile.capture(available, modOptions, byVersion)
|
||||
local enabled, options = {}, {}
|
||||
for _, m in ipairs(available or {}) do
|
||||
enabled[m.id] = m.enabled and true or false
|
||||
@@ -50,12 +67,47 @@ function ModProfile.capture(available, modOptions)
|
||||
options[m.id] = copy
|
||||
end
|
||||
end
|
||||
local slots = {}
|
||||
local slots, perVersion = {}, {}
|
||||
for _, id in ipairs(VERSION_ORDER) do
|
||||
local ok, slot = pcall(SaveData.activeSlot, id)
|
||||
if ok and slot then slots[id] = slot end
|
||||
local flags = copyFlags(type(byVersion) == "table" and byVersion[id])
|
||||
if flags then perVersion[id] = flags end
|
||||
end
|
||||
return { enabled = enabled, options = options, slots = slots }
|
||||
return { enabled = enabled, options = options, slots = slots,
|
||||
enabledByVersion = perVersion }
|
||||
end
|
||||
|
||||
-- Write a profile's per-game answers back into an options table, replacing
|
||||
-- only the games it carries: a profile shared by someone who never played
|
||||
-- Gold must not blank the Gold set here.
|
||||
function ModProfile.restoreVersions(p, options)
|
||||
if type(options) ~= "table" then return end
|
||||
local wanted = type(p) == "table" and p.enabledByVersion or nil
|
||||
if type(wanted) ~= "table" then return end
|
||||
options.modsByVersion = options.modsByVersion or {}
|
||||
for _, id in ipairs(VERSION_ORDER) do
|
||||
local flags = copyFlags(wanted[id])
|
||||
if flags then options.modsByVersion[id] = flags end
|
||||
end
|
||||
end
|
||||
|
||||
-- Does the live per-game overlay still read the way this profile captured it?
|
||||
-- Only the games the profile names are compared, matching restoreVersions.
|
||||
function ModProfile.matchesVersions(p, options)
|
||||
local wanted = type(p) == "table" and p.enabledByVersion or nil
|
||||
if type(wanted) ~= "table" then return true end
|
||||
local live = (type(options) == "table" and options.modsByVersion) or {}
|
||||
for _, id in ipairs(VERSION_ORDER) do
|
||||
local want, got = wanted[id], live[id]
|
||||
if type(want) == "table" then
|
||||
for modId, on in pairs(want) do
|
||||
local cur = type(got) == "table" and got[modId] or nil
|
||||
if (cur and true or false) ~= (on and true or false) then return false end
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- The slot moves a profile may actually make: a slot id has to be registered
|
||||
@@ -94,7 +146,8 @@ function ModProfile.encode(p)
|
||||
format = ModProfile.FORMAT,
|
||||
formatVersion = ModProfile.FORMAT_VERSION,
|
||||
profile = { name = p.name, enabled = p.enabled,
|
||||
options = p.options, slots = p.slots },
|
||||
options = p.options, slots = p.slots,
|
||||
enabledByVersion = p.enabledByVersion },
|
||||
})
|
||||
end
|
||||
|
||||
@@ -109,7 +162,8 @@ function ModProfile.decode(body)
|
||||
if type(raw) ~= "table" or type(raw.name) ~= "string" or raw.name == "" then
|
||||
return nil, "BAD FILE"
|
||||
end
|
||||
local p = { name = raw.name:sub(1, 10), enabled = {}, options = {}, slots = {} }
|
||||
local p = { name = raw.name:sub(1, 10), enabled = {}, options = {}, slots = {},
|
||||
enabledByVersion = {} }
|
||||
for id, on in pairs(type(raw.enabled) == "table" and raw.enabled or {}) do
|
||||
if type(id) == "string" then p.enabled[id] = on and true or false end
|
||||
end
|
||||
@@ -130,6 +184,13 @@ function ModProfile.decode(body)
|
||||
p.slots[version] = slot
|
||||
end
|
||||
end
|
||||
local shared = type(raw.enabledByVersion) == "table" and raw.enabledByVersion or {}
|
||||
for version, bucket in pairs(shared) do
|
||||
if GameVersion.VERSIONS[version] then
|
||||
local flags = copyFlags(bucket)
|
||||
if flags then p.enabledByVersion[version] = flags end
|
||||
end
|
||||
end
|
||||
return p
|
||||
end
|
||||
|
||||
@@ -196,7 +257,7 @@ function ModProfile.ensureFirst(opts, available, modOptions)
|
||||
opts.modProfilesSeeded = true
|
||||
opts.modProfiles = opts.modProfiles or {}
|
||||
if #opts.modProfiles > 0 then return nil end
|
||||
local p = ModProfile.capture(available, modOptions)
|
||||
local p = ModProfile.capture(available, modOptions, opts.modsByVersion)
|
||||
p.name = "PROFILE 1"
|
||||
opts.modProfiles[1] = p
|
||||
opts.activeProfile = p.name
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
-- Which games a mod is for. One derivation for the manifest's `games` key,
|
||||
-- the legacy `gen2compat` flag, and the labels both mod surfaces draw:
|
||||
-- src/mods/LauncherMods.lua and src/mods/ManagerState.lua read this rather
|
||||
-- than each keeping its own copy of the rule.
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local ModTargets = {}
|
||||
|
||||
-- every version of one generation, in launcher order (GameVersion.ORDER)
|
||||
function ModTargets.generationVersions(gen)
|
||||
local out = {}
|
||||
for _, id in ipairs(GameVersion.ORDER) do
|
||||
if GameVersion.generation(id) == gen then out[#out + 1] = id end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- the generations this engine has games for, ascending
|
||||
local function generations()
|
||||
local seen, out = {}, {}
|
||||
for _, id in ipairs(GameVersion.ORDER) do
|
||||
local gen = GameVersion.generation(id)
|
||||
if not seen[gen] then
|
||||
seen[gen] = true
|
||||
out[#out + 1] = gen
|
||||
end
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end
|
||||
|
||||
-- one manifest token -> the version ids it covers, or nil when it names no
|
||||
-- game this engine knows: "red" | "gen1" | "all"
|
||||
function ModTargets.expand(token)
|
||||
if type(token) ~= "string" then return nil end
|
||||
local key = token:lower():match("^%s*(.-)%s*$")
|
||||
if key == "all" then return GameVersion.ORDER end
|
||||
if GameVersion.VERSIONS[key] then return { key } end
|
||||
local gen = key:match("^gen%s*(%d+)$")
|
||||
if gen then
|
||||
local list = ModTargets.generationVersions(tonumber(gen))
|
||||
if #list > 0 then return list end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- version-id lists are always ORDER-sorted and deduped, so two manifests that
|
||||
-- say the same thing different ways encode and compare the same
|
||||
local function fromSet(set)
|
||||
local out = {}
|
||||
for _, id in ipairs(GameVersion.ORDER) do
|
||||
if set[id] then out[#out + 1] = id end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- normalize a manifest `games` array; second return is the tokens that named
|
||||
-- no game, which Manifest reports at its own api level
|
||||
function ModTargets.normalize(list)
|
||||
local set, unknown = {}, {}
|
||||
for _, token in ipairs(type(list) == "table" and list or {}) do
|
||||
local ids = ModTargets.expand(token)
|
||||
if ids then
|
||||
for _, id in ipairs(ids) do set[id] = true end
|
||||
else
|
||||
unknown[#unknown + 1] = tostring(token)
|
||||
end
|
||||
end
|
||||
return fromSet(set), unknown
|
||||
end
|
||||
|
||||
function ModTargets.union(a, b)
|
||||
local set = {}
|
||||
for _, list in ipairs({ a or {}, b or {} }) do
|
||||
for _, id in ipairs(list) do set[id] = true end
|
||||
end
|
||||
return fromSet(set)
|
||||
end
|
||||
|
||||
-- The pre-`games` reading of a manifest: Gen 1 always, Gen 2 only where the
|
||||
-- author claimed gen2compat (src/mods/Manifest.lua).
|
||||
function ModTargets.legacy(gen2compat)
|
||||
local out = ModTargets.generationVersions(1)
|
||||
if gen2compat then
|
||||
return ModTargets.union(out, ModTargets.generationVersions(2))
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- does a version-id list hold any game of that generation
|
||||
function ModTargets.covers(versions, gen)
|
||||
for _, id in ipairs(versions or {}) do
|
||||
if GameVersion.generation(id) == gen then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- the version ids a validated manifest targets; Manifest.validate resolves
|
||||
-- `games` at load, so this is the same answer everywhere
|
||||
function ModTargets.versions(manifest)
|
||||
local games = manifest and manifest.games
|
||||
if type(games) == "table" and #games > 0 then return games end
|
||||
return ModTargets.legacy(manifest and manifest.gen2compat)
|
||||
end
|
||||
|
||||
-- Does the mod target this game? `version` is a version id; pass nil with a
|
||||
-- generation to ask about a whole generation (the loader's injected seam).
|
||||
function ModTargets.supports(manifest, version, generation)
|
||||
local versions = ModTargets.versions(manifest)
|
||||
if version and GameVersion.VERSIONS[version] then
|
||||
for _, id in ipairs(versions) do
|
||||
if id == version then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
return ModTargets.covers(versions, generation or GameVersion.generation())
|
||||
end
|
||||
|
||||
-- What will actually happen here: the loader gates on this same answer, per
|
||||
-- version (Loader:_gateGeneration), and the player's override forces past it,
|
||||
-- so the two UIs report a run, not a claim.
|
||||
function ModTargets.runsHere(manifest, version, generation, forced)
|
||||
if forced then return true end
|
||||
return ModTargets.supports(manifest, version, generation)
|
||||
end
|
||||
|
||||
-- "Gen 1" / "Gen 1+2" while a mod takes whole generations, the version names
|
||||
-- ("Red/Gold") once it takes only some of one
|
||||
function ModTargets.label(manifest)
|
||||
local versions = ModTargets.versions(manifest)
|
||||
local set = {}
|
||||
for _, id in ipairs(versions) do set[id] = true end
|
||||
local whole, partial = {}, false
|
||||
for _, gen in ipairs(generations()) do
|
||||
local all, any = true, false
|
||||
for _, id in ipairs(ModTargets.generationVersions(gen)) do
|
||||
if set[id] then any = true else all = false end
|
||||
end
|
||||
if any and all then whole[#whole + 1] = tostring(gen)
|
||||
elseif any then partial = true end
|
||||
end
|
||||
if #whole > 0 and not partial then
|
||||
return "Gen " .. table.concat(whole, "+")
|
||||
end
|
||||
local names = {}
|
||||
for _, id in ipairs(versions) do
|
||||
names[#names + 1] = GameVersion.info(id).label or id
|
||||
end
|
||||
if #names == 0 then return "No game" end
|
||||
return table.concat(names, "/")
|
||||
end
|
||||
|
||||
-- the same label as an all-caps chip, for the launcher tag and the GB font
|
||||
function ModTargets.chip(manifest)
|
||||
return ModTargets.label(manifest):upper()
|
||||
end
|
||||
|
||||
-- one game's own name, for a line that has to say which one this is
|
||||
function ModTargets.gameLabel(version)
|
||||
local info = version and GameVersion.info(version)
|
||||
return (info and info.label) or tostring(version)
|
||||
end
|
||||
|
||||
-- One launcher-voice line for a mod that does not target `version`
|
||||
-- (src/mods/LauncherMods.lua statusFor).
|
||||
function ModTargets.detail(manifest, version)
|
||||
return ("For %s, not %s"):format(ModTargets.label(manifest),
|
||||
ModTargets.gameLabel(version))
|
||||
end
|
||||
|
||||
return ModTargets
|
||||
+864
-8
@@ -214,7 +214,15 @@ end
|
||||
-- records are a feature -- but a patch key that is only a case/underscore
|
||||
-- variant of a schema field is the classic typo and gets rejected with a
|
||||
-- suggestion.
|
||||
function Schemas.check(spec, registryName, id, value, mode)
|
||||
--
|
||||
-- `generation` is optional and only ever narrows: passing it resolves the
|
||||
-- per-generation shape first (Schemas.shapeFor), and omitting it validates
|
||||
-- against the Gen 1 shape, which is what every Gen 1 call site wants and what
|
||||
-- a caller already holding a derived spec has anyway.
|
||||
function Schemas.check(spec, registryName, id, value, mode, generation)
|
||||
if generation ~= nil then
|
||||
spec = Schemas.shapeFor(registryName, spec, generation)
|
||||
end
|
||||
if mode == "remove" or spec == nil then return true end
|
||||
-- register and patch are synonyms on a deep registry, so a partial
|
||||
-- payload is the normal case there and only override is a full value
|
||||
@@ -323,10 +331,10 @@ local function refsFor(spec, name, id, value)
|
||||
return refs
|
||||
end
|
||||
|
||||
-- a structured target (battle_anims' per-kind subtables) hides its ids one
|
||||
-- level down, so the pristine scan asks the spec instead of the raw keys
|
||||
local function baseEntries(registry, base)
|
||||
local spec = registry.spec
|
||||
-- a structured target (battle_anims' per-kind subtables, Gold's trainer
|
||||
-- classes) hides its ids one level down, so the pristine scan asks the spec
|
||||
-- instead of the raw keys
|
||||
local function baseEntries(spec, base)
|
||||
if not spec.baseIds then return pairs(base) end
|
||||
local ids = spec.baseIds(base)
|
||||
local i = 0
|
||||
@@ -357,13 +365,26 @@ function Schemas.crossValidate(loader, data)
|
||||
end
|
||||
end
|
||||
for name, registry in pairs(loader.content) do
|
||||
local spec = registry.spec
|
||||
-- the shape this boot's generation validates by, so a Gen 2 record's
|
||||
-- refs are read out of the Gen 2 fields (a species' `into`, not
|
||||
-- `species`) instead of being missed entirely
|
||||
local spec = Schemas.shapeFor(name, registry.spec, loader.generation)
|
||||
for id in pairs(registry.ops) do
|
||||
local value = registry:get(id)
|
||||
if value ~= nil and registry.owners[id] ~= Schemas.ENGINE then
|
||||
for _, ref in ipairs(refsFor(spec, name, id, value)) do
|
||||
local refRegistry = Schemas.REGISTRIES[ref.registry]
|
||||
and loader.content[ref.registry]
|
||||
-- A registry with no home in this generation has no id space to
|
||||
-- check against: its base view resolves to nothing, so EVERY
|
||||
-- reference into it would read as dangling. Gold's species carry a
|
||||
-- growthRate and an evolution method like Red's do; the ids are
|
||||
-- fine, it is the Gen 1 `growth_rates` / `evolution_methods`
|
||||
-- namespaces that are not there to confirm them. Skipped for the
|
||||
-- same reason an undeclared registry is: unknown, not wrong.
|
||||
if refRegistry and Schemas.gatedFor(ref.registry, loader.generation) then
|
||||
refRegistry = nil
|
||||
end
|
||||
if refRegistry and refRegistry:get(ref.ref) == nil then
|
||||
problems[#problems + 1] = {
|
||||
owner = registry.owners[id],
|
||||
@@ -377,7 +398,7 @@ function Schemas.crossValidate(loader, data)
|
||||
if removed then
|
||||
local base = registry.base and registry.base()
|
||||
if base then
|
||||
for id, value in baseEntries(registry, base) do
|
||||
for id, value in baseEntries(spec, base) do
|
||||
if registry.ops[id] == nil then
|
||||
for _, ref in ipairs(refsFor(spec, name, id, value)) do
|
||||
local set = tombstoned[ref.registry]
|
||||
@@ -408,9 +429,307 @@ Schemas.ALIASES = { scripts = "map_scripts", ui = "screens" }
|
||||
-- pass skips it and stays zero-work on a mod-free boot
|
||||
Schemas.ENGINE = "engine"
|
||||
|
||||
-- ------- generation routing
|
||||
--
|
||||
-- Registry NAMES are shared across generations on purpose: a mod writes
|
||||
-- mod.content.pokemon whichever game is running, and mod.content.encounters
|
||||
-- means "wild encounters" in both. What can differ is the Data path the
|
||||
-- merge lands on, because Gold namespaces the tables whose Gen 1 counterpart
|
||||
-- means something else (data.gen2Palettes beside data.palettes).
|
||||
--
|
||||
-- One routing table per generation, read through Schemas.routing, and both
|
||||
-- are read the same way:
|
||||
--
|
||||
-- absent -> keeps spec.target in that generation
|
||||
-- mapped to a path -> merges there instead
|
||||
-- mapped to false -> no home in that generation; the write is taken,
|
||||
-- dropped and reported
|
||||
--
|
||||
-- That last case is the whole point of the manifest's gen2compat opt-in: a
|
||||
-- mod that claims Gen 2 gets told which registry has no home there instead of
|
||||
-- merging into a table nothing reads and appearing to work. It runs in both
|
||||
-- directions, because the catalog now holds content BOTH ways round: Gold has
|
||||
-- systems Red never had (the phone book, the decorations, the radio dial), and
|
||||
-- those registries are the mirror image of the rows below -- declared once,
|
||||
-- gated under GEN ONE, reported to a Red mod in the same sentence a Gold mod
|
||||
-- gets about `tokens`. Schemas.GEN1 below Schemas.GEN2 carries them.
|
||||
--
|
||||
-- The `false` rows used to have three causes and now have one. The first is
|
||||
-- gone: Gold's overworld tables no longer load off disk into World fields --
|
||||
-- src/core/Game2.lua:load reads every one of them into game.data BEFORE it
|
||||
-- calls mods:load(self.data), and src/world/gen2/World.lua:dataTable takes
|
||||
-- them by reference and never copies, so a routed row merges into the very
|
||||
-- table the world walks. The second is gone too: a registry whose Gen 2
|
||||
-- records are shaped differently now says so in its own spec (the gen2Fields /
|
||||
-- gen2Keys layer below, resolved by Schemas.shapeFor), so routing it validates
|
||||
-- a mod's record against the GEN 2 shape rather than against Red's. What is
|
||||
-- left is the systems Gold has not reimplemented through a registry at all.
|
||||
Schemas.GEN2 = {
|
||||
-- Namespaced on Gold and merged there. The registry NAME stays shared --
|
||||
-- mod.content.maps means "maps" in both games -- and only the Data path
|
||||
-- underneath it differs, which is the whole reason this table maps to paths
|
||||
-- rather than renaming anything. Two id-space notes an author needs, and
|
||||
-- docs/mod-api-gen2-compat.md spells out: Gold's `text` ids are ROM pointer
|
||||
-- strings ("55:4067") rather than TEXT_* names, and a Gen 2 tileset carries
|
||||
-- its walkability as `collision` where Gen 1 says `walkable`.
|
||||
maps = "gen2Maps", tilesets = "gen2Tilesets", sprites = "gen2Sprites",
|
||||
text = "gen2Text",
|
||||
-- Namespaced AND differently shaped, and the shape is what these waited on.
|
||||
-- Each carries a Gen 2 record schema in its catalog entry now, so the id
|
||||
-- space is the one Gold actually keys by: the encounter KIND (.grass), the
|
||||
-- trainer CLASS (one level into .classes, through gen2Write), a species id
|
||||
-- or an ICON_ sheet name, and for palettes / battle_anims / constants the
|
||||
-- target's own subtable names.
|
||||
encounters = "gen2Encounters", trainers = "gen2Trainers",
|
||||
palettes = "gen2Palettes", icons = "gen2Icons",
|
||||
battle_anims = "gen2BattleAnims", constants = "gen2Constants",
|
||||
-- Gold reimplements the system, and reads its rules back through the same
|
||||
-- registry: src/battle/gen2/Battle.lua:statusRecordFor / moveEffectRecordFor,
|
||||
-- Catching.recordFor, Ai.layersFor, Evolution.methodFor and
|
||||
-- src/core/gen2/ItemEffects.lua:recordFor each read the merged table here
|
||||
-- and fall back to their own module records when no loader ran. The vanilla
|
||||
-- records at these paths are GOLD's, not Red's -- src/mods/Builtins.lua
|
||||
-- swaps the registrant per generation, which it has to: both games call it
|
||||
-- GREAT_BALL.
|
||||
statuses = "gen2Statuses", move_effects = "gen2MoveEffects",
|
||||
item_effects = "gen2ItemEffects", balls = "gen2Balls",
|
||||
ai_classes = "gen2AiClasses", evolution_methods = "gen2EvolutionMethods",
|
||||
-- The Gen 2-only six. They have no Gen 1 target to keep (their specs carry
|
||||
-- none), so the routed path IS the only path they ever have, and the
|
||||
-- Schemas.GEN1 rows below are what makes writing to one on Red a reported
|
||||
-- drop rather than a merge into a table Red has never heard of. Two of them
|
||||
-- merge onto a table that already exists when mods:load runs -- landmarks
|
||||
-- onto the cache's own gen2Landmarks.landmarks, held_items onto the view
|
||||
-- src/core/Game2.lua builds from data.items -- and the other four come
|
||||
-- into existence AS the merge, seeded from their module's literals by
|
||||
-- src/mods/Builtins.lua the way the battle-rule six are.
|
||||
held_items = "gen2HeldItems", phone_contacts = "gen2PhoneContacts",
|
||||
decorations = "gen2Decorations", apricorns = "gen2Apricorns",
|
||||
landmarks = "gen2Landmarks.landmarks", radio_channels = "gen2RadioChannels",
|
||||
-- Still no Gen 2 home. Every one of these is a system Gold reimplements
|
||||
-- WITHOUT reading a registry: the Gen 1 target is still built and merged
|
||||
-- into, but nothing in a Gold boot ever looks at it. Closing one is a
|
||||
-- consumer change in the Gen 2 module first and a row here second, which is
|
||||
-- exactly how battle_sprite_scales and render_pipelines came off this list
|
||||
-- (see the note under it).
|
||||
-- rulesets no Gen 2 ruleset dispatch exists
|
||||
-- transitions Gold draws its own battle intro
|
||||
-- (src/ui/gen2/BattleTransition.lua) and its STYLES table
|
||||
-- is a boolean SET of the four cart wipes, not the
|
||||
-- { frames, draw, sound, flash } record this registry
|
||||
-- carries; there is no styleDef lookup for a mod id to
|
||||
-- reach, so a registered style would fall back to vanilla
|
||||
-- field the overworld grab bag; Gold's equivalents live in
|
||||
-- gen2Maps and the VM's own tables
|
||||
-- text_pointers Gen 1's TEXT_* indirection; Gold's text IS pointers
|
||||
-- link_fields link play is Gen 1 only
|
||||
rulesets = false, transitions = false,
|
||||
field = false, text_pointers = false, link_fields = false,
|
||||
-- battle_sprite_scales and render_pipelines are ABSENT from this table on
|
||||
-- purpose: both keep the shared Gen 1 target because Gold reads the merged
|
||||
-- table at that exact path.
|
||||
-- battle_sprite_scales src/ui/gen2/BattleState.lua:imageScale reads
|
||||
-- data.battle_sprite_scales with the same
|
||||
-- image-then-species-then-default order as Gen 1's
|
||||
-- BattleState.imageBattleScale / resolveBattleScale,
|
||||
-- skipping the `_owners` bookkeeping row the same
|
||||
-- way. Only the DEFAULT differs and neither side
|
||||
-- reads it from here: Red's 32x32 back pics draw at
|
||||
-- 2x, Gold's 48x48 ones fill their 6x6 box at 1x.
|
||||
-- render_pipelines src/core/Game2.lua:load calls Pipelines.install
|
||||
-- AFTER the merge, so data.render_pipelines is the
|
||||
-- merged table, and Game2:draw composites `present`
|
||||
-- through Pipelines.wantsPresent / Pipelines.present.
|
||||
-- The `drawWorld` half is not composited yet (Gold's
|
||||
-- overworld draws straight to the window rather than
|
||||
-- into a canvas), and Game2 RETIRES a restored
|
||||
-- drawWorld-only level rather than leaving it
|
||||
-- switched on and rendering nothing; a mod that
|
||||
-- registers only drawWorld is therefore inert on
|
||||
-- Gold, which docs/mod-api-gen2-compat.md says in
|
||||
-- those words. Gold also has no OPTION row for a
|
||||
-- pipeline (Pipelines.rows is read only from
|
||||
-- src/ui/OptionsMenu.lua), so a Gold player reaches
|
||||
-- one by its hotkey.
|
||||
-- src/script/gen2/Vm.lua is a bytecode VM over the cart's own opcodes, not
|
||||
-- the Gen 1 row-list runner. `commands` IS routed (it is absent from this
|
||||
-- table, so it keeps the shared data.commands target): the VM dispatches the
|
||||
-- Opcodes.MOD_COMMAND row -- an op name with no cart byte behind it --
|
||||
-- through that merged table, so mod.commands:register works on both games.
|
||||
-- data.gen2Scripts is that bytecode pool keyed by ROM pointer, so
|
||||
-- `map_scripts` has no home there: a Lua row list merged into it is not
|
||||
-- something the VM can run. (`tokens` used to sit on this line and does
|
||||
-- not belong there -- TextBox.new runs TextBox.substitute on EVERY box in
|
||||
-- both generations and substitute reads game.data.tokens, so the shared
|
||||
-- target was already live on Gold. It keeps that target, absent from this
|
||||
-- table, and src/core/Game2.lua seeds data.tokens with a copy of
|
||||
-- TextBox.TOKENS so the merge cannot mutate the module table.)
|
||||
map_scripts = false,
|
||||
-- Everything not listed keeps its Gen 1 target and works on Gold today:
|
||||
-- pokemon, moves, items, type_chart, audio + music/sfx/cries/map_songs,
|
||||
-- screens (the Gen2* ids in src/ui/Screens.lua), strings, font and commands.
|
||||
}
|
||||
|
||||
-- The mirror of Schemas.GEN2: what a GEN 1 boot does with the registries that
|
||||
-- only exist because Gold exists. Same three readings as the table above, and
|
||||
-- only the third is used today -- there is no Gen 2-only registry with a
|
||||
-- useful Red target to reroute to, because the systems themselves are absent
|
||||
-- from Red rather than spelled differently there.
|
||||
--
|
||||
-- held_items Red's items carry no held attributes at all; the whole
|
||||
-- hold/trigger machinery is Gen 2 (src/battle/gen2/Battle.lua
|
||||
-- heldEffect)
|
||||
-- phone_contacts no Pokegear, no phone
|
||||
-- decorations no bedroom PC decoration menu
|
||||
-- apricorns no Kurt, no apricorn balls
|
||||
-- landmarks Red's town map is a Gen 1 town-map table, not the
|
||||
-- LANDMARK_* index space the Pokegear and the #DEX AREA
|
||||
-- page share
|
||||
-- radio_channels no radio
|
||||
Schemas.GEN1 = {
|
||||
held_items = false, phone_contacts = false, decorations = false,
|
||||
apricorns = false, landmarks = false, radio_channels = false,
|
||||
}
|
||||
|
||||
-- The routing table for a generation: which one is consulted is the only
|
||||
-- difference between the two directions. An unknown generation routes
|
||||
-- nothing, so every registry keeps its catalog target.
|
||||
local NO_ROUTING = {}
|
||||
|
||||
function Schemas.routing(generation)
|
||||
if generation == 2 then return Schemas.GEN2 end
|
||||
if generation == 1 then return Schemas.GEN1 end
|
||||
return NO_ROUTING
|
||||
end
|
||||
|
||||
-- The Data path `name` merges into for a generation, or nil when the registry
|
||||
-- has no home there.
|
||||
function Schemas.targetFor(name, spec, generation)
|
||||
local routed = Schemas.routing(generation)[name]
|
||||
if routed == nil then return spec.target end
|
||||
return routed or nil
|
||||
end
|
||||
|
||||
-- true when the registry exists but this generation has nowhere to put it,
|
||||
-- which is a different diagnostic from a registry that has no target at all
|
||||
function Schemas.gatedFor(name, generation)
|
||||
return Schemas.routing(generation)[name] == false
|
||||
end
|
||||
|
||||
-- ------- per-generation record shapes
|
||||
--
|
||||
-- Routing says WHERE a registration lands; this says what a record at that
|
||||
-- path LOOKS like. The two are separate questions and only the second one is
|
||||
-- gating the rest of the catalog: Gold's tables are the Gen 2 ROM's own
|
||||
-- layout, so a species carries specialAttack/specialDefense where Red carries
|
||||
-- one `special`, wild encounters key by encounter kind and time of day rather
|
||||
-- than by map, and the palette table is GBC four-colour rows in a dozen named
|
||||
-- subtables. Validating any of those against the Gen 1 schema judges a mod's
|
||||
-- record against the wrong shape, which is worse than refusing the write.
|
||||
--
|
||||
-- So beside `value` / `fields` / `keys` / `keyValue` a spec may carry
|
||||
-- `gen2Value` / `gen2Fields` / `gen2Keys` / `gen2KeyValue`, and beside
|
||||
-- `semantics` / `extra` / `write` / `baseAt` / `baseIds` / `example` /
|
||||
-- `notes` the matching `gen2*`. Absent means "the Gen 1 shape is right here
|
||||
-- too", which is the common case and why most registries carry none of this.
|
||||
-- The registry NAME, the verbs and (wherever the id space allows it) the ids
|
||||
-- stay shared, exactly as the routing table keeps them shared.
|
||||
--
|
||||
-- `gen2X = false` CLEARS the Gen 1 slot rather than setting it, the same
|
||||
-- reading `false` has in the routing table above: battle_anims' Gen 1 `write`
|
||||
-- routes ids into per-kind subtables by prefix, and under Gen 2 the ids ARE
|
||||
-- the subtables, so the right Gen 2 write is the default one. No slot here
|
||||
-- ever carries a meaningful `false` (they are functions, strings and tables),
|
||||
-- so the two readings cannot collide.
|
||||
--
|
||||
-- Schemas.shapeFor resolves it. It hands back the spec unchanged for Gen 1
|
||||
-- and for any registry with no Gen 2 shape; otherwise a derived spec with the
|
||||
-- gen2* keys folded onto the canonical names, memoized per spec so the
|
||||
-- resolve is one table lookup after the first call. Everything downstream --
|
||||
-- Schemas.check, Registry's fold and baseAt, the loader's merge and write --
|
||||
-- then reads one spec and never learns about generations.
|
||||
local GEN2_SHAPE = {
|
||||
gen2Value = "value", gen2Fields = "fields", gen2Keys = "keys",
|
||||
gen2KeyValue = "keyValue", gen2Extra = "extra",
|
||||
gen2Semantics = "semantics", gen2Write = "write",
|
||||
gen2BaseAt = "baseAt", gen2BaseIds = "baseIds",
|
||||
gen2Example = "example", gen2Notes = "notes",
|
||||
}
|
||||
|
||||
-- Schemas.check reads these four in a fixed order (keys/keyValue, then value,
|
||||
-- then fields), so a Gen 2 shape that describes its records with `keys` must
|
||||
-- clear the Gen 1 `value` rather than sit beside it: otherwise the first
|
||||
-- branch that matches wins and the new schema is never consulted.
|
||||
local VALUE_SLOTS = { value = true, fields = true, keys = true, keyValue = true }
|
||||
|
||||
-- Weak keys: a derived spec lives exactly as long as the catalog entry it
|
||||
-- came from, which in a headless harness is per require rather than forever.
|
||||
-- Keyed by spec alone, which is sound because the catalog gives every
|
||||
-- registry its own table -- the two ALIASES resolve to the canonical name
|
||||
-- before anything reaches here, and `target` is the only name-dependent
|
||||
-- field a derived spec carries.
|
||||
local derivedSpecs = setmetatable({}, { __mode = "k" })
|
||||
|
||||
-- does this registry describe its Gen 2 records differently at all?
|
||||
function Schemas.hasGen2Shape(spec)
|
||||
if type(spec) ~= "table" then return false end
|
||||
for source in pairs(GEN2_SHAPE) do
|
||||
if spec[source] ~= nil then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- The spec to validate and merge `name` with under `generation`. Idempotent:
|
||||
-- a derived spec carries no gen2* keys, so resolving one again returns it.
|
||||
function Schemas.shapeFor(name, spec, generation)
|
||||
if generation ~= 2 or not Schemas.hasGen2Shape(spec) then return spec end
|
||||
local hit = derivedSpecs[spec]
|
||||
if hit then return hit end
|
||||
local out = {}
|
||||
for key, value in pairs(spec) do out[key] = value end
|
||||
local replacesValue = false
|
||||
for source, slot in pairs(GEN2_SHAPE) do
|
||||
if spec[source] ~= nil and VALUE_SLOTS[slot] then replacesValue = true end
|
||||
end
|
||||
if replacesValue then
|
||||
for slot in pairs(VALUE_SLOTS) do out[slot] = nil end
|
||||
end
|
||||
for source, slot in pairs(GEN2_SHAPE) do
|
||||
out[source] = nil
|
||||
-- `or nil` is the clear: gen2Write = false leaves the slot empty
|
||||
if spec[source] ~= nil then out[slot] = spec[source] or nil end
|
||||
end
|
||||
-- self-describing: a derived spec's `target` is the routed one, so a caller
|
||||
-- holding it alone never reads the Gen 1 path by accident. targetFor stays
|
||||
-- authoritative and stays idempotent over the result.
|
||||
out.target = Schemas.targetFor(name, spec, generation)
|
||||
derivedSpecs[spec] = out
|
||||
return out
|
||||
end
|
||||
|
||||
local R = {}
|
||||
Schemas.REGISTRIES = R
|
||||
|
||||
-- ------- shared Gen 2 leaves
|
||||
--
|
||||
-- The ROM name spaces Gold's tables key by. They are enums rather than
|
||||
-- f.str so a typo ("MORNING") fails at register time instead of writing a
|
||||
-- subtable nothing ever reads; the ordered lists themselves ship as
|
||||
-- data.gen2Constants (eggGroupOrder, trainerTypeOrder, ...).
|
||||
|
||||
-- wild encounters, overworld palettes and the roof pair all bucket by time of
|
||||
-- day; DARK is the fourth palette bucket and never an encounter one, so the
|
||||
-- encounter maps take the three-value list (constants/time_of_day.asm)
|
||||
local gen2Tod = f.enum{ "MORN", "DAY", "NITE" }
|
||||
local gen2PaletteTod = f.enum{ "MORN", "DAY", "NITE", "DARK" }
|
||||
|
||||
-- one GBC colour as the extractor writes it: a positional {r,g,b} triple
|
||||
-- already expanded from 5-bit BGR to 0..255 (src/render/GbcPalette.lua)
|
||||
local gen2Color = f.list(f.int(0, 255))
|
||||
-- a palette row. The OBJ rows the sprite and mon pics use carry two colours
|
||||
-- (the cart supplies white and black), the BG rows carry all four.
|
||||
local gen2PaletteRow = f.list(gen2Color)
|
||||
|
||||
R.pokemon = {
|
||||
semantics = "record", target = "pokemon",
|
||||
fields = {
|
||||
@@ -445,7 +764,61 @@ R.pokemon = {
|
||||
battleScaleFront = f.opt(f.numRange(0.25, 4.0)),
|
||||
battleScaleBack = f.opt(f.numRange(0.25, 4.0)),
|
||||
},
|
||||
-- Same registry, same target (data.pokemon), same species ids: only the
|
||||
-- record differs, and it differs in four places, every one of them a real
|
||||
-- Gen 2 change rather than an extractor spelling. Gen 2 splits `special`
|
||||
-- into specialAttack/specialDefense (BaseData in pokegold's
|
||||
-- data/pokemon/base_stats/), names the level-up table `levelMoves` and the
|
||||
-- pic size `picSize`, has no separate level-1 move list (level 1 rows live
|
||||
-- in levelMoves), and points an evolution at `into` rather than `species`.
|
||||
-- Beside that it carries the breeding block Gen 1 has no analogue for
|
||||
-- (eggGroups/eggMoves/eggSteps, genderRatio) and a held-item pair.
|
||||
--
|
||||
-- Without this, mod.content.pokemon:register is unusable for a Gold
|
||||
-- species -- every record fails on the missing `special` -- while patch
|
||||
-- happens to work, which is the worst of both.
|
||||
gen2Fields = {
|
||||
id = f.str, name = f.str, dex = f.int(1),
|
||||
index = f.opt(f.int(0, 255)),
|
||||
types = f.list(f.id("type_chart")),
|
||||
baseStats = f.rec{ hp = f.int(1, 255), attack = f.int(1, 255),
|
||||
defense = f.int(1, 255), speed = f.int(1, 255),
|
||||
specialAttack = f.int(1, 255),
|
||||
specialDefense = f.int(1, 255) },
|
||||
catchRate = f.int(0, 255), baseExp = f.int(0, 255),
|
||||
growthRate = f.id("growth_rates"), growthRateId = f.opt(f.int(0, 255)),
|
||||
levelMoves = f.list(f.rec{ level = f.int(1), move = f.id("moves") }),
|
||||
tmhm = f.opt(f.list(f.id("moves"))),
|
||||
-- the raw TM/HM bitfield bytes, kept beside the resolved list so a
|
||||
-- re-export round-trips; the engine reads `tmhm`
|
||||
tmhmRaw = f.opt(f.list(f.int(0, 255))),
|
||||
evolutions = f.list(f.rec{ method = f.id("evolution_methods"),
|
||||
into = f.id("pokemon"),
|
||||
level = f.opt(f.int(1)),
|
||||
item = f.opt(f.id("items")),
|
||||
-- EVOLVE_HAPPINESS' window and EVOLVE_STAT's
|
||||
-- attack-vs-defence test
|
||||
time = f.opt(f.enum{ "ANYTIME", "MORNDAY",
|
||||
"NITE" }),
|
||||
comparison = f.opt(f.enum{ "ATK_LT_DEF",
|
||||
"ATK_GT_DEF",
|
||||
"ATK_EQ_DEF" }) }),
|
||||
-- breeding: two egg groups (the raw byte packs both nibbles), the egg
|
||||
-- move list, and the cycle count src/core/gen2/Breeding.lua counts down
|
||||
eggGroups = f.opt(f.list(f.str)), eggGroupsRaw = f.opt(f.int(0, 255)),
|
||||
eggMoves = f.opt(f.list(f.id("moves"))), eggSteps = f.opt(f.int(0)),
|
||||
genderRatio = f.opt(f.int(0, 255)),
|
||||
-- the two wild held items, in the ROM's own order (rare then common)
|
||||
items = f.opt(f.list(f.id("items"))),
|
||||
spriteFront = f.path, spriteBack = f.path, picSize = f.int(1, 7),
|
||||
source = f.opt(f.str),
|
||||
cry = f.opt(f.id("cries")), trueColor = f.opt(f.bool),
|
||||
battleScaleFront = f.opt(f.numRange(0.25, 4.0)),
|
||||
battleScaleBack = f.opt(f.numRange(0.25, 4.0)),
|
||||
},
|
||||
example = 'mod.content.pokemon:patch("MEW", { baseStats = { attack = 120 } })',
|
||||
gen2Example = 'mod.content.pokemon:patch("TOTODILE", '
|
||||
.. '{ baseStats = { specialAttack = 80 } })',
|
||||
}
|
||||
|
||||
R.moves = {
|
||||
@@ -504,8 +877,16 @@ R.maps = {
|
||||
-- carries no palettes at all, so an id reference would fail validation for
|
||||
-- a perfectly good mod wherever there is no imported dataset.
|
||||
palette = f.opt(f.str),
|
||||
-- destGroup / destMapNum are the ROM map-group pair Gen 2 carries beside
|
||||
-- the destination it actually warps through (World:resolveWarp reads
|
||||
-- destMap and destWarp, the same two keys Gen 1 does). Optional and
|
||||
-- additive rather than a second warp shape: `maps` routes to
|
||||
-- data.gen2Maps under Gen 2, so the records a mod patches there are the
|
||||
-- extractor's own, and a strict rec would reject every one of them.
|
||||
warps = f.opt(f.list(f.rec{ x = f.int(0), y = f.int(0),
|
||||
destMap = f.str, destWarp = f.int(0) })),
|
||||
destMap = f.str, destWarp = f.int(0),
|
||||
destGroup = f.opt(f.int(0)),
|
||||
destMapNum = f.opt(f.int(0)) })),
|
||||
objects = f.opt(f.list(f.any)),
|
||||
signs = f.opt(f.list(f.any)),
|
||||
connections = f.opt(f.map(f.enum{ "north", "south", "east", "west" }, f.any)),
|
||||
@@ -545,6 +926,36 @@ R.tilesets = {
|
||||
example = 'mod.content.tilesets:register("MY_TILES", { image = "...", blocks = { ... } })',
|
||||
}
|
||||
|
||||
-- ------- Gen 2 wild encounters
|
||||
--
|
||||
-- One wild slot. A fishing slot's species may be the literal 0 the ROM uses
|
||||
-- for "no fish here, roll the map's water table instead" (pokegold
|
||||
-- data/wild/fish.asm), which is why species is a union rather than a bare id.
|
||||
local gen2Slot = f.rec{ level = f.int(1), species = f.id("pokemon") }
|
||||
-- the sentinel row carries level 0 as well as species 0, so both floors drop
|
||||
local gen2FishSlot = f.rec{ chance = f.int(0, 255), level = f.int(0),
|
||||
species = f.union{ f.id("pokemon"), f.int(0, 0) } }
|
||||
-- Headbutt/Rock Smash slots. species is optional and the level floor is 0
|
||||
-- because TreeMonSet_Rock has no `rare` half in the ROM (pokegold
|
||||
-- data/wild/treemons.asm ends the table after the common rows), so the four
|
||||
-- Rock Smash maps that point at it carry a rare table read out of whatever
|
||||
-- follows: levels past 100 and rows with no species at all. Rejecting it
|
||||
-- would mean the extractor's own table could never be re-registered.
|
||||
local gen2TreeSlot = f.rec{ chance = f.int(0, 255), level = f.int(0),
|
||||
species = f.opt(f.id("pokemon")) }
|
||||
|
||||
-- a grass row: one encounter rate and one seven-slot table PER time of day,
|
||||
-- which is the whole reason this cannot share the Gen 1 shape
|
||||
local gen2GrassRow = f.rec{
|
||||
map = f.opt(f.str),
|
||||
rates = f.map(gen2Tod, f.int(0, 255)),
|
||||
slots = f.map(gen2Tod, f.list(gen2Slot)),
|
||||
}
|
||||
-- water has no time-of-day split: one rate, one three-slot table
|
||||
local gen2WaterRow = f.rec{
|
||||
map = f.opt(f.str), rate = f.int(0, 255), slots = f.list(gen2Slot),
|
||||
}
|
||||
|
||||
R.encounters = {
|
||||
semantics = "record", target = "encounters",
|
||||
fields = {
|
||||
@@ -556,7 +967,47 @@ R.encounters = {
|
||||
slots = f.list(f.rec{ level = f.int(1),
|
||||
species = f.id("pokemon") }) }),
|
||||
},
|
||||
-- Gold keys wild encounters by encounter KIND first and by map second
|
||||
-- (data.gen2Encounters.grass.ROUTE_29), because the cart ships one table
|
||||
-- per kind and a map appears in as many of them as it has water, swarms,
|
||||
-- fishing spots and headbuttable trees. There is no per-map record to key
|
||||
-- the registry by, so the id is the kind and `patch` folds per map instead
|
||||
-- of replacing the kind's whole table.
|
||||
--
|
||||
-- Semantics stay "record" rather than becoming "deep" even though the id is
|
||||
-- a namespace: a slot table is an ORDERED list whose position is the
|
||||
-- encounter roll, and Merge.deepMerge appends lists under "deep" semantics,
|
||||
-- so a mod rewriting a seven-slot table would get a fourteen-slot one.
|
||||
gen2Keys = {
|
||||
grass = f.map(f.str, gen2GrassRow),
|
||||
-- the swarm variants shadow their base table while a swarm is running
|
||||
swarmGrass = f.map(f.str, gen2GrassRow),
|
||||
water = f.map(f.str, gen2WaterRow),
|
||||
swarmWater = f.map(f.str, gen2WaterRow),
|
||||
-- fishing: a map's rod points at a named group, and the group carries a
|
||||
-- chance-ordered table per rod
|
||||
fishGroups = f.map(f.str, f.rec{
|
||||
id = f.opt(f.str), index = f.opt(f.int(0, 255)),
|
||||
chance = f.int(0, 255),
|
||||
old = f.list(gen2FishSlot), good = f.list(gen2FishSlot),
|
||||
super = f.list(gen2FishSlot) }),
|
||||
-- headbutt: map -> tree set id, and the set's common/rare tables. rocks
|
||||
-- is the same indirection for Rock Smash.
|
||||
trees = f.map(f.str, f.str),
|
||||
rocks = f.map(f.str, f.str),
|
||||
treeSets = f.map(f.str, f.rec{ common = f.list(gen2TreeSlot),
|
||||
rare = f.list(gen2TreeSlot) }),
|
||||
-- the Bug-Catching Contest pool (min/max level, not one level per slot)
|
||||
bugContest = f.list(f.rec{ species = f.id("pokemon"),
|
||||
min = f.int(1), max = f.int(1),
|
||||
chance = f.int(0, 255) }),
|
||||
-- where a roaming beast may walk next, keyed by the map it is on
|
||||
roamMaps = f.list(f.rec{ map = f.str, to = f.list(f.str) }),
|
||||
source = f.str, generation = f.int(1),
|
||||
},
|
||||
example = 'mod.content.encounters:patch("ROUTE_1", { grass = { rate = 30 } })',
|
||||
gen2Example = 'mod.content.encounters:patch("grass", '
|
||||
.. '{ ROUTE_29 = { rates = { NITE = 40 } } })',
|
||||
}
|
||||
|
||||
R.trainers = {
|
||||
@@ -582,7 +1033,64 @@ R.trainers = {
|
||||
-- battles. The victory jingle stays kind-based.
|
||||
battleTheme = f.opt(f.id("music")),
|
||||
},
|
||||
-- Gold hangs its rosters off data.gen2Trainers.classes, one record per
|
||||
-- trainer CLASS carrying every named trainer of that class. The id space
|
||||
-- is still the class id, so the registry keeps the Gen 1 call shape --
|
||||
-- mod.content.trainers:patch("BEAUTY", { baseMoney = 99 }) -- and only the
|
||||
-- one level of indirection to `.classes` is new. That is the same trick
|
||||
-- battle_anims plays with its per-kind subtables, and it is why these three
|
||||
-- callbacks exist rather than a `classes` key nobody would guess.
|
||||
gen2BaseAt = function(base, id)
|
||||
return base.classes and base.classes[id] or nil
|
||||
end,
|
||||
gen2BaseIds = function(base)
|
||||
local ids = {}
|
||||
for id in pairs(base.classes or {}) do ids[#ids + 1] = id end
|
||||
return ids
|
||||
end,
|
||||
gen2Write = function(target, registry)
|
||||
local classes = target.classes
|
||||
if not classes then
|
||||
classes = {}
|
||||
target.classes = classes
|
||||
end
|
||||
local tombstones = {}
|
||||
for id in pairs(registry.ops) do
|
||||
local value = registry:get(id)
|
||||
if value == nil then
|
||||
tombstones[#tombstones + 1] = id
|
||||
else
|
||||
classes[id] = value
|
||||
end
|
||||
end
|
||||
for _, id in ipairs(tombstones) do classes[id] = nil end
|
||||
end,
|
||||
gen2Fields = {
|
||||
id = f.opt(f.str), name = f.str,
|
||||
index = f.opt(f.int(0, 255)),
|
||||
baseMoney = f.opt(f.int(0)),
|
||||
-- the class's battle theme; Gen 1 spells the same idea `battleTheme`,
|
||||
-- but this is the extractor's own key and a strict rename would reject
|
||||
-- every one of Gold's 66 classes
|
||||
encounterMusic = f.opt(f.id("music")),
|
||||
-- the items the class's AI may use mid-battle, and the seven raw AI
|
||||
-- bytes behind them (pokegold data/trainers/attributes.asm)
|
||||
items = f.opt(f.list(f.id("items"))),
|
||||
attributes = f.opt(f.list(f.int(0, 255))),
|
||||
-- one entry per named trainer of the class. trainerType decides which
|
||||
-- optional party fields the cart actually stores, so `moves` and `item`
|
||||
-- are optional here rather than four party shapes in a union.
|
||||
trainers = f.list(f.rec{
|
||||
id = f.opt(f.str), name = f.str, index = f.opt(f.int(0, 255)),
|
||||
trainerType = f.opt(f.enum{ "TRAINERTYPE_NORMAL", "TRAINERTYPE_MOVES",
|
||||
"TRAINERTYPE_ITEM",
|
||||
"TRAINERTYPE_ITEM_MOVES" }),
|
||||
party = f.list(f.rec{ level = f.int(1), species = f.id("pokemon"),
|
||||
item = f.opt(f.id("items")),
|
||||
moves = f.opt(f.list(f.id("moves"))) }) }),
|
||||
},
|
||||
example = 'mod.content.trainers:patch("OPP_BROCK", { baseMoney = 99 })',
|
||||
gen2Example = 'mod.content.trainers:patch("BEAUTY", { baseMoney = 99 })',
|
||||
}
|
||||
|
||||
R.sprites = {
|
||||
@@ -605,7 +1113,32 @@ R.sprites = {
|
||||
-- the ROM (which is what `source` documents on imported records).
|
||||
paletteSource = f.opt(f.str),
|
||||
},
|
||||
-- Same name, same ids, and (unlike the rest of this section) already
|
||||
-- routed: `sprites` merges into data.gen2Sprites and Gold walks that very
|
||||
-- table. The Gen 1 schema accepts a Gen 2 record only because the extra
|
||||
-- keys fall through as unknown-but-preserved, which means none of them is
|
||||
-- checked -- a mod could write paletteId = "blue" and find out at draw
|
||||
-- time. This types them.
|
||||
gen2Fields = {
|
||||
id = f.opt(f.str), image = f.path, frames = f.int(1),
|
||||
walker = f.opt(f.bool), trueColor = f.opt(f.bool),
|
||||
paletteSource = f.opt(f.str),
|
||||
-- the OBJ palette this sprite draws with, by name and by the slot index
|
||||
-- src/world/gen2/Palettes.lua indexes into (PAL_OW_RED is slot 0)
|
||||
palette = f.opt(f.str), paletteId = f.opt(f.int(0, 7)),
|
||||
-- how the overworld animates it: WALKING_SPRITE has the four facings and
|
||||
-- a step cycle, STANDING_SPRITE only the facings, STILL_SPRITE one frame,
|
||||
-- POKEMON_SPRITE the party-icon pair (pokegold constants/sprite_constants)
|
||||
spriteType = f.opt(f.enum{ "WALKING_SPRITE", "STANDING_SPRITE",
|
||||
"STILL_SPRITE", "POKEMON_SPRITE" }),
|
||||
-- a POKEMON_SPRITE names the species it follows and the party icon it
|
||||
-- borrows its art from
|
||||
species = f.opt(f.id("pokemon")), icon = f.opt(f.str),
|
||||
source = f.opt(f.str),
|
||||
},
|
||||
example = 'mod.content.sprites:register("SPRITE_HERO", { image = "...", frames = 6 })',
|
||||
gen2Example = 'mod.content.sprites:patch("SPRITE_BEAUTY", '
|
||||
.. '{ palette = "PAL_OW_RED", paletteId = 0 })',
|
||||
}
|
||||
|
||||
R.text = {
|
||||
@@ -839,7 +1372,48 @@ R.battle_anims = {
|
||||
into[key] = registry:get(id)
|
||||
end
|
||||
end,
|
||||
-- Gold's battle animations are the cart's own bytecode, not a Lua sequence:
|
||||
-- data.gen2BattleAnims is a script POOL keyed by ROM pointer plus the name
|
||||
-- tables that index into it (a move id or an ANIM_* id resolves to a
|
||||
-- pointer), and the object/frameset/OAM/graphics tables the scripts spawn
|
||||
-- from. src/battle/gen2/AnimRunner.lua walks exactly those. The id is the
|
||||
-- table, and `patch` adds one object without restating the pool.
|
||||
--
|
||||
-- The Gen 1 write/baseAt/baseIds trio is cleared rather than reused: it
|
||||
-- routes an id into a per-kind subtable by prefix, and here the ids ARE the
|
||||
-- subtables, so the plain record placement is the correct one.
|
||||
gen2Write = false, gen2BaseAt = false, gen2BaseIds = false,
|
||||
gen2Keys = {
|
||||
-- pointer -> the decoded command rows the runner steps; each row is a
|
||||
-- verb string followed by its operands
|
||||
scripts = f.map(f.str, f.list(f.list(f.any))),
|
||||
-- the pool in ROM order, which is what a re-export writes back
|
||||
scriptOrder = f.list(f.str),
|
||||
-- move id -> script pointer, and ANIM_* id -> script pointer
|
||||
moves = f.map(f.str, f.str),
|
||||
ids = f.map(f.str, f.str),
|
||||
-- an animation object: which graphics, palette, frameset and update
|
||||
-- function it spawns with
|
||||
objects = f.map(f.str, f.rec{ gfx = f.str, palette = f.str,
|
||||
frameset = f.str, func = f.str,
|
||||
fixY = f.opt(f.int(0, 255)),
|
||||
flags = f.opt(f.int(0, 255)) }),
|
||||
-- a frameset is its own little row list (frame / wait / delete)
|
||||
framesets = f.map(f.str, f.list(f.list(f.any))),
|
||||
-- OAM: the sprite rectangle an object draws, and the VRAM tile it starts
|
||||
-- at. x/y are the cart's unsigned bytes, so 240 means -16.
|
||||
oamsets = f.map(f.str, f.rec{ vtile = f.int(0, 255),
|
||||
sprites = f.list(f.rec{
|
||||
x = f.int(0, 255), y = f.int(0, 255),
|
||||
tile = f.int(0, 255),
|
||||
attr = f.int(0, 255) }) }),
|
||||
gfx = f.map(f.str, f.rec{ image = f.path, tiles = f.int(1),
|
||||
wide = f.int(1) }),
|
||||
bank = f.int(0), source = f.str, generation = f.int(1),
|
||||
},
|
||||
example = 'mod.content.battle_anims:register("SHADOW_BALL", { seq = { ... } })',
|
||||
gen2Example = 'mod.content.battle_anims:patch("moves", '
|
||||
.. '{ SHADOW_BALL = "5e86" })',
|
||||
}
|
||||
|
||||
R.transitions = {
|
||||
@@ -1052,7 +1626,45 @@ R.palettes = {
|
||||
return ("needs exactly 4 colors, got %d"):format(#colors)
|
||||
end
|
||||
end,
|
||||
-- Gold's palette table is not a flat name -> four colours map: the GBC has
|
||||
-- eight BG and eight OBJ slots and the cart reloads them per context, so
|
||||
-- the extractor writes one subtable per context (mon pics with their shiny
|
||||
-- twin, trainer pics, the BG rows a map's environment indexes into, the
|
||||
-- overworld OBJ rows per time of day, the town roof pair, the HP and EXP
|
||||
-- bars). The id is the context, so a mod that recolours one species
|
||||
-- patches `pokemon` and leaves the other 250 alone. The Gen 1 four-colour
|
||||
-- `extra` is cleared: it reads the record as one palette, and here a record
|
||||
-- is a whole subtable of them.
|
||||
gen2Extra = false,
|
||||
gen2Keys = {
|
||||
-- every species has both a normal and a shiny row; the shiny one is what
|
||||
-- src/render/GbcPalette.lua swaps in on a shiny battler
|
||||
pokemon = f.map(f.str, f.rec{ normal = gen2PaletteRow,
|
||||
shiny = gen2PaletteRow }),
|
||||
trainers = f.map(f.str, gen2PaletteRow),
|
||||
-- the BG rows, indexed by number: `environments` names eight of them per
|
||||
-- environment per time of day, which is how a map gets its palette
|
||||
bg = f.list(gen2PaletteRow),
|
||||
environments = f.map(f.str, f.map(gen2PaletteTod, f.list(f.int(0)))),
|
||||
-- the eight overworld OBJ rows per time of day; a sprite's paletteId
|
||||
-- indexes this
|
||||
objects = f.map(gen2PaletteTod, f.list(gen2PaletteRow)),
|
||||
-- one pair per roof group (keyed by the group number, 0 included), and
|
||||
-- the BG slot the roof colours are written into
|
||||
roofs = f.map(f.int(0), f.rec{ mornDay = gen2PaletteRow,
|
||||
nite = gen2PaletteRow }),
|
||||
roofSlot = f.int(0, 7),
|
||||
hpBar = f.map(f.enum{ "green", "yellow", "red", "blue" }, gen2PaletteRow),
|
||||
expBar = gen2PaletteRow,
|
||||
partyMenu = f.list(gen2PaletteRow),
|
||||
battleObjects = f.map(f.str, gen2PaletteRow),
|
||||
-- the ordered name lists the numeric indices above resolve through
|
||||
daytimes = f.list(f.str), slotNames = f.list(f.str),
|
||||
source = f.str, generation = f.int(1),
|
||||
},
|
||||
example = 'mod.content.palettes:override("MEWMON", { {255,255,255}, ... })',
|
||||
gen2Example = 'mod.content.palettes:patch("pokemon", '
|
||||
.. '{ TOTODILE = { shiny = { {255,255,255}, {255,0,0} } } })',
|
||||
}
|
||||
|
||||
-- keyed by species id, unlike the vanilla byDex array: a species past the
|
||||
@@ -1061,10 +1673,63 @@ R.palettes = {
|
||||
-- dex-indexed default. The value is a built-in icon NAME -- one of BALL, BIRD,
|
||||
-- BUG, FAIRY, GRASS, HELIX, MON, QUADRUPED, SNAKE, WATER (uppercase) -- or a
|
||||
-- { image = <bundled file path>, frames? } table of your own art.
|
||||
-- Gold splits the same idea in two: data.gen2Icons.icons is the 39 icon
|
||||
-- SHEETS (each its own two-frame image) and data.gen2Icons.species is the
|
||||
-- species -> sheet name assignment. Both halves keep the Gen 1 id space --
|
||||
-- a species id names an assignment, a sheet id names a sheet -- so one
|
||||
-- registry serves both, routed by the ICON_ prefix every sheet name carries.
|
||||
-- Two id forms in one registry is the same shape font and battle_anims use.
|
||||
local function gen2IconIsSheet(id)
|
||||
return tostring(id):match("^ICON_") ~= nil
|
||||
end
|
||||
|
||||
R.icons = {
|
||||
semantics = "record", target = "icons.bySpecies",
|
||||
value = f.union{ f.str, f.rec{ image = f.path, frames = f.opt(f.int(1)) } },
|
||||
gen2Value = f.union{
|
||||
-- the assignment form: a species id mapped to a sheet name
|
||||
f.str,
|
||||
-- the sheet form: width/height are the sheet's pixel size, and every
|
||||
-- vanilla sheet is a 16x32 two-frame strip
|
||||
f.rec{ id = f.opt(f.str), index = f.opt(f.int(0, 255)), image = f.path,
|
||||
width = f.int(1), height = f.int(1), frames = f.int(1) },
|
||||
},
|
||||
gen2Extra = function(id, value)
|
||||
if gen2IconIsSheet(id) then
|
||||
if type(value) ~= "table" then
|
||||
return "an ICON_ id is a sheet and needs an image, width, height and frames"
|
||||
end
|
||||
elseif type(value) ~= "string" then
|
||||
return "a species id takes the NAME of an ICON_ sheet, not a sheet"
|
||||
end
|
||||
end,
|
||||
gen2BaseAt = function(base, id)
|
||||
if gen2IconIsSheet(id) then return base.icons and base.icons[id] or nil end
|
||||
return base.species and base.species[id] or nil
|
||||
end,
|
||||
gen2BaseIds = function(base)
|
||||
local ids = {}
|
||||
for id in pairs(base.icons or {}) do ids[#ids + 1] = id end
|
||||
for id in pairs(base.species or {}) do ids[#ids + 1] = id end
|
||||
return ids
|
||||
end,
|
||||
gen2Write = function(target, registry)
|
||||
local sheets, species = target.icons, target.species
|
||||
if not sheets then
|
||||
sheets = {}
|
||||
target.icons = sheets
|
||||
end
|
||||
if not species then
|
||||
species = {}
|
||||
target.species = species
|
||||
end
|
||||
for _, id in ipairs(registry.order) do
|
||||
local into = gen2IconIsSheet(id) and sheets or species
|
||||
into[id] = registry:get(id)
|
||||
end
|
||||
end,
|
||||
example = 'mod.content.icons:register("MODMON", "QUADRUPED") -- a built-in name, or { image = mod.assets:path("icon.png"), frames = 2 }',
|
||||
gen2Example = 'mod.content.icons:override("TOTODILE", "ICON_MONSTER")',
|
||||
}
|
||||
|
||||
-- glyph codes are not bytes: the vanilla pages sit at $60/$80 but a
|
||||
@@ -1178,6 +1843,44 @@ R.tokens = {
|
||||
|
||||
-- ------- deep registries: id is a top-level key of the target table
|
||||
|
||||
-- Gold's `constants` is not the Gen 1 rule block at all: it is the ROM's own
|
||||
-- ordered name lists, one per enum the cart indexes by number. A script
|
||||
-- opcode that says "special 12" or an animation that says "object 41" is
|
||||
-- resolved through these, so replacing an entry renames what that number
|
||||
-- means. Every one of them is a dense list of ids in ROM order, which is why
|
||||
-- they can be built from a name list instead of restated one by one.
|
||||
local GEN2_CONSTANT_ORDERS = {
|
||||
"battleAnimBgPaletteOrder", "battleAnimFramesetOrder", "battleAnimFuncOrder",
|
||||
"battleAnimGfxOrder", "battleAnimOamsetOrder", "battleAnimObPaletteOrder",
|
||||
"battleAnimObjectOrder", "battleBgEffectOrder", "cmdQueueOrder",
|
||||
"decoDescOrder", "eggGroupOrder", "environmentOrder", "evolveMethodOrder",
|
||||
"fishGroupOrder", "floorOrder", "growthRateOrder", "heldEffectOrder",
|
||||
"iconOrder", "itemMenuOrder", "itemOrder", "landmarkOrder",
|
||||
"mapCallbackOrder", "mapOrder", "moveEffectOrder", "moveOrder", "musicOrder",
|
||||
"paletteOrder", "phoneContactOrder", "pocketOrder", "sfxOrder", "spawnOrder",
|
||||
"specialCallOrder", "specialOrder", "speciesOrder", "spriteOrder",
|
||||
"stdScriptOrder", "tilesetOrder", "tradeDialogOrder", "tradeGenderOrder",
|
||||
"trainerClassOrder", "trainerTypeOrder", "treeMonSetOrder",
|
||||
}
|
||||
|
||||
local gen2ConstantKeys = {
|
||||
-- the map table the group/number pair in a warp resolves through
|
||||
mapGroups = f.list(f.rec{ group = f.int(0), map = f.int(0), name = f.str,
|
||||
width = f.int(1), height = f.int(1) }),
|
||||
-- class id -> its named trainers, in the order the class's table stores them
|
||||
trainerClassMembers = f.map(f.str, f.list(f.str)),
|
||||
-- type id -> its ROM byte; the only one of these that is a lookup rather
|
||||
-- than an ordered list, because the type numbers are not contiguous
|
||||
types = f.map(f.str, f.int(0)),
|
||||
-- counts the extractor stamps beside the lists
|
||||
itemNameCount = f.int(0), numOverworldSprites = f.int(0),
|
||||
spritePokemon = f.int(0),
|
||||
source = f.str, generation = f.int(1),
|
||||
}
|
||||
for _, name in ipairs(GEN2_CONSTANT_ORDERS) do
|
||||
gen2ConstantKeys[name] = f.list(f.str)
|
||||
end
|
||||
|
||||
-- The rules the engine used to hard-code as Kanto/Red literals. Keys the
|
||||
-- importer does not stamp are seeded with their vanilla value at data load
|
||||
-- (src/core/Data.lua) so a patch always has something to fold over.
|
||||
@@ -1195,7 +1898,16 @@ R.constants = {
|
||||
hmMoves = f.list(f.id("moves")),
|
||||
encounterBuckets = f.list(f.int(1, 256)),
|
||||
},
|
||||
-- Gold's keys are ordered lists where position IS the id a script byte
|
||||
-- resolves through, so they must replace rather than append -- which is
|
||||
-- what "deep" semantics would do to them (Merge.deepMerge concatenates
|
||||
-- lists there, and Gen 1's `field` rows genuinely want that). A key
|
||||
-- neither catalog names is still a mod's own data and merges as-is.
|
||||
gen2Semantics = "record",
|
||||
gen2Keys = gen2ConstantKeys,
|
||||
example = 'mod.content.constants:patch("levelCap", 80)',
|
||||
gen2Example = 'mod.content.constants:patch("speciesOrder", '
|
||||
.. '{ [252] = "MODMON" })',
|
||||
}
|
||||
|
||||
-- The overworld's data grab bag. Only the keys this milestone routes are
|
||||
@@ -1267,6 +1979,150 @@ R.text_pointers = {
|
||||
example = 'mod.content.text_pointers:patch("PalletTown", { TEXT_PALLETTOWN_SIGN = { text = "_MySign" } })',
|
||||
}
|
||||
|
||||
-- ------- Gen 2 only content
|
||||
--
|
||||
-- The mirror of the gated rows in Schemas.GEN2: six systems Gold has and Red
|
||||
-- does not, so there is no Gen 1 table to share a target with and no Gen 1
|
||||
-- consumer to read one. Each spec therefore carries NO `target` at all -- the
|
||||
-- routed Schemas.GEN2 path is its only home -- and a Schemas.GEN1 row of
|
||||
-- `false`, which is what turns a Red mod's write into the same reported drop a
|
||||
-- Gold mod gets for `tokens` instead of a silent merge into a namespace
|
||||
-- nothing on Red would ever read.
|
||||
--
|
||||
-- Names stay plain for the same reason hook and event names do: `decorations`
|
||||
-- is what the thing is called, and a "gen2Decorations" registry NAME would be
|
||||
-- a namespace no mod could ever share if Gen 1 grew the system later. Only
|
||||
-- the Data path underneath carries the gen2 prefix.
|
||||
|
||||
-- data/items/attributes.asm's last two columns, split out of the item record
|
||||
-- so a mod can give an item a held behaviour without owning the whole item.
|
||||
-- src/core/Game2.lua seeds the merge target from data.items and writes the
|
||||
-- merged rows back onto it, and src/battle/gen2/Battle.lua's heldEffect (the
|
||||
-- one read all eight held-item sites go through, and the held_item.trigger
|
||||
-- hook's own site) reads it from there.
|
||||
R.held_items = {
|
||||
semantics = "record",
|
||||
fields = {
|
||||
-- the HELD_* name the battle compares against, out of
|
||||
-- data.gen2Constants.heldEffectOrder; a mod may invent its own and steer
|
||||
-- it from the held_item.trigger hook
|
||||
heldEffect = f.str,
|
||||
-- ItemAttributes' parameter byte: the boost percentage, the heal amount,
|
||||
-- the BrightPowder odds -- whatever the effect reads it as
|
||||
heldParameter = f.opt(f.int(0, 255)),
|
||||
},
|
||||
example = 'mod.content.held_items:override("LEFTOVERS", '
|
||||
.. '{ heldEffect = "HELD_LEFTOVERS", heldParameter = 0 })',
|
||||
}
|
||||
|
||||
-- data/phone/phone_contacts.asm, one record per PHONE_* row. The id space is
|
||||
-- data.gen2Constants.phoneContactOrder, so PHONE_YOUNGSTER_JOEY names the row
|
||||
-- the cart calls PHONE_YOUNGSTER_JOEY; `index` is that row's byte, which is
|
||||
-- what the save's contact list holds and what src/core/gen2/Phone.lua keys
|
||||
-- every one of its own lookups by. The four PHONE_UNUSED const_skip holes are
|
||||
-- not registered -- they are copies of the wrong-number filler row, and one id
|
||||
-- cannot name four of them.
|
||||
R.phone_contacts = {
|
||||
semantics = "record",
|
||||
fields = {
|
||||
index = f.int(0),
|
||||
-- non-trainer rows (MOM, BILL, ELM, the BIKE SHOP) carry a PHONECONTACT_*
|
||||
-- number instead of a trainer; trainer rows carry the class and the
|
||||
-- roster member, which is what the rematch machinery and the caller's
|
||||
-- name are looked up by
|
||||
number = f.opt(f.int(0, 255)),
|
||||
class = f.opt(f.str), member = f.opt(f.str),
|
||||
map = f.opt(f.id("maps")),
|
||||
-- the SCRIPT1 / SCRIPT2 time masks: MORN | DAY | NITE, 0 for "never"
|
||||
calleeTime = f.opt(f.int(0, 7)), callerTime = f.opt(f.int(0, 7)),
|
||||
-- the script LABEL (Phone.SCRIPT_KEYS resolves it) and, once the cache
|
||||
-- has been read, the "<bank>:<addr>" pointer it resolved to
|
||||
callee = f.opt(f.str), caller = f.opt(f.str),
|
||||
calleeKey = f.opt(f.str), callerKey = f.opt(f.str),
|
||||
},
|
||||
example = 'mod.content.phone_contacts:patch("PHONE_YOUNGSTER_JOEY", '
|
||||
.. '{ map = "ROUTE_31" })',
|
||||
}
|
||||
|
||||
-- data/decorations/attributes.asm, one record per DECO_* row. The cart's
|
||||
-- decoration constants are a bare const_def block with no name table behind
|
||||
-- them -- nothing in the ROM spells DECO_FEATHERY_BED -- so the id is the
|
||||
-- attribute row's own index, written "deco:<n>" the way battle_anims writes
|
||||
-- "subanim:<n>". That index IS wMenuSelection, which is what every caller
|
||||
-- passes src/core/gen2/Decorations.lua.
|
||||
R.decorations = {
|
||||
semantics = "record",
|
||||
fields = {
|
||||
-- constants/deco_constants.asm decoration types: 1 PLANT, 2 BED,
|
||||
-- 3 CARPET, 4 POSTER, 5 DOLL, 6 BIGDOLL. The type decides how GetDecoName
|
||||
-- spells the row and whether `sprite` is a block id or a sprite one.
|
||||
type = f.int(1, 6),
|
||||
name = f.str,
|
||||
-- DECOATTR_ACTION, as the Decorations.ACTIONS key rather than the
|
||||
-- jumptable index; nil on the CANCEL row alone
|
||||
action = f.opt(f.str),
|
||||
-- DECOATTR_EVENT_FLAG: the wEventFlags bit that says the player owns it
|
||||
flag = f.int(0),
|
||||
-- DECOATTR_SPRITE: a BLOCK id for the four kinds the map paints, a
|
||||
-- SPRITE_* byte for the four an object stands on
|
||||
sprite = f.int(0, 255),
|
||||
},
|
||||
example = 'mod.content.decorations:patch("deco:2", { name = "COZY" })',
|
||||
}
|
||||
|
||||
-- data/items/apricorn_balls.asm. Id = the apricorn item, because that is what
|
||||
-- the player hands Kurt and what FindApricornsInBag walks the bag for;
|
||||
-- `index` is the row's position in that table, which is load bearing twice
|
||||
-- (Kurt's menu order and the checkevent chain in maps/KurtsHouse.asm).
|
||||
R.apricorns = {
|
||||
semantics = "record",
|
||||
fields = {
|
||||
apricorn = f.id("items"), ball = f.id("items"),
|
||||
-- constants/event_flags.asm index of this apricorn's EVENT_GAVE_KURT_*
|
||||
event = f.int(0),
|
||||
index = f.int(1),
|
||||
},
|
||||
example = 'mod.content.apricorns:override("RED_APRICORN", '
|
||||
.. '{ apricorn = "RED_APRICORN", ball = "ULTRA_BALL", event = 600, index = 1 })',
|
||||
}
|
||||
|
||||
-- data/maps/landmarks.asm. The town-map places, which on Gold are one index
|
||||
-- space shared by the Pokegear MAP card, the #DEX AREA page and every map
|
||||
-- header's `landmark` byte. The merge lands inside the cache's own landmark
|
||||
-- table (gen2Landmarks.landmarks), so a registered record is one the map card
|
||||
-- can already draw.
|
||||
R.landmarks = {
|
||||
semantics = "record",
|
||||
fields = {
|
||||
id = f.opt(f.str),
|
||||
-- the two-line name the town map prints, "\n" and all
|
||||
name = f.str,
|
||||
-- the marker's tile position on the 20x18 town map
|
||||
x = f.int(0), y = f.int(0),
|
||||
-- LANDMARK_*: the byte a map header carries, and what
|
||||
-- src/core/gen2/Nests.lua's region split reads
|
||||
index = f.int(0),
|
||||
},
|
||||
example = 'mod.content.landmarks:patch("LANDMARK_ROUTE_29", { x = 12 })',
|
||||
}
|
||||
|
||||
-- PlayRadioStationPointers (engine/pokegear/pokegear.asm). Id = the station
|
||||
-- the dial resolves to, which is the LoadStation_* id the show state machine
|
||||
-- is keyed by; `channel` is its MAPRADIO_* dial position, the byte a wall
|
||||
-- radio's `setval` passes to the MapRadio special. Position 0 is not a
|
||||
-- station: it resolves by region and time of day, so no record claims it.
|
||||
R.radio_channels = {
|
||||
semantics = "record",
|
||||
fields = {
|
||||
channel = f.int(0, 255),
|
||||
-- the name quoted in the text box; without one the Pokegear's own
|
||||
-- STATION_NAMES row is used, which is where the vanilla eight get theirs
|
||||
name = f.opt(f.str),
|
||||
},
|
||||
example = 'mod.content.radio_channels:register("PIRATE_RADIO", '
|
||||
.. '{ channel = 9, name = "PIRATE RADIO" })',
|
||||
}
|
||||
|
||||
-- ------- persistence
|
||||
|
||||
-- compose, keyed by the owning mod id: the runner walks each owner's chain
|
||||
|
||||
Reference in New Issue
Block a user