Merge pull request #1072 from dlloa/gen1recomp-issue-28

Export versioned mod option schemas for native launchers
This commit is contained in:
bryanthaboi
2026-08-11 21:24:57 -04:00
committed by GitHub
3 changed files with 248 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
# RFC 0008 — Runtime mod option schema export
## 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:
```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}
]
}
}
```
`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`.
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.
+54
View File
@@ -23,6 +23,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
-- The working tree's engine version is the "0.0.0-dev" placeholder that CI
-- restamps into the packed game.love (src/core/Version.lua:7), and it sorts
@@ -339,6 +341,57 @@ 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, 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
-- 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
@@ -1421,6 +1474,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
+103
View File
@@ -303,6 +303,109 @@ 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
]],
["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 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")
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
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: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,
"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