From 02024fef580a821a768647114eff39e01937e3c5 Mon Sep 17 00:00:00 2001 From: david Date: Mon, 10 Aug 2026 17:33:19 -0700 Subject: [PATCH 1/2] Export versioned mod option schemas --- docs/mod-option-schema.md | 36 +++++++++++++++ src/mods/Loader.lua | 40 +++++++++++++++++ tests/mod_loader_tests.lua | 89 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 docs/mod-option-schema.md diff --git a/docs/mod-option-schema.md b/docs/mod-option-schema.md new file mode 100644 index 00000000..4df61fa3 --- /dev/null +++ b/docs/mod-option-schema.md @@ -0,0 +1,36 @@ +# Mod option schema export + +`mod_option_schemas.json` is an optional runtime snapshot written beside +`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 +untrusted mod entry code before boot. + +Version 1 has this shape: + +```json +{ + "schema_version": 1, + "mods": { + "example": [ + {"key":"enabled","type":"toggle","label":"Enabled","default":true}, + {"key":"mode","type":"choice","label":"Mode","default":"safe", + "choices":[["Safe","safe"],["Fast","fast"]]}, + {"key":"rate","type":"number","label":"Rate","default":5, + "min":0,"max":10,"step":1}, + {"key":"name","type":"text","label":"Name","default":"","maxLen":12} + ] + } +} +``` + +Only enabled, successfully loaded mods are included. A boot with no schemas +writes `{"schema_version":1,"mods":{}}` when an older snapshot exists, so a +disabled or failed mod cannot leave stale settings rows behind. A filesystem +that cannot write is tolerated, and a fresh mod-free boot does not create the +file. + +The supported row types are `toggle`, `choice`, `number`, and `text`. Native +consumers may ignore unknown future row types. Consumers must accept a +missing `schema_version` as legacy version 1 and ignore newer versions rather +than guessing at their shape. Producers must bump the version when changing +the document shape. diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index fa6f6a34..a424c85b 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -19,6 +19,8 @@ local Loader = {} Loader.__index = Loader local MOD_STATE_FILE = "mod_state.lua" -- legacy migration only +local OPTION_SCHEMAS_FILENAME = "mod_option_schemas.json" +local OPTION_SCHEMAS_VERSION = 1 -- walk a dotted target path without creating anything; the base view a -- registry folds against must never perturb Data on a mod-free boot @@ -191,6 +193,43 @@ function Loader:_saveState() SaveData.saveOptions(options, self.fs) end +-- Export the runtime option schemas after mod entry chunks have run. This +-- is an optional, data-only handoff for native launchers: they must not run +-- arbitrary mod code before boot just to discover settings. The snapshot is +-- deliberately written beside options.lua so every platform's native shell +-- can use the same filesystem contract. +function Loader:_writeOptionSchemas() + if not self.fs.write then return end + + local mods = {} + for id, schema in pairs(self.optionSchemas) do + if self.mods[id] and self.mods[id].enabled and not self.mods[id].failed then + mods[id] = schema + end + end + + -- Do not create storage on a fresh mod-free boot, but do overwrite an old + -- snapshot when the current boot has no schemas so disabled/failed mods do + -- not leave stale native settings rows behind. + if next(mods) == nil + and not (self.fs.getInfo and self.fs.getInfo(OPTION_SCHEMAS_FILENAME)) then + return + end + + local ok, encoded = pcall(Json.encode, { + schema_version = OPTION_SCHEMAS_VERSION, + mods = mods, + }) + if not ok then + Logger.warn("mod option schema export: failed to encode: %s", tostring(encoded)) + return + end + local written, err = self.fs.write(OPTION_SCHEMAS_FILENAME, encoded) + if not written then + Logger.warn("mod option schema export: failed to write: %s", tostring(err)) + end +end + function Loader:setEnabled(id, enabled) if not self.mods[id] then return false end self.disabled[id] = not enabled @@ -1089,6 +1128,7 @@ function Loader:load(data) -- which resolves every path to itself (14 §asset resolution). Assets.installLoader(self) self.events:emit("mods.loaded", { loader = self, data = data }) + self:_writeOptionSchemas() self.initialized = true return #self.errors == 0 end diff --git a/tests/mod_loader_tests.lua b/tests/mod_loader_tests.lua index e14d2b78..e1d58a3f 100644 --- a/tests/mod_loader_tests.lua +++ b/tests/mod_loader_tests.lua @@ -303,6 +303,95 @@ do "with the env var unset, the saved disable is left alone") end +-- ------- runtime option schema export +-- The optional native-launcher contract is written only after enabled mods +-- have successfully run, and stale snapshots are cleared when the load set +-- no longer contains schema-bearing mods. +do + local Json = require("src.link.Json") + local schemaFiles = { + ["options.lua"] = "return { mods = { quiet = false } }", + ["mods/loud/manifest.json"] = manifestJson("loud"), + ["mods/loud/main.lua"] = [[ +return function(mod) + mod.options:define({ + { key = "hardcore", type = "toggle", label = "Hardcore", default = false }, + { key = "difficulty", type = "choice", label = "Difficulty", default = "normal", + choices = { { "Easy", "easy" }, { "Normal", "normal" } } }, + { key = "rate", type = "number", label = "Rate", default = 10, + min = 0, max = 100, step = 5 }, + { key = "nickname", type = "text", label = "Nickname", default = "", maxLen = 7 }, + }) +end +]], + ["mods/quiet/manifest.json"] = manifestJson("quiet"), + ["mods/quiet/main.lua"] = [[ +return function(mod) + mod.options:define({ { key = "shh", type = "toggle", default = true } }) +end +]], + } + local writes = {} + local fs = memfs(schemaFiles) + fs.write = function(path, contents) + writes[path] = contents + schemaFiles[path] = contents + return true + end + + local loader = Loader.new({ fs = fs }) + check(loader:load({ pokemon = {} }) == true, + "schema export fixture boots clean") + local decoded = writes["mod_option_schemas.json"] + and Json.decode(writes["mod_option_schemas.json"]) + check(decoded and decoded.schema_version == 1, + "schema export has an explicit version") + check(decoded and decoded.mods and decoded.mods.loud ~= nil, + "enabled mod schema is exported") + check(decoded and decoded.mods and decoded.mods.quiet == nil, + "disabled mod schema is not exported") + local rows = decoded and decoded.mods.loud or {} + local byKey = {} + for _, row in ipairs(rows) do byKey[row.key] = row end + check(byKey.hardcore and byKey.hardcore.type == "toggle", + "toggle row round-trips") + check(byKey.difficulty and byKey.difficulty.choices + and byKey.difficulty.choices[1][1] == "Easy" + and byKey.difficulty.choices[1][2] == "easy", + "choice row round-trips") + check(byKey.rate and byKey.rate.min == 0 and byKey.rate.max == 100 + and byKey.rate.step == 5, "number bounds round-trip") + check(byKey.nickname and byKey.nickname.maxLen == 7, + "text length round-trips") + + local readOnlyLoader = Loader.new({ fs = memfs(schemaFiles) }) + check(readOnlyLoader:load({ pokemon = {} }) == true, + "read-only filesystems tolerate schema export") + + -- A schema captured before an entry failure is rolled back and must not + -- leak into the native snapshot. + schemaFiles["mods/broken/manifest.json"] = manifestJson("broken") + schemaFiles["mods/broken/main.lua"] = [[ +return function(mod) + mod.options:define({ { key = "ghost", type = "toggle", default = true } }) + error("broken entry") +end +]] + local failedLoader = Loader.new({ fs = fs }) + check(failedLoader:load({ pokemon = {} }) == false, + "a failing entry is reported") + local afterFailure = Json.decode(writes["mod_option_schemas.json"]) + check(afterFailure and afterFailure.mods and afterFailure.mods.broken == nil, + "a failed mod schema is not exported") + + loader:setEnabled("loud", false) + loader:_writeOptionSchemas() + local cleared = Json.decode(writes["mod_option_schemas.json"]) + check(cleared and cleared.schema_version == 1 and next(cleared.mods) == nil, + "disabling the only schema-bearing mod clears the snapshot") + +end + -- leave shared singletons the way we found them for later chained tests local StateStack = require("src.core.StateStack") while StateStack:top() do StateStack:pop() end From 798f3c25c0cb3c9827258c6c40baac5b300f3ad7 Mon Sep 17 00:00:00 2001 From: david Date: Mon, 10 Aug 2026 20:23:01 -0700 Subject: [PATCH 2/2] Export legacy mod option schemas --- docs/mod-option-schema.md | 85 +++++++++++++++++++++++++++++++------- src/mods/Loader.lua | 20 +++++++-- tests/mod_loader_tests.lua | 14 +++++++ 3 files changed, 101 insertions(+), 18 deletions(-) diff --git a/docs/mod-option-schema.md b/docs/mod-option-schema.md index 4df61fa3..d111a856 100644 --- a/docs/mod-option-schema.md +++ b/docs/mod-option-schema.md @@ -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 -`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 -untrusted mod entry code before boot. +## Status + +Proposed. Engine: `src/mods/Loader.lua`. Tests: +`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: @@ -23,14 +40,52 @@ Version 1 has this shape: } ``` -Only enabled, successfully loaded mods are included. A boot with no schemas -writes `{"schema_version":1,"mods":{}}` when an older snapshot exists, so a -disabled or failed mod cannot leave stale settings rows behind. A filesystem -that cannot write is tolerated, and a fresh mod-free boot does not create the -file. +`mods` is keyed by mod id. Its rows come from the runtime +`mod.options:define` schema, or from the legacy manifest `options_schema` file +when the runtime schema is absent. The supported row types are `toggle`, +`choice`, `number`, and `text`. Their optional fields retain the meanings +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 -consumers may ignore unknown future row types. Consumers must accept a -missing `schema_version` as legacy version 1 and ignore newer versions rather -than guessing at their shape. Producers must bump the version when changing -the document shape. +Only mods that are enabled and successfully loaded in the current boot are +included. A disabled or failed mod must not contribute rows. If an older +snapshot exists and the current boot has no schema-bearing mods, the producer +overwrites it with `{"schema_version":1,"mods":{}}`; this prevents stale +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. diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index a424c85b..fd1a96e9 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -202,9 +202,23 @@ function Loader:_writeOptionSchemas() if not self.fs.write then return end local mods = {} - for id, schema in pairs(self.optionSchemas) do - if self.mods[id] and self.mods[id].enabled and not self.mods[id].failed then - mods[id] = schema + for id, mod in pairs(self.mods) do + if mod.enabled and not mod.failed then + 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 diff --git a/tests/mod_loader_tests.lua b/tests/mod_loader_tests.lua index e1d58a3f..bdbf0784 100644 --- a/tests/mod_loader_tests.lua +++ b/tests/mod_loader_tests.lua @@ -329,6 +329,16 @@ end return function(mod) mod.options:define({ { key = "shh", type = "toggle", default = true } }) 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 = {} @@ -350,6 +360,9 @@ end "enabled mod schema is exported") check(decoded and decoded.mods and decoded.mods.quiet == nil, "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 byKey = {} for _, row in ipairs(rows) do byKey[row.key] = row end @@ -385,6 +398,7 @@ end "a failed mod schema is not exported") loader:setEnabled("loud", false) + loader:setEnabled("legacy", false) loader:_writeOptionSchemas() local cleared = Json.decode(writes["mod_option_schemas.json"]) check(cleared and cleared.schema_version == 1 and next(cleared.mods) == nil,