mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
Merge pull request #1072 from dlloa/gen1recomp-issue-28
Export versioned mod option schemas for native launchers
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user