diff --git a/docs/updater.md b/docs/updater.md index c27615a3..f117c898 100644 --- a/docs/updater.md +++ b/docs/updater.md @@ -26,7 +26,7 @@ JSON parsing, and sha256 verification run on a background `love.thread` ## 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 untouched): @@ -37,6 +37,11 @@ untouched): as a valid payload to chainload). - `shell` - the native-shell contract this build's fused executable 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. 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; 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 Each tagged release `vX.Y.Z` carries the existing per-platform archives diff --git a/src/core/Version.lua b/src/core/Version.lua index c57b91a2..0424bc82 100644 --- a/src/core/Version.lua +++ b/src/core/Version.lua @@ -8,6 +8,9 @@ local Version = { -- "-dev" placeholder; CI stamps the real X.Y.Z into -- the packed game.love only, never the working tree. 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. -- Bump only when a payload needs a newer native -- binary (e.g. a LOVE version bump); an older shell diff --git a/src/update/Boot.lua b/src/update/Boot.lua index b3936ad4..43f0781f 100644 --- a/src/update/Boot.lua +++ b/src/update/Boot.lua @@ -80,7 +80,8 @@ local function purgeBundledModules() 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 -- 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 return nil, "payload has no usable Version table" 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 --- 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. --- 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 --- 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 -- already ship), or superseded by the chosen one (not newer than chosen). --- A payload newer than the chosen one but unrunnable here (minShell too --- high) is kept: a future shell upgrade may be able to run it. -function Boot.select(candidates, bundledEngine, bundledShell) +-- A newer incompatible payload is kept: a matching host or future shell +-- may be able to run it. +function Boot.select(candidates, bundledEngine, bundledShell, bundledPayloadHost) local chosen for _, c in ipairs(candidates) do 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 not chosen or Semver.compare(c.engine, chosen.engine) > 0 then chosen = c @@ -132,9 +159,15 @@ function Boot.select(candidates, bundledEngine, bundledShell) local toDelete = {} for _, c in ipairs(candidates) do if not (chosen and c.name == chosen.name) then - local stale = Semver.compare(c.engine, bundledEngine) <= 0 - if chosen and Semver.compare(c.engine, chosen.engine) <= 0 then - stale = true + local stale = false + -- Never clean up another host family's payloads. A shared save directory + -- 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 if stale then toDelete[#toDelete + 1] = c.name end end @@ -223,6 +256,7 @@ local function runInner(args) name = entry, engine = info.engine, minShell = info.minShell, + payloadHost = info.payloadHost, } end end @@ -230,7 +264,8 @@ local function runInner(args) end 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 love.filesystem.remove(PAYLOAD_DIR .. "/" .. victim) diff --git a/src/update/check_worker.lua b/src/update/check_worker.lua index 7a0dd1ba..0efc7ada 100644 --- a/src/update/check_worker.lua +++ b/src/update/check_worker.lua @@ -153,8 +153,9 @@ local function gatePasses(rel) local info = Boot.probePayload(rel) if not info then return true end local shell = (Version and Version.shell) or 1 - if info.minShell and info.minShell > shell then return false end - return true + local payloadHost = (Version and Version.payloadHost) or "love" + if Boot.canHost then return Boot.canHost(info, shell, payloadHost) end + return not (info.minShell and info.minShell > shell) end -- --------------------------------------------------------------------------- diff --git a/tests/engine/update_tests.lua b/tests/engine/update_tests.lua index 9bf67171..2fb696ab 100644 --- a/tests/engine/update_tests.lua +++ b/tests/engine/update_tests.lua @@ -104,6 +104,34 @@ local function nameSet(list) return s 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 do 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") 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 -- otherwise-newer one for a future shell upgrade instead of deleting it do