feat(update): gate payloads by native host family

This commit is contained in:
Codex Agent
2026-08-12 17:23:25 -05:00
parent c3136bf8f7
commit 0aab11b690
5 changed files with 122 additions and 16 deletions
+12 -1
View File
@@ -26,7 +26,7 @@ JSON parsing, and sha256 verification run on a background `love.thread`
## Version.lua fields ## Version.lua fields
`src/core/Version.lua` carries three fields the updater reads directly (the `src/core/Version.lua` carries four fields the updater reads directly (the
existing `modApi`, `linkProtocol`, `saveFormat`, and `cache` fields are existing `modApi`, `linkProtocol`, `saveFormat`, and `cache` fields are
untouched): untouched):
@@ -37,6 +37,11 @@ untouched):
as a valid payload to chainload). as a valid payload to chainload).
- `shell` - the native-shell contract this build's fused executable - `shell` - the native-shell contract this build's fused executable
implements. implements.
- `payloadHost` - the native host family an in-place payload targets. Ordinary
LÖVE packages use `"love"`. A specialized native package uses a distinct,
stable identifier and accepts only payloads carrying that same identifier.
A missing field defaults to `"love"`, preserving compatibility with payloads
released before this field existed.
- `minShell` - the lowest shell contract required to *run* this payload. - `minShell` - the lowest shell contract required to *run* this payload.
Bump `minShell` only when a payload needs something the currently-shipped Bump `minShell` only when a payload needs something the currently-shipped
@@ -49,6 +54,12 @@ rather than deleting it, in case a future shell upgrade can run it, and
installer instead. Do not bump `minShell` for an ordinary Lua/data release; installer instead. Do not bump `minShell` for an ordinary Lua/data release;
that is exactly the case the updater exists to avoid a reinstall for. that is exactly the case the updater exists to avoid a reinstall for.
Change `payloadHost` only when the packaged Lua depends on a different native
host family. This is separate from `minShell`: the host name answers *which*
native integration the payload targets, while the shell number answers *which
revision* of that integration it requires. A mismatched-host payload is never
mounted or deleted as stale; the launcher directs the player to a full package.
## Release assets ## Release assets
Each tagged release `vX.Y.Z` carries the existing per-platform archives Each tagged release `vX.Y.Z` carries the existing per-platform archives
+3
View File
@@ -8,6 +8,9 @@ local Version = {
-- "-dev" placeholder; CI stamps the real X.Y.Z into -- "-dev" placeholder; CI stamps the real X.Y.Z into
-- the packed game.love only, never the working tree. -- the packed game.love only, never the working tree.
shell = 1, -- native-shell contract this build implements shell = 1, -- native-shell contract this build implements
payloadHost = "love", -- native host family for in-place Lua payloads.
-- A payload must name the same family; this prevents
-- mounting code packaged for a different native host.
minShell = 1, -- lowest shell contract that can RUN this payload. minShell = 1, -- lowest shell contract that can RUN this payload.
-- Bump only when a payload needs a newer native -- Bump only when a payload needs a newer native
-- binary (e.g. a LOVE version bump); an older shell -- binary (e.g. a LOVE version bump); an older shell
+48 -13
View File
@@ -80,7 +80,8 @@ local function purgeBundledModules()
end end
end end
-- Boot.probePayload(rel) -> { engine = string, minShell = number } | nil, err -- Boot.probePayload(rel)
-- -> { engine = string, minShell = number, payloadHost = string } | nil, err
-- --
-- Mount the archive at rel (a save-directory-relative path) on an isolated -- Mount the archive at rel (a save-directory-relative path) on an isolated
-- mountpoint, read its src/core/Version.lua by executing the source with -- mountpoint, read its src/core/Version.lua by executing the source with
@@ -104,24 +105,50 @@ function Boot.probePayload(rel)
if type(v) ~= "table" or type(v.engine) ~= "string" then if type(v) ~= "table" or type(v.engine) ~= "string" then
return nil, "payload has no usable Version table" return nil, "payload has no usable Version table"
end end
return { engine = v.engine, minShell = tonumber(v.minShell) or 1 } return {
engine = v.engine,
minShell = tonumber(v.minShell) or 1,
payloadHost = type(v.payloadHost) == "string" and v.payloadHost or "love",
}
end end
-- Boot.select(candidates, bundledEngine, bundledShell) -> chosen | nil, toDelete -- Pure host gate shared by boot selection and the download worker. Missing
-- payloadHost fields mean "love" so payloads made before this contract remain
-- compatible with ordinary LOVE packages.
function Boot.canHost(info, bundledShell, bundledPayloadHost)
if type(info) ~= "table" then return false end
local payloadHost = type(info.payloadHost) == "string"
and info.payloadHost or "love"
local host = type(bundledPayloadHost) == "string"
and bundledPayloadHost or "love"
return payloadHost == host and (tonumber(info.minShell) or 1)
<= (tonumber(bundledShell) or 1)
end
local function samePayloadHost(info, bundledPayloadHost)
local payloadHost = type(info.payloadHost) == "string"
and info.payloadHost or "love"
local host = type(bundledPayloadHost) == "string"
and bundledPayloadHost or "love"
return payloadHost == host
end
-- Boot.select(candidates, bundledEngine, bundledShell, bundledPayloadHost)
-- -> chosen | nil, toDelete
-- --
-- Pure (no love.*): decide which payload to run and which to delete. -- Pure (no love.*): decide which payload to run and which to delete.
-- candidates is a list of { name = , engine = , minShell = }. -- candidates is a list of { name = , engine = , minShell = , payloadHost = }.
-- * chosen: the highest engine that is STRICTLY newer than bundledEngine and -- * chosen: the highest engine that is STRICTLY newer than bundledEngine and
-- whose minShell <= bundledShell (a payload the running shell can host). -- whose payloadHost matches and minShell <= bundledShell.
-- * toDelete: stale payloads -- engine <= bundled (old or the same as what we -- * toDelete: stale payloads -- engine <= bundled (old or the same as what we
-- already ship), or superseded by the chosen one (not newer than chosen). -- already ship), or superseded by the chosen one (not newer than chosen).
-- A payload newer than the chosen one but unrunnable here (minShell too -- A newer incompatible payload is kept: a matching host or future shell
-- high) is kept: a future shell upgrade may be able to run it. -- may be able to run it.
function Boot.select(candidates, bundledEngine, bundledShell) function Boot.select(candidates, bundledEngine, bundledShell, bundledPayloadHost)
local chosen local chosen
for _, c in ipairs(candidates) do for _, c in ipairs(candidates) do
local newer = Semver.compare(c.engine, bundledEngine) > 0 local newer = Semver.compare(c.engine, bundledEngine) > 0
local runnable = (c.minShell or 1) <= bundledShell local runnable = Boot.canHost(c, bundledShell, bundledPayloadHost)
if newer and runnable then if newer and runnable then
if not chosen or Semver.compare(c.engine, chosen.engine) > 0 then if not chosen or Semver.compare(c.engine, chosen.engine) > 0 then
chosen = c chosen = c
@@ -132,9 +159,15 @@ function Boot.select(candidates, bundledEngine, bundledShell)
local toDelete = {} local toDelete = {}
for _, c in ipairs(candidates) do for _, c in ipairs(candidates) do
if not (chosen and c.name == chosen.name) then if not (chosen and c.name == chosen.name) then
local stale = Semver.compare(c.engine, bundledEngine) <= 0 local stale = false
if chosen and Semver.compare(c.engine, chosen.engine) <= 0 then -- Never clean up another host family's payloads. A shared save directory
stale = true -- may be opened by multiple native packages, and only the matching host
-- can decide whether one of its own archives is stale.
if samePayloadHost(c, bundledPayloadHost) then
stale = Semver.compare(c.engine, bundledEngine) <= 0
if chosen and Semver.compare(c.engine, chosen.engine) <= 0 then
stale = true
end
end end
if stale then toDelete[#toDelete + 1] = c.name end if stale then toDelete[#toDelete + 1] = c.name end
end end
@@ -223,6 +256,7 @@ local function runInner(args)
name = entry, name = entry,
engine = info.engine, engine = info.engine,
minShell = info.minShell, minShell = info.minShell,
payloadHost = info.payloadHost,
} }
end end
end end
@@ -230,7 +264,8 @@ local function runInner(args)
end end
local Version = require("src.core.Version") local Version = require("src.core.Version")
local chosen, toDelete = Boot.select(candidates, Version.engine, Version.shell) local chosen, toDelete = Boot.select(candidates, Version.engine,
Version.shell, Version.payloadHost)
for _, victim in ipairs(toDelete) do for _, victim in ipairs(toDelete) do
love.filesystem.remove(PAYLOAD_DIR .. "/" .. victim) love.filesystem.remove(PAYLOAD_DIR .. "/" .. victim)
+3 -2
View File
@@ -153,8 +153,9 @@ local function gatePasses(rel)
local info = Boot.probePayload(rel) local info = Boot.probePayload(rel)
if not info then return true end if not info then return true end
local shell = (Version and Version.shell) or 1 local shell = (Version and Version.shell) or 1
if info.minShell and info.minShell > shell then return false end local payloadHost = (Version and Version.payloadHost) or "love"
return true if Boot.canHost then return Boot.canHost(info, shell, payloadHost) end
return not (info.minShell and info.minShell > shell)
end end
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
+56
View File
@@ -104,6 +104,34 @@ local function nameSet(list)
return s return s
end end
-- Host-family compatibility is independent from the numeric shell revision.
-- Missing fields preserve the historical ordinary-LOVE defaults.
do
check(Boot.canHost({}, 1, "love"),
"canHost: legacy payload defaults to ordinary LOVE host and shell 1")
check(Boot.canHost({ payloadHost = "love", minShell = 2 }, 2, "love"),
"canHost: matching host and sufficient shell pass")
check(not Boot.canHost({ payloadHost = "love", minShell = 2 }, 1, "love"),
"canHost: newer shell requirement fails")
check(not Boot.canHost({ payloadHost = "special", minShell = 1 }, 9, "love"),
"canHost: a high shell revision cannot override a host mismatch")
check(Boot.canHost({ payloadHost = "special", minShell = 3 }, 3, "special"),
"canHost: a specialized host accepts its own payload")
check(not Boot.canHost(nil, 1, "love"),
"canHost: malformed payload metadata fails closed")
end
-- Even an older mismatched-host payload is not ours to clean up.
do
local candidates = {
{ name = "other-old.love", engine = "0.5.0", minShell = 1,
payloadHost = "special" },
}
local chosen, del = Boot.select(candidates, "1.0.0", 1, "love")
eq(chosen, nil, "select: old payload for another host is not runnable")
eq(#del, 0, "select: old payload for another host is not deleted")
end
-- empty candidate list: nothing to run, nothing to delete -- empty candidate list: nothing to run, nothing to delete
do do
local chosen, del = Boot.select({}, "1.0.0", 1) local chosen, del = Boot.select({}, "1.0.0", 1)
@@ -127,6 +155,34 @@ do
check(not d["b.love"], "select: the chosen payload is never deleted") check(not d["b.love"], "select: the chosen payload is never deleted")
end end
-- A newer payload for another native host is neither selected nor deleted.
-- It may be valid for another full package sharing this save directory.
do
local candidates = {
{ name = "other.love", engine = "2.0.0", minShell = 1,
payloadHost = "special" },
{ name = "ours.love", engine = "1.5.0", minShell = 1,
payloadHost = "love" },
}
local chosen, del = Boot.select(candidates, "1.0.0", 1, "love")
eq(chosen, "ours.love", "select: chooses the matching host payload")
eq(#del, 0, "select: incompatible newer host payload is retained")
end
-- The same candidate set selects the specialized payload when the bundled
-- package identifies as that host family.
do
local candidates = {
{ name = "ordinary.love", engine = "2.1.0", minShell = 1,
payloadHost = "love" },
{ name = "special.love", engine = "2.0.0", minShell = 1,
payloadHost = "special" },
}
local chosen, del = Boot.select(candidates, "1.0.0", 1, "special")
eq(chosen, "special.love", "select: specialized package chooses its host payload")
eq(#del, 0, "select: newer ordinary payload remains available to its host")
end
-- skips payloads whose minShell is above the bundled shell, and KEEPS an -- skips payloads whose minShell is above the bundled shell, and KEEPS an
-- otherwise-newer one for a future shell upgrade instead of deleting it -- otherwise-newer one for a future shell upgrade instead of deleting it
do do