big ass modding update

This commit is contained in:
bryanthaboi
2026-07-19 16:18:18 -04:00
parent b5a673b252
commit 47923d95b3
258 changed files with 31048 additions and 2310 deletions
+230
View File
@@ -0,0 +1,230 @@
-- Asset transforms (D11): the manifest's assets_transforms file, run once
-- at install / first load to generate derived art from the player's *own*
-- imported cache. A mod ships the recipe, never the pixels, which is the
-- only sanctioned way to port art that overlaps vanilla Red
-- (17-total-conversions.md §legal posture).
--
-- The chunk runs in a restricted context: a table of image utilities and
-- exactly two filesystem roots -- read assets/generated/**, write
-- save/mod-derived/<id>/** -- with no require, no love, no io, no os.
-- assets/generated is never written because re-import wipes it whole
-- (RomImporter), so anything a transform put there would vanish.
--
-- A stamp of (cache marker + transform source hash) gates the run, so the
-- cost is paid once per install and re-paid only when the cache is
-- re-imported or the recipe changes.
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local unpack = table.unpack or unpack
local loadstring = loadstring or load
local AssetTransform = {}
local SOURCE_ROOT = "assets/generated/"
local DERIVED_ROOT = "save/mod-derived/"
local CACHE_MARKER = "rom-cache.complete"
local STAMP = ".stamp"
AssetTransform.SOURCE_ROOT = SOURCE_ROOT
AssetTransform.DERIVED_ROOT = DERIVED_ROOT
-- ------- path sandbox
-- a relative path that cannot climb out of the root it is joined to
local function safeRelative(rel)
if type(rel) ~= "string" or rel == "" then return nil end
if rel:sub(1, 1) == "/" then return nil end
if rel:find("\\", 1, true) then return nil end
for segment in rel:gmatch("[^/]+") do
if segment == ".." or segment == "." then return nil end
end
return rel
end
local function requireRelative(rel, what)
local safe = safeRelative(rel)
if not safe then
error(("%s must stay inside its root, got %q"):format(what, tostring(rel)), 0)
end
return safe
end
-- ------- the restricted context
-- shade classification matching the importer's 4 grays and the render
-- thresholds (PaletteFX 0.83 / 0.5 / 0.17), so recolor lands on the same
-- buckets every other consumer reads
local function shadeIndex(r)
if r > 0.83 then return 1 end
if r > 0.5 then return 2 end
if r > 0.17 then return 3 end
return 4
end
-- shade index -> new color, as 0-255 triples (a palettes record's shape).
-- Alpha rides through untouched so a matted battle pic stays matted.
function AssetTransform.recolor(imageData, shades)
assert(type(shades) == "table" and #shades == 4,
"recolor needs 4 colors, lightest first")
local out = love.image.newImageData(imageData:getDimensions())
out:paste(imageData, 0, 0, 0, 0, imageData:getDimensions())
out:mapPixel(function(_, _, r, g, b, a)
if a == 0 then return r, g, b, a end
local c = shades[shadeIndex(r)]
return c[1] / 255, c[2] / 255, c[3] / 255, a
end)
return out
end
local function contextFor(modId, fs)
local ImageWriter = require("src.import.ImageWriter")
local derivedRoot = DERIVED_ROOT .. modId .. "/"
local written = 0
local ctx = {}
function ctx.source(rel)
return SOURCE_ROOT .. requireRelative(rel, "source path")
end
function ctx.derived(rel)
return derivedRoot .. requireRelative(rel, "derived path")
end
function ctx.exists(rel)
return fs.getInfo(ctx.source(rel)) ~= nil
end
function ctx.readImage(rel)
return love.image.newImageData(ctx.source(rel))
end
function ctx.writeImage(imageData, rel)
local path = ctx.derived(rel)
local dir = path:match("^(.*)/[^/]+$")
-- an injected headless fs implies its directories from key prefixes
if dir and fs.createDirectory then fs.createDirectory(dir) end
local encoded = imageData:encode("png")
local ok, err = fs.write(path, encoded)
if not ok then error("could not write " .. path .. ": " .. tostring(err), 0) end
written = written + 1
return path
end
ctx.blank = ImageWriter.blank
ctx.blit = ImageWriter.blit
ctx.matte = ImageWriter.matteColor0
ctx.recolor = AssetTransform.recolor
return ctx, function() return written end
end
-- Globals the recipe sees. Everything that could reach the filesystem,
-- the network or another engine module is absent, so the only way out of
-- the sandbox is the ctx table the transform is handed.
local function sandboxEnv()
return {
math = math, string = string, table = table,
ipairs = ipairs, pairs = pairs, next = next, select = select,
type = type, tostring = tostring, tonumber = tonumber,
assert = assert, error = error, pcall = pcall, unpack = unpack,
}
end
-- The recipe is compiled from source we already read rather than through
-- fs.load, because that is the only way the environment is ours to set:
-- 5.1/LuaJIT swap it after the fact with setfenv, 5.2+ dropped setfenv and
-- take the env as load's 4th argument. Getting this wrong hands the chunk
-- the real globals -- require, love, io -- so it is never left to chance.
local function loadSandboxed(source, chunkname)
local env = sandboxEnv()
if setfenv then
local chunk, err = loadstring(source, chunkname)
if not chunk then return nil, err end
setfenv(chunk, env)
return chunk
end
return load(source, chunkname, "t", env)
end
-- ------- stamp
-- djb2 over the recipe source; only has to change when the file does
local function hash(text)
local h = 5381
for i = 1, #text do
h = (h * 33 + text:byte(i)) % 4294967296
end
return string.format("%08x", h)
end
local function stampFor(fs, source)
local marker = fs.read(CACHE_MARKER) or "no-cache"
return marker .. "|" .. hash(source)
end
-- ------- runner
-- Run one mod's transform. Returns true when the derived assets are
-- current (whether this call built them or a previous one did); false
-- plus a reason when the recipe failed, which disables that mod's derived
-- art and nothing else. force skips the stamp (dev-mode hot reload).
function AssetTransform.runFor(mod, fs, force)
fs = fs or (love and love.filesystem)
local manifest = mod.manifest
local relative = manifest and manifest.assets_transforms
if not relative then return true end
local modId = manifest.id
local path = mod.path .. "/" .. relative
local source = fs.read(path)
if not source then
return false, "assets_transforms unreadable: " .. relative
end
local stampPath = DERIVED_ROOT .. modId .. "/" .. STAMP
local want = stampFor(fs, source)
if not force and fs.read(stampPath) == want then return true end
local chunk, err = loadSandboxed(source, path)
if not chunk then return false, "assets_transforms: " .. tostring(err) end
local ctx, count = contextFor(modId, fs)
local ok, result = pcall(chunk)
if ok and type(result) == "function" then
ok, result = pcall(result, ctx)
elseif ok and type(result) ~= "function" then
ok, result = false, "assets_transforms must return a function(ctx)"
end
if not ok then
local reason = "asset transform failed: " .. tostring(result)
Logger.error("[%s] %s", modId, reason)
Runtime.reportError(modId, reason)
return false, reason
end
if fs.createDirectory then fs.createDirectory(DERIVED_ROOT .. modId) end
fs.write(stampPath, want)
Runtime.emit("assets.transformed", { modId = modId, count = count() })
return true
end
-- every loaded mod that declares a transform, in load order. A failing
-- recipe is reported against its mod and the rest still run.
function AssetTransform.run(loader, force)
local ran = 0
for _, mod in ipairs(loader.loaded or {}) do
if mod.manifest.assets_transforms then
local ok, reason = AssetTransform.runFor(mod, loader.fs, force)
if ok then
ran = ran + 1
elseif reason then
loader.errors[#loader.errors + 1] = mod.manifest.id .. ": " .. reason
end
end
end
return ran
end
return AssetTransform
+119
View File
@@ -0,0 +1,119 @@
-- The engine's own content, registered into the catalog under owner
-- "engine" before any mod runs. Overriding a vanilla record and overriding
-- a mod's record are then the same verb, each() always yields the whole
-- world, and a mod's cross-references resolve against real ids.
-- Every registrant hands over the table its module already reads, and
-- install deep-copies it on the way in: two loads must not share record
-- tables, or an edit through one dataset (hot reload, a test loading
-- twice) reaches the other and the module's own statics. Functions ride
-- the copy by reference, so handlers keep their identity and the merged
-- value stays equal to the vanilla one -- the mod-free merge is a no-op.
-- Modules are required lazily -- the loader must not drag the battle and
-- script stacks in with it at require time.
local Logger = require("src.core.Logger")
local Merge = require("src.mods.Merge")
local Schemas = require("src.mods.Schemas")
local Builtins = {}
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" },
{ name = "statuses", from = "src.battle.Status" },
{ name = "move_effects", from = "src.battle.MoveEffects" },
{ name = "balls", from = "src.battle.Catching" },
{ name = "transitions", from = "src.render.BattleTransition" },
{ name = "growth_rates", from = "src.pokemon.Growth" },
{ name = "evolution_methods", from = "src.pokemon.Evolution" },
{ name = "commands", from = "src.script.Commands" },
{ name = "tokens", from = "src.render.TextBox" },
-- plain data files with no owning module: registered from here
{ name = "rulesets", modules = { "src.battle.rulesets.gen1_faithful",
"src.battle.rulesets.modern_clean" },
install = function(registry, modules, owner)
for _, ruleset in ipairs(modules) do
registry:register(ruleset.name, ruleset, owner)
end
end },
-- the per-trainer class records plus the three vanilla move-scoring
-- layers, which share the registry under LAYER_1..LAYER_3
{ name = "ai_classes", modules = { "data.scripts.ai_classes",
"src.battle.TrainerAI" },
install = function(registry, modules, owner)
for id, record in pairs(modules[1]) do
registry:register(id, record, owner)
end
modules[2].registerInto(registry, nil, owner)
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()
local names = {}
for i, entry in ipairs(REGISTRANTS) 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()
local roots = {}
for _, name in ipairs(Builtins.registries()) do
local target = Schemas.REGISTRIES[name] and Schemas.REGISTRIES[name].target
if target then roots[target:match("^[^%.]+")] = true end
end
return roots
end
-- a module the build dropped disables its registry rather than the game:
-- the consumer still reads its own table, so vanilla keeps working
local function load(path)
local ok, module = pcall(require, path)
if ok then return module end
Logger.warn("builtin registrations skipped for %s (%s)", path, tostring(module))
return nil
end
-- the write verbs copy their payload before it lands; centralized here so
-- the isolation holds for every registrant instead of leaning on each
-- module to hand over fresh tables
local function isolate(registry)
return setmetatable({
register = function(_, id, value, owner)
return registry:register(id, Merge.deepCopy(value), owner)
end,
override = function(_, id, value, owner)
return registry:override(id, Merge.deepCopy(value), owner)
end,
patch = function(_, id, partial, owner)
return registry:patch(id, Merge.deepCopy(partial), owner)
end,
}, { __index = registry })
end
function Builtins.install(content, data)
for _, entry in ipairs(REGISTRANTS) do
local registry = content[entry.name] and isolate(content[entry.name])
if registry then
if entry.install then
local modules, complete = {}, true
for i, path in ipairs(entry.modules) do
modules[i] = load(path)
if modules[i] == nil then complete = false end
end
if complete then entry.install(registry, modules, Builtins.OWNER) end
else
local module = load(entry.from)
if module and module.registerInto then
module.registerInto(registry, data, Builtins.OWNER)
end
end
end
end
end
return Builtins
+51 -9
View File
@@ -1,35 +1,77 @@
local Logger = require("src.core.Logger")
local Events = {}
Events.__index = Events
function Events.new()
return setmetatable({ listeners = {}, sealed = false }, Events)
return setmetatable({ listeners = {} }, Events)
end
function Events:on(name, callback, priority)
assert(not self.sealed, "mod events are sealed")
-- owner is the subscribing mod id; failures are attributed to it
function Events:on(name, callback, priority, owner)
assert(type(name) == "string" and name ~= "", "event name is required")
assert(type(callback) == "function", "event callback must be a function")
local list = self.listeners[name] or {}
self.listeners[name] = list
local entry = { callback = callback, priority = priority or 0 }
local entry = { callback = callback, priority = priority or 0, owner = owner }
list[#list + 1] = entry
table.sort(list, function(a, b) return a.priority > b.priority end)
return function()
for i, candidate in ipairs(list) do
if candidate == entry then table.remove(list, i) break end
end
-- an emptied name drops its key, as removeOwner does, so Runtime.wants
-- stops telling hot call sites to build payloads for nobody; the
-- identity check keeps a stale second call off a later subscription
if #list == 0 and self.listeners[name] == list then
self.listeners[name] = nil
end
end
end
-- retires itself after the first fire; safe to unsubscribe from inside the
-- dispatch because emit walks a copy
function Events:once(name, callback, priority, owner)
assert(type(callback) == "function", "event callback must be a function")
local unsubscribe
unsubscribe = self:on(name, function(payload)
unsubscribe()
return callback(payload)
end, priority, owner)
return unsubscribe
end
-- a throwing listener is logged and skipped so the emitting engine path
-- always completes; the error never propagates
function Events:emit(name, payload)
local list = self.listeners[name] or {}
for _, entry in ipairs(list) do
entry.callback(payload)
local list = self.listeners[name]
if not list then return end
-- dispatch over a snapshot: a listener may retire itself or a sibling
-- mid-emit (once, or the closure on() returns), and table.remove on the
-- live list shifts the entries ipairs has not reached yet
local snapshot = {}
for i = 1, #list do snapshot[i] = list[i] end
for _, entry in ipairs(snapshot) do
local ok, err = pcall(entry.callback, payload)
if not ok then
Logger.error("[%s] event %s: %s",
tostring(entry.owner or "?"), name, tostring(err))
end
end
end
function Events:seal()
self.sealed = true
-- drops every subscription a mod made; used by entry-chunk rollback
function Events:removeOwner(owner)
if owner == nil then return end
for name, list in pairs(self.listeners) do
for i = #list, 1, -1 do
if list[i].owner == owner then table.remove(list, i) end
end
if #list == 0 then self.listeners[name] = nil end
end
end
-- deprecated no-op: subscription stays legal for the life of the process
function Events:seal() end
return Events
+78 -20
View File
@@ -1,18 +1,27 @@
local Logger = require("src.core.Logger")
local Hooks = {}
Hooks.__index = Hooks
local unpack = table.unpack or unpack
local function pack(...) return { n = select("#", ...), ... } end
-- errors raised below the chain (the vanilla function itself) must not be
-- attributed to a mod link or retried; they ride out wrapped under this key
-- so every guard re-raises instead of skipping
local PASS = {}
function Hooks.new()
return setmetatable({ chains = {}, sealed = false }, Hooks)
return setmetatable({ chains = {} }, Hooks)
end
function Hooks:wrap(name, callback, priority)
assert(not self.sealed, "mod hooks are sealed")
-- owner is the wrapping mod id; failures are attributed to it
function Hooks:wrap(name, callback, priority, owner)
assert(type(name) == "string" and name ~= "", "hook name is required")
assert(type(callback) == "function", "hook callback must be a function")
local chain = self.chains[name] or {}
self.chains[name] = chain
local entry = { callback = callback, priority = priority or 0 }
local entry = { callback = callback, priority = priority or 0, owner = owner }
chain[#chain + 1] = entry
table.sort(chain, function(a, b) return a.priority > b.priority end)
return function()
@@ -22,26 +31,75 @@ function Hooks:wrap(name, callback, priority)
end
end
-- each link runs under pcall: a throwing wrapper is logged and skipped and
-- the chain continues with the current arguments, so a broken mod degrades
-- to "not installed for this call" instead of breaking the pipeline.
-- vanilla must run at most once per call -- it has side effects -- so a link
-- that throws after its next() returned keeps the downstream results (its
-- post-processing is discarded) rather than re-walking the chain, and a link
-- that swallowed a vanilla error then threw propagates instead of retrying
function Hooks:call(name, vanilla, ...)
local chain = self.chains[name] or {}
local args = { ... }
local function run(index, current)
if index > #chain then return current(unpack(args)) end
return chain[index].callback(function(...)
local nextArgs = { ... }
if #nextArgs == 0 then return run(index + 1, current) end
local old = args
args = nextArgs
local result = run(index + 1, current)
args = old
return result
end, unpack(args))
local chain = self.chains[name]
if not chain or #chain == 0 then return vanilla(...) end
local args = pack(...)
local ranVanilla = false
local function run(index)
if index > #chain then
ranVanilla = true
local res = pack(pcall(vanilla, unpack(args, 1, args.n)))
if res[1] then return unpack(res, 2, res.n) end
error({ [PASS] = res[2] }, 0)
end
local entry = chain[index]
local downstream
local function nextFn(...)
if select("#", ...) == 0 then
downstream = pack(run(index + 1))
else
local saved = args
args = pack(...)
downstream = pack(run(index + 1))
args = saved
end
return unpack(downstream, 1, downstream.n)
end
local res = pack(pcall(entry.callback, nextFn, unpack(args, 1, args.n)))
if res[1] then return unpack(res, 2, res.n) end
local err = res[2]
if type(err) == "table" and err[PASS] ~= nil then error(err, 0) end
if downstream ~= nil then
Logger.warn("[%s] hook %s failed after next: %s -- downstream result kept",
tostring(entry.owner or "?"), name, tostring(err))
return unpack(downstream, 1, downstream.n)
end
if ranVanilla then
Logger.warn("[%s] hook %s failed: %s -- vanilla already ran, not retried",
tostring(entry.owner or "?"), name, tostring(err))
error({ [PASS] = err }, 0)
end
Logger.warn("[%s] hook %s failed: %s -- link skipped",
tostring(entry.owner or "?"), name, tostring(err))
return run(index + 1)
end
return run(1, vanilla)
local res = pack(pcall(run, 1))
if res[1] then return unpack(res, 2, res.n) end
local err = res[2]
if type(err) == "table" and err[PASS] ~= nil then error(err[PASS], 0) end
error(err, 0)
end
function Hooks:seal()
self.sealed = true
-- drops every wrap a mod made; used by entry-chunk rollback
function Hooks:removeOwner(owner)
if owner == nil then return end
for name, chain in pairs(self.chains) do
for i = #chain, 1, -1 do
if chain[i].owner == owner then table.remove(chain, i) end
end
if #chain == 0 then self.chains[name] = nil end
end
end
-- deprecated no-op: wrapping stays legal for the life of the process
function Hooks:seal() end
return Hooks
+837 -92
View File
File diff suppressed because it is too large Load Diff
+1012 -143
View File
File diff suppressed because it is too large Load Diff
+94
View File
@@ -1,11 +1,55 @@
-- Manifest v2: a strict superset of v1, so every shipped v1 manifest stays
-- valid. Pure (no filesystem): the loader's validate phase owns the checks
-- that need to stat a file, this owns shape, vocabulary and range grammar.
local Logger = require("src.core.Logger")
local Semver = require("src.mods.Semver")
local Version = require("src.core.Version")
local Manifest = {}
Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true }
Manifest.PERMISSIONS = { network = true, filesystem = true, engine_internals = true }
-- link-relevant registries; a mod that writes into one of these while
-- declaring affects_link = false gets an attributed warning from the loader
Manifest.LINK_REGISTRIES = {
pokemon = true, moves = true, type_chart = true,
statuses = true, move_effects = true,
}
local function array(value)
if value == nil then return {} end
assert(type(value) == "table", "manifest arrays must be tables")
return value
end
-- api 2 treats vocabulary violations as load errors; api 1 keeps loading and
-- gets an attributed warning so v1 mods never break on a field they predate
local function violation(strict, id, message)
if strict then error(message, 0) end
Logger.warn("[%s] %s", tostring(id), message)
end
-- "id" or "id@<range>"; a malformed id or range fails for every api level
-- because there is no sane fallback reading for it
local function parseSpecs(list, field)
local specs = {}
for _, entry in ipairs(list) do
assert(type(entry) == "string" and entry ~= "",
field .. " entries must be non-empty strings")
local id, range = entry:match("^([%w_%-]+)@(.+)$")
if not id then
id = entry:match("^([%w_%-]+)$")
assert(id, ("malformed %s entry %q"):format(field, entry))
range = nil
end
local ok, err = Semver.validRange(range)
assert(ok, ("malformed %s range in %q: %s"):format(field, entry, tostring(err)))
specs[#specs + 1] = { id = id, range = range }
end
return specs
end
function Manifest.validate(raw, path)
assert(type(raw) == "table", "manifest must be an object")
assert(type(raw.id) == "string" and raw.id:match("^[%w_%-]+$"),
@@ -13,18 +57,68 @@ function Manifest.validate(raw, path)
assert(type(raw.name) == "string" and raw.name ~= "", "manifest name is required")
assert(type(raw.version) == "string" and raw.version ~= "", "manifest version is required")
assert(type(raw.entry) == "string" and raw.entry ~= "", "manifest entry is required")
-- absent means 1: full v1 compat, schema violations downgrade to warnings
assert(raw.api == nil or tonumber(raw.api) ~= nil, "manifest api must be a number")
local api = tonumber(raw.api) or 1
assert(api >= 1 and api % 1 == 0, "manifest api must be a positive integer")
assert(api <= Version.modApi, ("requires mod API %d; this engine provides %d")
:format(api, Version.modApi))
local strict = api >= 2
local profile = raw.profile or "content"
if not Manifest.PROFILES[profile] then
violation(strict, raw.id, ("unknown profile %q"):format(tostring(profile)))
profile = "content"
end
local permissions, permissionSet = {}, {}
for _, name in ipairs(array(raw.permissions)) do
if Manifest.PERMISSIONS[name] then
permissions[#permissions + 1] = name
permissionSet[name] = true
else
violation(strict, raw.id, ("unknown permission %q"):format(tostring(name)))
end
end
local gameVersionOk, gameVersionErr = Semver.validRange(raw.game_version)
assert(gameVersionOk, ("malformed game_version %q: %s")
:format(tostring(raw.game_version), tostring(gameVersionErr)))
-- overhauls and total conversions are assumed to move the link
-- fingerprint unless the manifest says otherwise; content packs are not
local affectsLink = profile ~= "content"
if type(raw.affects_link) == "boolean" then affectsLink = raw.affects_link end
local function optionalFile(value, field)
if value == nil then return nil end
assert(type(value) == "string" and value ~= "", field .. " must be a file path")
return value
end
return {
id = raw.id,
name = raw.name,
version = raw.version,
entry = raw.entry,
api = api,
priority = tonumber(raw.priority) or 0,
dependencies = array(raw.dependencies),
optional_dependencies = array(raw.optional_dependencies),
conflicts = array(raw.conflicts),
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies"),
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies"),
conflictSpecs = parseSpecs(array(raw.conflicts), "conflicts"),
category = raw.category or "OTHER",
game_version = raw.game_version,
description = raw.description or "",
profile = profile,
affects_link = affectsLink,
permissions = permissions,
permissionSet = permissionSet,
options_schema = optionalFile(raw.options_schema, "options_schema"),
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
path = path,
raw = raw,
}
+128
View File
@@ -0,0 +1,128 @@
-- Deep-merge engine shared by Registry:patch, the deep registries, and the
-- save-migration runner. Pure Lua, no love.*, so the headless loader and
-- offline tools can require it.
local Logger = require("src.core.Logger")
local Merge = {}
-- patch payloads carry this where a key must be unset; mods reach it as
-- mod.DELETE (assigning nil into a patch table would simply omit the key)
Merge.DELETE = setmetatable({}, { __tostring = function() return "<DELETE>" end })
-- arrays are contiguous 1..n; empty tables count as dictionaries so a bare
-- {} patch is a no-op instead of wiping the target list
local function isArray(t)
local n = 0
for k in pairs(t) do
if type(k) ~= "number" then return false end
n = n + 1
end
if n == 0 then return false end
for i = 1, n do
if t[i] == nil then return false end
end
return true
end
Merge.isArray = isArray
-- the documented list-extension wrappers; a mod writes
-- { __append = {row} } where a bare list would replace, or __prepend to
-- reach the front, and the wrapper is unwrapped so it never reaches Data
local function isWrapper(t)
return type(t) == "table" and (t.__append ~= nil or t.__prepend ~= nil)
end
Merge.isWrapper = isWrapper
local function extend(dst, src)
if type(dst) ~= "table" then dst = {} end
local rows = src.__prepend
if type(rows) == "table" then
for i = #rows, 1, -1 do table.insert(dst, 1, Merge.deepCopy(rows[i])) end
end
rows = src.__append
if type(rows) == "table" then
for _, element in ipairs(rows) do dst[#dst + 1] = Merge.deepCopy(element) end
end
return dst
end
-- deep registries accumulate lists so two mods adding rows to the same key
-- both land; a list arriving over a dictionary is still a shape clash
local function concat(dst, src, key)
if type(dst) ~= "table" or (next(dst) ~= nil and not isArray(dst)) then
if dst ~= nil then
Logger.warn("merge: %slist replaces %s", key and (tostring(key) .. ": ") or "",
type(dst) == "table" and "dictionary" or type(dst))
end
return Merge.deepCopy(src)
end
for _, element in ipairs(src) do dst[#dst + 1] = Merge.deepCopy(element) end
return dst
end
function Merge.deepCopy(value, seen)
if type(value) ~= "table" or value == Merge.DELETE then return value end
seen = seen or {}
if seen[value] then return seen[value] end
local copy = {}
seen[value] = copy
for k, v in pairs(value) do copy[k] = Merge.deepCopy(v, seen) end
return copy
end
-- dst is mutated and returned. Dictionaries merge per key; DELETE unsets;
-- a table/non-table shape clash replaces with a warning so a typo'd patch
-- stays visible instead of silently nesting. Arrays replace wholesale and
-- extend only through the __append/__prepend wrappers, except under "deep"
-- semantics, where lists append so two mods adding rows to one key both
-- land; there override is the verb that drops a list
function Merge.deepMerge(dst, src, semantics)
if type(src) ~= "table" or src == Merge.DELETE then return src end
-- an extension wrapper builds the list even where there was none, so it
-- is resolved before the shape-clash guard below
if isWrapper(src) then return extend(dst, src) end
if type(dst) ~= "table" then
if dst ~= nil then
Logger.warn("merge: table replaces non-table value")
end
return Merge.deepCopy(src)
end
-- a whole-list payload takes the same rule the per-key branch below
-- applies one level down
if isArray(src) then
if semantics == "deep" then return concat(dst, src) end
return Merge.deepCopy(src)
end
for key, value in pairs(src) do
if value == Merge.DELETE then
dst[key] = nil
elseif type(value) == "table" then
if isWrapper(value) then
dst[key] = extend(dst[key], value)
elseif isArray(value) then
if semantics == "deep" then
dst[key] = concat(dst[key], value, key)
else
dst[key] = Merge.deepCopy(value)
end
elseif type(dst[key]) == "table" then
Merge.deepMerge(dst[key], value, semantics)
else
if dst[key] ~= nil then
Logger.warn("merge: %s: table replaces %s", tostring(key), type(dst[key]))
end
dst[key] = Merge.deepCopy(value)
end
else
if type(dst[key]) == "table" then
Logger.warn("merge: %s: %s replaces table", tostring(key), type(value))
end
dst[key] = value
end
end
return dst
end
return Merge
+251 -15
View File
@@ -1,38 +1,274 @@
-- Ordered, namespaced registries used by the native mod API.
-- Mods register definitions here; the loader merges them into the live data
-- only after every enabled mod has initialized successfully.
-- Ordered, namespaced content registries used by the native mod API.
-- Each registry stores an op log per id (register/override/patch/remove)
-- folded over the base record at read/merge time, so patches stack across
-- mods in load order and undoing a failed mod is just dropping its ops.
-- The loader merges effective values into the live data only after every
-- enabled mod has initialized successfully.
local Merge = require("src.mods.Merge")
local Registry = {}
Registry.__index = Registry
function Registry.new(name)
return setmetatable({ name = name, values = {}, owners = {} }, Registry)
-- exposed to mods as mod.DELETE: a patch value that unsets a field
Registry.DELETE = Merge.DELETE
-- spec comes from Schemas.REGISTRIES[name]; bare Registry.new(name) keeps
-- the v1 record behavior for standalone use in tests and tools
function Registry.new(name, spec)
return setmetatable({
name = name,
spec = spec or { semantics = "record" },
ops = {}, -- id -> ordered { op, value, owner }
owners = {}, -- id -> last-writing owner (provenance for errors)
order = {}, -- ids in first-touch order, for array-rebuilding targets
seen = {}, -- id -> true, keeps order free of duplicates
cache = {}, -- id -> { value } memoized fold
base = nil, -- installed by the loader: fn() -> base table or nil
frozen = false,
}, Registry)
end
function Registry:register(id, value, owner, replace)
assert(type(id) == "string" and id ~= "", self.name .. " id is required")
assert(value ~= nil, self.name .. " value is required for " .. id)
if self.values[id] ~= nil and not replace then
error(("%s already registered: %s"):format(self.name, id))
local function append(self, id, op, value, owner)
if self.frozen then
error(self.name .. ": content is frozen after load")
end
self.values[id] = value
assert(type(id) == "string" and id ~= "", self.name .. " id is required")
local list = self.ops[id]
if not list then
list = {}
self.ops[id] = list
end
-- a rolled-back id keeps its slot: order is registration history, not a
-- live key set, so a resurrected id stays where it first appeared
if not self.seen[id] then
self.seen[id] = true
self.order[#self.order + 1] = id
end
list[#list + 1] = { op = op, value = value, owner = owner }
self.owners[id] = owner
self.cache[id] = nil
return value
end
-- spec.baseAt lets a registry whose ids do not map one-to-one onto target
-- keys (battle_anims routes by id prefix) resolve its own pristine value
local function baseValue(self, id)
local base = self.base and self.base()
if base == nil then return nil end
if self.spec.baseAt then return self.spec.baseAt(base, id) end
return base[id]
end
-- effective value = base plus the op list; a tombstone folds to nil and a
-- later register may resurrect the id
local function fold(self, value, opList)
local deep = self.spec.semantics == "deep"
for _, entry in ipairs(opList or {}) do
local op = entry.op
-- a payload that IS the sentinel folds as a delete, never a value;
-- without this the bare DELETE table would leak into Data as a record
if entry.value == Merge.DELETE then
value = nil
elseif op == "override" or (op == "register" and not deep) then
value = entry.value
elseif op == "register" or op == "patch" then
-- deep registries treat register and patch alike; scalar payloads
-- (a lone top-level value) replace outright
if type(entry.value) == "table" then
value = Merge.deepMerge(Merge.deepCopy(value == nil and {} or value),
entry.value, self.spec.semantics)
else
value = entry.value
end
elseif op == "remove" then
value = nil
end
end
return value
end
function Registry:register(id, value, owner, replace)
if replace then return self:override(id, value, owner) end -- v1 signature
assert(value ~= nil, self.name .. " value is required for " .. tostring(id))
-- duplicates collide against the base table too, forcing an explicit
-- override; compose chains accumulate and deep keys merge instead
if self.spec.semantics == "record" and self:get(id) ~= nil then
error(("%s already registered: %s"):format(self.name, id))
end
return append(self, id, "register", value, owner)
end
function Registry:override(id, value, owner)
return self:register(id, value, owner, true)
assert(value ~= nil, self.name .. " value is required for " .. tostring(id))
return append(self, id, "override", value, owner)
end
function Registry:patch(id, partial, owner)
assert(partial ~= nil, self.name .. " patch value is required for " .. tostring(id))
if self.spec.semantics == "compose" then
error(self.name .. ": patch is not supported on compose registries")
end
return append(self, id, "patch", partial, owner)
end
-- tombstone: consumers treat the id as absent after the merge
function Registry:remove(id, owner)
return append(self, id, "remove", nil, owner)
end
function Registry:get(id)
return self.values[id]
if self.spec.semantics == "compose" then
-- chain() sorts top priority first, so the head is the effective value
local chain = self:chain(id)
return chain[1]
end
local hit = self.cache[id]
if hit then return hit.value end
local value = fold(self, baseValue(self, id), self.ops[id])
self.cache[id] = { value = value }
return value
end
function Registry:has(id)
return self.values[id] ~= nil
return self:get(id) ~= nil
end
-- compose fold: the ordered entry list for an id. Override is the
-- total-conversion escape hatch (09 4.4) -- it clears the whole chain, every
-- owner's entries alike, and installs itself as the only contribution;
-- remove tombstones the whole entry the same way but installs nothing.
-- Order is priority (higher first) then registration order. The second
-- return says the chain was cleared, which is how a consumer holding an
-- out-of-band base contribution (MapScripts) knows to leave it out.
local function composed(self, id)
local entries, replacesBase = {}, false
for seq, entry in ipairs(self.ops[id] or {}) do
if entry.op == "register" then
entries[#entries + 1] = { value = entry.value, owner = entry.owner, seq = seq }
elseif entry.op == "override" then
for i = #entries, 1, -1 do entries[i] = nil end
entries[1] = { value = entry.value, owner = entry.owner, seq = seq }
replacesBase = true
elseif entry.op == "remove" then
-- owner-scoped removal would leave the consumer's own base
-- contribution standing, so the map would still dispatch; 09 4.4
-- makes remove a whole-entry tombstone. A later register still
-- resurrects the id, ops after this one survive the clear
for i = #entries, 1, -1 do entries[i] = nil end
replacesBase = true
end
end
table.sort(entries, function(a, b)
local pa = type(a.value) == "table" and a.value.priority or 0
local pb = type(b.value) == "table" and b.value.priority or 0
if pa ~= pb then return pa > pb end
return a.seq < b.seq
end)
return entries, replacesBase
end
-- compose only: the ordered value list for an id
function Registry:chain(id)
assert(self.spec.semantics == "compose",
self.name .. ": chain is compose-only")
local entries = composed(self, id)
local values = {}
for i = 1, #entries do values[i] = entries[i].value end
return values
end
-- chain()'s owners, index-aligned with its values: consumers that
-- attribute dispatch (map_scripts runner sources) read both sides of the
-- same fold
function Registry:chainOwners(id)
assert(self.spec.semantics == "compose",
self.name .. ": chainOwners is compose-only")
local entries = composed(self, id)
local owners = {}
for i = 1, #entries do owners[i] = entries[i].owner end
return owners
end
-- compose only: true once an override has cleared this id's chain, so a
-- consumer that keeps its own base contribution outside the registry
-- (MapScripts' engine scripts) knows the total conversion excluded it
function Registry:chainReplacesBase(id)
assert(self.spec.semantics == "compose",
self.name .. ": chainReplacesBase is compose-only")
local _, replacesBase = composed(self, id)
return replacesBase
end
-- iterator over the merged view: base ids first, then op-only ids;
-- tombstoned ids are skipped. No ordering guarantee.
function Registry:each()
local ids, seen = {}, {}
local base = self.base and self.base()
if base then
-- spec.baseIds names the ids hiding inside a structured target; without
-- it the target's own keys are the id space
if self.spec.baseIds then
for _, id in ipairs(self.spec.baseIds(base)) do
seen[id] = true
ids[#ids + 1] = id
end
else
for id in pairs(base) do
seen[id] = true
ids[#ids + 1] = id
end
end
end
for id in pairs(self.ops) do
if not seen[id] then ids[#ids + 1] = id end
end
local i = 0
return function()
while true do
i = i + 1
local id = ids[i]
if id == nil then return nil end
local value = self:get(id)
if value ~= nil then return id, value end
end
end
end
-- v1 compat: the values mods contributed, folded to their effective form
function Registry:items()
return self.values
local out = {}
for id in pairs(self.ops) do out[id] = self:get(id) end
return out
end
-- deletes every op an owner appended, in one pass; the loader calls this
-- before the merge so a failed mod leaves zero trace in Data
function Registry:rollback(owner)
if owner == nil then return end
for id, list in pairs(self.ops) do
local touched = false
for i = #list, 1, -1 do
if list[i].owner == owner then
table.remove(list, i)
touched = true
end
end
if touched then
if #list == 0 then
self.ops[id] = nil
self.owners[id] = nil
else
self.owners[id] = list[#list].owner
end
self.cache[id] = nil
end
end
end
-- set once the boot merge has run; unlike the event/hook buses, content
-- stays deterministic by refusing registration after that point
function Registry:freeze()
self.frozen = true
end
return Registry
+65
View File
@@ -0,0 +1,65 @@
-- Process-wide access to the mod event/hook buses. Engine files require
-- this instead of threading the Game object through call sites; Loader:load
-- installs the live buses. Until then the null objects below make every
-- emit/call site a safe pass-through, so headless code paths and tools that
-- never run a loader need no guards.
local Runtime = {}
local NullEvents = {}
function NullEvents:emit() end
function NullEvents:removeOwner() end
local NullHooks = {}
function NullHooks:call(name, vanilla, ...) return vanilla(...) end
function NullHooks:removeOwner() end
Runtime.events = NullEvents
Runtime.hooks = NullHooks
-- the live loader's error list, lent out by install. Failures that only
-- surface long after the load phase -- a mod's audio def that first fails
-- when its cue fires -- have to land in the same feed the mod manager
-- reads, and nil here means nobody is collecting.
Runtime.errors = nil
-- id of the mod whose code is currently running, set by the loader around
-- every mod-authored frame; nil on engine paths, which is how the dev-mode
-- permissions tripwire knows there is nobody to attribute to
Runtime.currentMod = nil
function Runtime.install(events, hooks, errors)
Runtime.events, Runtime.hooks = events, hooks
Runtime.errors = errors
end
-- attribute a runtime failure to the mod that owns the offending record.
-- "base" is the engine's own owner id: a vanilla record that fails is a
-- console line, not something the manager can ask the player to disable.
function Runtime.reportError(modId, message)
local errors = Runtime.errors
if not errors or not modId or modId == "base" then return end
errors[#errors + 1] = tostring(modId) .. ": " .. tostring(message)
end
function Runtime.emit(name, payload)
Runtime.events:emit(name, payload)
end
function Runtime.call(name, vanilla, ...)
return Runtime.hooks:call(name, vanilla, ...)
end
-- fast guards so hot call sites can skip payload/ctx construction when
-- nothing is subscribed (the null objects have no listeners/chains tables)
function Runtime.wants(name)
local listeners = Runtime.events.listeners
return listeners ~= nil and listeners[name] ~= nil
end
function Runtime.wantsHook(name)
local chains = Runtime.hooks.chains
return chains ~= nil and chains[name] ~= nil
end
return Runtime
+1073
View File
File diff suppressed because it is too large Load Diff
+156
View File
@@ -0,0 +1,156 @@
-- Semantic versions and the range grammar manifests use for game_version
-- and for dependency/conflict pins. No requires, so the headless loader,
-- the doc generator and tools all match on the same implementation.
--
-- Ranges: comparators = > >= < <= ^ (bare version means =), space-separated
-- comparators AND together, || separates alternatives.
local Semver = {}
-- "1", "1.2", "1.2.3", "1.2.3-beta.1"; absent components are 0 and build
-- metadata is parsed then discarded. nil for anything unparsable -- mod
-- versions stay free-form strings, only range checks need a parse.
function Semver.parse(text)
if type(text) ~= "string" then return nil end
local body = text:match("^%s*(.-)%s*$"):gsub("^[vV]", "")
local plus = body:find("+", 1, true)
if plus then body = body:sub(1, plus - 1) end
local core, pre = body:match("^([^%-]+)%-?(.*)$")
if not core then return nil end
if core:match("[^%d%.]") or core:match("^%.") or core:match("%.$")
or core:find("..", 1, true) then
return nil
end
local nums = {}
for part in core:gmatch("[^%.]+") do nums[#nums + 1] = tonumber(part) end
if #nums == 0 or #nums > 3 then return nil end
if pre == "" then
pre = nil
elseif not pre:match("^[%w%.%-]+$") then
return nil
end
return { major = nums[1], minor = nums[2] or 0, patch = nums[3] or 0, pre = pre }
end
-- SemVer 2.0 pre-release precedence: a release outranks its pre-releases,
-- numeric identifiers compare numerically and rank below alphanumeric ones,
-- and a longer identifier list wins when every shared field is equal
local function comparePre(a, b)
if a == b then return 0 end
if a == nil then return 1 end
if b == nil then return -1 end
local left, right = {}, {}
for part in a:gmatch("[^%.]+") do left[#left + 1] = part end
for part in b:gmatch("[^%.]+") do right[#right + 1] = part end
local count = #left > #right and #left or #right
for i = 1, count do
local x, y = left[i], right[i]
if x == nil then return -1 end
if y == nil then return 1 end
local nx, ny = tonumber(x), tonumber(y)
if nx and ny then
if nx ~= ny then return nx < ny and -1 or 1 end
elseif nx then
return -1
elseif ny then
return 1
elseif x ~= y then
return x < y and -1 or 1
end
end
return 0
end
-- accepts strings or already-parsed tables; nil when either side is unparsable
function Semver.compare(a, b)
local va = type(a) == "table" and a or Semver.parse(a)
local vb = type(b) == "table" and b or Semver.parse(b)
if not va or not vb then return nil end
for _, field in ipairs({ "major", "minor", "patch" }) do
local x, y = va[field] or 0, vb[field] or 0
if x ~= y then return x < y and -1 or 1 end
end
return comparePre(va.pre, vb.pre)
end
-- ------- ranges
local OPS = {
["="] = true, ["=="] = true, [">"] = true, [">="] = true,
["<"] = true, ["<="] = true, ["^"] = true,
}
-- ^ pins the leftmost non-zero component: ^1.2 is >=1.2 <2.0, ^0.2 is
-- >=0.2 <0.3, ^0.0.3 is >=0.0.3 <0.0.4
local function caretUpper(v)
if v.major > 0 then return { major = v.major + 1, minor = 0, patch = 0 } end
if v.minor > 0 then return { major = 0, minor = v.minor + 1, patch = 0 } end
return { major = 0, minor = 0, patch = v.patch + 1 }
end
local function matchToken(version, token)
local op, rest = token:match("^([=<>%^]*)(.*)$")
if op == "" then op = "=" end
if not OPS[op] then
return nil, ("unknown comparator %q in range"):format(op)
end
local target = Semver.parse(rest)
if not target then
return nil, ("unparsable version %q in range"):format(rest)
end
local order = Semver.compare(version, target)
if op == "=" or op == "==" then return order == 0 end
if op == ">" then return order > 0 end
if op == ">=" then return order >= 0 end
if op == "<" then return order < 0 end
if op == "<=" then return order <= 0 end
return order >= 0 and Semver.compare(version, caretUpper(target)) < 0
end
-- true only when every space-separated comparator in one alternative holds
local function matchAlternative(version, alternative)
local tokens = 0
local ok = true
for token in alternative:gmatch("%S+") do
tokens = tokens + 1
local hit, err = matchToken(version, token)
if err then return nil, err end
if not hit then ok = false end
end
if tokens == 0 then return nil, "empty range alternative" end
return ok
end
-- returns false with no reason for a clean miss and false plus a reason for
-- an unparsable version or a malformed range; callers turn the reason into a
-- load error (api 2) or a warning (api 1)
function Semver.satisfies(version, range)
local parsed = Semver.parse(version)
if not parsed then
return false, ("unparsable version %q"):format(tostring(version))
end
if range == nil or range == "" then return true end
if type(range) ~= "string" then return false, "range must be a string" end
local matched = false
local rest = range
while true do
local head, tail = rest:match("^(.-)||(.*)$")
local alternative = head or rest
local ok, err = matchAlternative(parsed, alternative)
if err then return false, err end
matched = matched or ok
if not tail then break end
rest = tail
end
return matched
end
-- grammar-only check for manifest validation, where no version is in hand yet
function Semver.validRange(range)
if range == nil or range == "" then return true end
local _, err = Semver.satisfies("0.0.0", range)
if err then return false, err end
return true
end
return Semver