CLOSES #785, CLOSES #807, CLOSES #811, CLOSES #814

This commit is contained in:
bryanthaboi
2026-08-04 15:13:06 -04:00
parent 8fbe819493
commit 0fa8206321
15 changed files with 862 additions and 37 deletions
+79
View File
@@ -138,6 +138,7 @@ function Loader.new(opts)
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
exports = {}, migrations = {}, order = {},
modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {},
modInput = {},
fs = (opts and opts.fs) or (love and love.filesystem),
dev = dev,
}, Loader)
@@ -527,6 +528,44 @@ function Loader:_registerCommand(modId, verb, fn)
return self.content.commands:register(verb, fn, modId)
end
-- the GB buttons mod.input may drive (#807)
local GB_BUTTONS = {
up = true, down = true, left = true, right = true,
a = true, b = true, start = true, select = true,
}
-- per-mod mod.input ledger (#807): seq numbers this mod's Input sources,
-- tokens maps each opaque press token to what release must undo. Living
-- on the loader (not the api closure) is what lets rollback, hot reload
-- and input recovery retire a mod's holds from outside the mod's own code.
function Loader:_modInput(modId)
local bucket = self.modInput[modId]
if not bucket then
bucket = { seq = 0, tokens = {} }
self.modInput[modId] = bucket
end
return bucket
end
-- Release every outstanding mod.input hold: one mod's on entry-chunk
-- rollback, everyone's (no argument) on hot reload and input recovery
-- (#807). When Input:reset already dropped the sources these releases
-- are no-ops; the point is the stale tokens die with the code that took
-- them, so a later mod.input:release on one is refused instead of
-- touching a button someone else now holds.
function Loader:releaseModInput(modId)
if modId == nil then
for id in pairs(self.modInput) do self:releaseModInput(id) end
return
end
local bucket = self.modInput[modId]
if not bucket then return end
self.modInput[modId] = nil
for _, rec in pairs(bucket.tokens) do
rec.input:sourceRelease(rec.btn, rec.source)
end
end
function Loader:_api(mod)
local loader = self
local modId = mod.manifest.id
@@ -559,6 +598,45 @@ function Loader:_api(mod)
hooks = { wrap = function(_, name, callback, priority)
return loader.hooks:wrap(name, callback, priority, modId)
end },
-- source-safe scripted GB input (#807): tap queues exactly one
-- wasPressed edge for the next fixed step with no held state; press
-- holds until release. Every call is its own "mod:<id>:<n>" source in
-- game.input, so releasing a token can never drop a button the
-- keyboard, a pad, the touch overlay, or another mod still holds.
input = {
tap = function(_, game, btn)
local input = game and game.input
assert(input, "mod.input needs the live game (see game.ready)")
assert(GB_BUTTONS[btn], "unknown GB button: " .. tostring(btn))
local bucket = loader:_modInput(modId)
bucket.seq = bucket.seq + 1
local source = "mod:" .. modId .. ":" .. bucket.seq
input:sourcePress(btn, source)
input:sourceRelease(btn, source)
end,
press = function(_, game, btn)
local input = game and game.input
assert(input, "mod.input needs the live game (see game.ready)")
assert(GB_BUTTONS[btn], "unknown GB button: " .. tostring(btn))
local bucket = loader:_modInput(modId)
bucket.seq = bucket.seq + 1
local source = "mod:" .. modId .. ":" .. bucket.seq
input:sourcePress(btn, source)
local token = {}
bucket.tokens[token] = { input = input, btn = btn, source = source }
return token
end,
-- idempotent, and a token another mod took is simply not in this
-- ledger, so cross-mod release is refused by construction
release = function(_, token)
local bucket = loader.modInput[modId]
local rec = bucket and bucket.tokens[token]
if not rec then return false end
bucket.tokens[token] = nil
rec.input:sourceRelease(rec.btn, rec.source)
return true
end,
},
-- the widget toolkit facade (12 4.5) is one shared surface, not
-- per-mod state; each widget inside it loads on first touch
ui = ModUI,
@@ -714,6 +792,7 @@ function Loader:_rollback(modId)
end
self.events:removeOwner(modId)
self.hooks:removeOwner(modId)
self:releaseModInput(modId)
self.exports[modId] = nil
self.optionSchemas[modId] = nil
self.migrations[modId] = nil