G2 support

This commit is contained in:
bryanthaboi
2026-08-11 11:52:56 -04:00
parent 79ed37699e
commit ae6cac89e1
489 changed files with 226677 additions and 1798 deletions
+112 -13
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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]
+36
View File
@@ -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
View File
@@ -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
+171
View File
@@ -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
View File
@@ -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