mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 11:11:10 +02:00
big ass modding update
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+1012
-143
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
Reference in New Issue
Block a user