Export legacy mod option schemas

This commit is contained in:
david
2026-08-10 20:23:01 -07:00
parent 02024fef58
commit 798f3c25c0
3 changed files with 101 additions and 18 deletions
+70 -15
View File
@@ -1,9 +1,26 @@
# Mod option schema export # RFC 0008 — Runtime mod option schema export
`mod_option_schemas.json` is an optional runtime snapshot written beside ## Status
`options.lua` after the mod loader finishes. It gives a native launcher a
data-only description of mod settings without requiring the launcher to run Proposed. Engine: `src/mods/Loader.lua`. Tests:
untrusted mod entry code before boot. `tests/mod_loader_tests.lua`. This RFC defines an optional filesystem
contract; it does not require a native launcher or any other consumer.
## Motivation
A native launcher may want to present settings for installed mods before it
starts the game. Running every mod's entry chunk in that launcher just to
discover its settings would duplicate engine behavior and give the launcher
an unnecessary code-execution surface. The engine already has the authoritative
runtime schemas after mod loading, so it can publish a data-only snapshot for
platform shells that want one.
## The exact contract
After the mod loader has finished running entry chunks, it may write
`mod_option_schemas.json` beside `options.lua` in the same filesystem. The
document is a snapshot of the current boot; it is not a second settings store
and does not change how option values are read or written.
Version 1 has this shape: Version 1 has this shape:
@@ -23,14 +40,52 @@ Version 1 has this shape:
} }
``` ```
Only enabled, successfully loaded mods are included. A boot with no schemas `mods` is keyed by mod id. Its rows come from the runtime
writes `{"schema_version":1,"mods":{}}` when an older snapshot exists, so a `mod.options:define` schema, or from the legacy manifest `options_schema` file
disabled or failed mod cannot leave stale settings rows behind. A filesystem when the runtime schema is absent. The supported row types are `toggle`,
that cannot write is tolerated, and a fresh mod-free boot does not create the `choice`, `number`, and `text`. Their optional fields retain the meanings
file. established by the existing in-game option UI: choices are `[label, value]`
pairs, numeric rows may provide `min`, `max`, and `step`, and text rows may
provide `maxLen`.
The supported row types are `toggle`, `choice`, `number`, and `text`. Native Only mods that are enabled and successfully loaded in the current boot are
consumers may ignore unknown future row types. Consumers must accept a included. A disabled or failed mod must not contribute rows. If an older
missing `schema_version` as legacy version 1 and ignore newer versions rather snapshot exists and the current boot has no schema-bearing mods, the producer
than guessing at their shape. Producers must bump the version when changing overwrites it with `{"schema_version":1,"mods":{}}`; this prevents stale
the document shape. settings rows from surviving a disable or load failure. A fresh mod-free boot
does not create the file, and a filesystem without write support is tolerated.
The producer writes the snapshot after entry chunks and the final load set
have been established. Consumers must treat the file as untrusted input and
must not execute anything from it.
## Compatibility and versioning
The contract is optional on both sides. A native consumer may be absent, and
the engine continues normally if the file cannot be written. A native
consumer is not required to render, validate, or persist every supported row;
it may ignore an unknown row type or optional field.
For compatibility with files produced by the original unversioned prototype,
a missing `schema_version` means version 1. Consumers must ignore documents
with a newer version rather than guessing at their shape. Producers must bump
the version whenever they change the document shape or the meaning of an
existing field. Version 1 is therefore the legacy unversioned format as well
as the explicitly versioned format shown above.
## Migration note
Nothing. Existing mods, option values, and the in-game options UI are
unchanged. Platforms that do not consume `mod_option_schemas.json` have no
new integration requirement.
## Parity tests
`tests/mod_loader_tests.lua` verifies the explicit version, runtime and legacy
row round-tripping, enabled/disabled filtering, failed-mod filtering,
stale-snapshot clearing, and tolerance of a read-only filesystem.
## Deprecation etiquette
Nothing is deprecated. The unversioned file form remains readable as legacy
version 1; new producers write the explicit `schema_version` field.
+17 -3
View File
@@ -202,9 +202,23 @@ function Loader:_writeOptionSchemas()
if not self.fs.write then return end if not self.fs.write then return end
local mods = {} local mods = {}
for id, schema in pairs(self.optionSchemas) do for id, mod in pairs(self.mods) do
if self.mods[id] and self.mods[id].enabled and not self.mods[id].failed then if mod.enabled and not mod.failed then
mods[id] = schema local schema = self.optionSchemas[id]
-- Keep the legacy manifest options_schema path visible to native
-- consumers too. ManagerState loads this same data-only chunk on
-- demand; using it here means older mods do not need to migrate to
-- mod.options:define just to appear in a launcher settings screen.
if schema == nil and mod.manifest.options_schema and self.fs.load then
local chunk = self.fs.load(mod.path .. "/" .. mod.manifest.options_schema)
if chunk then
local ok, rows = pcall(chunk)
if ok and type(rows) == "table" then schema = rows end
end
end
if schema ~= nil then
mods[id] = schema
end
end end
end end
+14
View File
@@ -329,6 +329,16 @@ end
return function(mod) return function(mod)
mod.options:define({ { key = "shh", type = "toggle", default = true } }) mod.options:define({ { key = "shh", type = "toggle", default = true } })
end end
]],
["mods/legacy/manifest.json"] = [[
{"id":"legacy","name":"legacy","version":"1.0.0","entry":"main.lua",
"options_schema":"options.lua"}
]],
["mods/legacy/main.lua"] = "return function(mod) end",
["mods/legacy/options.lua"] = [[
return {
{ key = "legacy_toggle", type = "toggle", label = "Legacy", default = true },
}
]], ]],
} }
local writes = {} local writes = {}
@@ -350,6 +360,9 @@ end
"enabled mod schema is exported") "enabled mod schema is exported")
check(decoded and decoded.mods and decoded.mods.quiet == nil, check(decoded and decoded.mods and decoded.mods.quiet == nil,
"disabled mod schema is not exported") "disabled mod schema is not exported")
check(decoded and decoded.mods and decoded.mods.legacy
and decoded.mods.legacy[1].key == "legacy_toggle",
"manifest options_schema is exported")
local rows = decoded and decoded.mods.loud or {} local rows = decoded and decoded.mods.loud or {}
local byKey = {} local byKey = {}
for _, row in ipairs(rows) do byKey[row.key] = row end for _, row in ipairs(rows) do byKey[row.key] = row end
@@ -385,6 +398,7 @@ end
"a failed mod schema is not exported") "a failed mod schema is not exported")
loader:setEnabled("loud", false) loader:setEnabled("loud", false)
loader:setEnabled("legacy", false)
loader:_writeOptionSchemas() loader:_writeOptionSchemas()
local cleared = Json.decode(writes["mod_option_schemas.json"]) local cleared = Json.decode(writes["mod_option_schemas.json"])
check(cleared and cleared.schema_version == 1 and next(cleared.mods) == nil, check(cleared and cleared.schema_version == 1 and next(cleared.mods) == nil,