From b8ec4fe6b5fe4df48926e316ed4e89efd97bf0a5 Mon Sep 17 00:00:00 2001 From: anxiousintrovert <82425472+anxiousintrovert@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:17:04 -0500 Subject: [PATCH 01/12] Fix versioned mod conflicts in launcher --- src/mods/LauncherMods.lua | 12 ++++++++-- tests/mod_manifest_tests.lua | 45 ++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index 87e51acb..10922dea 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -215,12 +215,19 @@ function LauncherMods.checkDependencies(manifest, options, version, installedMan return m and not m.experimental end + local function conflictApplies(spec, other) + return not spec.range or (other and other.version + and Semver.satisfies(other.version, spec.range)) + end + -- (a) Conflicts declared by target manifest if type(manifest.conflictSpecs) == "table" then for _, spec in ipairs(manifest.conflictSpecs) do local conflictId = spec.id local installedOther = installedMap[conflictId] - if installedOther and isEnabled(conflictId) and not conflictIdsSeen[conflictId] then + if installedOther and isEnabled(conflictId) + and conflictApplies(spec, installedOther) + and not conflictIdsSeen[conflictId] then conflictIdsSeen[conflictId] = true hasIssues = true depsResult[#depsResult + 1] = { @@ -238,11 +245,12 @@ function LauncherMods.checkDependencies(manifest, options, version, installedMan -- (b) Reverse conflicts declared by installed mods against target manifest if manifest.id then + local installedTarget = installedMap[manifest.id] or manifest for _, other in ipairs(manifests) do if other.id ~= manifest.id and isEnabled(other.id) and not conflictIdsSeen[other.id] then local conflicts = other.conflictSpecs or {} for _, spec in ipairs(conflicts) do - if spec.id == manifest.id then + if spec.id == manifest.id and conflictApplies(spec, installedTarget) then conflictIdsSeen[other.id] = true hasIssues = true depsResult[#depsResult + 1] = { diff --git a/tests/mod_manifest_tests.lua b/tests/mod_manifest_tests.lua index f7d1061d..7d7edae3 100644 --- a/tests/mod_manifest_tests.lua +++ b/tests/mod_manifest_tests.lua @@ -550,6 +550,51 @@ local installedColorlib = Manifest.validate({ version = "1.0.0", entry = "main.lua", }, "mods/colorlib") +local unconditionalConflict = LauncherMods.checkDependencies(testTargetManifest, + nil, nil, { testTargetManifest, installedColorlib }) +check(unconditionalConflict.hasIssues == true, + "dependency resolver reports an unversioned conflict") + +local function rangeTarget(version, conflicts) + return Manifest.validate({ + id = "range_target", + name = "Range Target", + version = version, + entry = "main.lua", + conflicts = conflicts or {}, + }, "mods/range_target") +end +local function rangeSource(conflicts) + return Manifest.validate({ + id = "range_source", + name = "Range Source", + version = "1.0.0", + entry = "main.lua", + conflicts = conflicts or {}, + }, "mods/range_source") +end + +local forwardSource = rangeSource({ "range_target@<2.0.0" }) +local matchingTarget = rangeTarget("1.4.0") +local nonmatchingTarget = rangeTarget("2.0.0") +local forwardMatching = LauncherMods.checkDependencies(forwardSource, + nil, nil, { forwardSource, matchingTarget }) +check(forwardMatching.hasIssues == true and #forwardMatching.deps == 1, + "dependency resolver applies a matching forward conflict range") +local forwardNonmatching = LauncherMods.checkDependencies(forwardSource, + nil, nil, { forwardSource, nonmatchingTarget }) +check(forwardNonmatching.hasIssues == false and #forwardNonmatching.deps == 0, + "dependency resolver ignores a nonmatching forward conflict range") + +local reverseSource = rangeSource({ "range_target@<2.0.0" }) +local reverseMatching = LauncherMods.checkDependencies(matchingTarget, + nil, nil, { reverseSource, matchingTarget }) +check(reverseMatching.hasIssues == true and #reverseMatching.deps == 1, + "dependency resolver applies a matching reverse conflict range") +local reverseNonmatching = LauncherMods.checkDependencies(nonmatchingTarget, + nil, nil, { reverseSource, nonmatchingTarget }) +check(reverseNonmatching.hasIssues == false and #reverseNonmatching.deps == 0, + "dependency resolver ignores a nonmatching reverse conflict range") -- ------- scoped dependency tests local Json = require("src.link.Json") local scopedDepManifest = Manifest.validate({ From cf335f67dea01099febbeddf5e02a3f1e0986991 Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Sat, 15 Aug 2026 21:03:24 +0100 Subject: [PATCH 02/12] feat(mods): add mod.postLog one-way log reporting to a manifest-declared URL --- docs/modding.md | 34 ++++++ src/core/HostShell.lua | 52 +++++++++ src/mods/Loader.lua | 17 +++ src/mods/Manifest.lua | 19 ++++ src/mods/Net.lua | 59 ++++++++++ src/net/Fetch.lua | 11 ++ src/net/fetch_worker.lua | 17 +++ tests/modkit/cases/mod_postlog.lua | 169 +++++++++++++++++++++++++++++ 8 files changed, 378 insertions(+) create mode 100644 tests/modkit/cases/mod_postlog.lua diff --git a/docs/modding.md b/docs/modding.md index b369f504..4856b6c1 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -90,6 +90,7 @@ Every mod contains a root `manifest.json` defining its metadata, supported games | `optional_imports` | `array` | User-supplied files that unlock optional mod functionality. They use the same validation and private-copy flow but never block the mod from loading. | | `conflicts` / `incompatible` | `array` | List of mod IDs that cannot run concurrently with this mod. | | `permissions` | `array` | Requested privileges (e.g. `["engine_internals"]`, `["network"]`, `["filesystem"]`). | +| `log_url` | `string` | Optional https URL for `mod.postLog` log reporting (api 2; requires the `network` permission). | | `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. | ### Declaring Dependencies & Scoping @@ -864,6 +865,39 @@ with a full standard library that the sandbox cannot reach, so handing one to a mod would undo every other rule; `mod.fetch`'s workers run engine code, so a mod gets asynchrony without gaining any new reach. +## Log reporting + +`mod.postLog(body, opts)` is the one-way exception to the rule that a mod +decides where it talks. It reports a debug/crash log to the https URL the +manifest declares in `log_url`, and it is the only API that may not be +pointed at a caller-chosen address: + +```json +{ + "permissions": ["network"], + "log_url": "https://logs.example.com/receive" +} +``` + +The URL is validated at load: it must be `https://`, and declaring it +without the `network` permission is a load violation for api 2 mods. The +destination is reviewed when the mod ships, not chosen per call, so a mod +cannot aim this at arbitrary hosts or read back anything a server replies. + +```lua +-- fire and forget; poll() never blocks, same shape as mod.fetch +local job = mod:postLog("session crashed at 0x1f3a\n" .. logText) +``` + +`postLog(body, opts)` returns the same opaque handle as `mod.fetch:get`, +polled and released through `mod.fetch:poll` / `mod.fetch:release`. `opts` +is a closed list with one switch: `format`, either `"text"` (the default) +or `"json"`. `json` wraps the body in an envelope of `{ ts, mod, format, +body }` so a server can attribute and sort reports; any other key or value +is refused before a job is submitted. The body is capped at 64 KB, the +transfer is bounded by the same worker ceilings as `mod.fetch`, and the +response body is never returned to the mod. + ## Background jobs `mod.fetch` covers work waiting on a server. `mod.job` covers work waiting on diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index ced12063..7f4a28ed 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -417,4 +417,56 @@ function HostShell.httpGet(url, userAgent, accept, maxTime) return body end +-- POST returning success/failure. Strictly one-way: the response body is +-- discarded, only the HTTP status class is surfaced (postLog callers never +-- trust the reply). curl --data-binary reads the payload from a pipe, so a +-- large body never lands in the command line; the Android bridge has no POST +-- transport, and httpPost reports that instead of half-working through +-- httpDownload (a GET round-trip to a POST endpoint would be a lie). +function HostShell.httpPost(url, body, contentType, userAgent, maxTime) + if type(url) ~= "string" or url == "" then return nil, "missing url" end + if type(body) ~= "string" then return nil, "missing body" end + userAgent = userAgent or "gen1recomp" + if HostShell.haveCurl() then + -- --data-binary @- keeps the payload out of argv (command-line length + -- limits on Windows) and preserves every byte including trailing + -- newlines. No -f, matching httpGet: the response body is discarded + -- anyway, and curl's stderr carries the real diagnosis on failure. + local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https " + .. "--connect-timeout 10 --max-time %d ") + :format(tonumber(maxTime) or 40) + .. "-X POST " + .. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " " + if contentType then + cmd = cmd .. "-H " .. HostShell.quote("Content-Type: " .. contentType) .. " " + end + cmd = cmd .. "-H " .. HostShell.quote("Content-Length: " .. tostring(#body)) .. " " + .. "--data-binary @- " + .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " " + .. HostShell.quote(url) .. " 2>&1" + local pipe = HostShell.popen(cmd, "rw") + if not pipe then return nil, "could not run curl" end + local writeOk, werr = pcall(pipe.write, pipe, body) + if not writeOk then + HostShell.pclose(pipe) + return nil, "could not write body: " .. tostring(werr) + end + local readOk, out = pcall(function() return pipe:read("*a") end) + HostShell.pclose(pipe) + if not readOk then + return nil, fetchError(url, nil, tostring(out)) + end + local _, status, noise = splitCurlOutput(out) + if not status then return nil, fetchError(url, nil, noise) end + if status < 200 or status >= 300 then + return nil, fetchError(url, status, "log post rejected") + end + return true + end + if not haveBridge() then + return nil, "no network transport on this platform" + end + return nil, "no POST transport on this platform" +end + return HostShell diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index d01ec45e..79fb87c5 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1094,6 +1094,23 @@ function Loader:_api(mod) return { available = function() return false end, get = refuse, poll = refuse, release = refuse, cancel = refuse } end)(), + -- One-way crash-log reporting to the https URL the manifest declares in + -- log_url. The destination is reviewed at load, not chosen per call, so + -- a mod cannot aim this at arbitrary hosts; the response body is never + -- returned, and the worker pool bounds the transfer. Same handle/poll/ + -- release shape as mod.fetch, so mod.job's sibling patterns carry over. + postLog = (function() + if mod.manifest.permissionSet.network and mod.manifest.log_url then + return function(_, body, opts) + return Net.postLog(loader, modId, mod.manifest.log_url, body, opts) + end + end + local function refuse() + error(('[%s] mod.postLog needs the "network" permission and a ' + .. "log_url in manifest.json"):format(modId), 2) + end + return refuse + end)(), -- Background compute, behind the "background" permission. The worker -- rebuilds this mod's sandbox before loading the script, so a job is the -- one thing love.thread is not: off the main thread without a Lua state diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index ab53b6be..6893ff87 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -308,6 +308,24 @@ function Manifest.validate(raw, path) local github = Manifest.parseGithub(raw.github) + -- log_url: the mod's one-way crash-log reporting destination. https-only, + -- declared in the manifest so the engine reviews the target at load instead + -- of trusting per-call URLs from gameplay code, and gated on the `network` + -- permission the mod must also declare. api 1 mods never carry it: it is a + -- load violation, not a warning, because a postLog-capable mod that does not + -- opt in to networking is a bug in the manifest itself. + local logUrl = nil + if raw.log_url ~= nil then + if strict and not permissionSet.network then + violation(strict, raw.id, "log_url requires the network permission") + elseif strict and (type(raw.log_url) ~= "string" + or not raw.log_url:match("^https://")) then + violation(strict, raw.id, "log_url must be an https:// URL") + elseif strict then + logUrl = raw.log_url + end + end + assert(raw.experimental == nil or type(raw.experimental) == "boolean", "experimental must be a boolean") local experimental = raw.experimental == true @@ -414,6 +432,7 @@ function Manifest.validate(raw, path) affects_link = affectsLink, permissions = permissions, permissionSet = permissionSet, + log_url = logUrl, options_schema = optionalFile(raw.options_schema, "options_schema"), assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"), required_imports = requiredImports, diff --git a/src/mods/Net.lua b/src/mods/Net.lua index d691c7c2..c54f0a68 100644 --- a/src/mods/Net.lua +++ b/src/mods/Net.lua @@ -32,6 +32,9 @@ local Net = {} Net.MAX_INFLIGHT = 4 -- Clamp on the caller's timeout, so a mod cannot pin a worker indefinitely. Net.MAX_SECONDS = 30 +-- A log body ceiling. Debug logs are kilobytes, and a server operator has no +-- reason to accept a mod uploading arbitrary megabytes to its endpoint. +Net.MAX_BODY = 65536 local function fetch() return require("src.net.Fetch") @@ -99,6 +102,62 @@ function Net.get(loader, modId, url, opts) return handle end +-- The closed list of postLog format switches. Anything outside it is a +-- caller bug, rejected before a job is submitted, so the surface stays +-- exactly two shapes on the wire. +local POST_FORMATS = { text = true, json = true } + +-- A one-way log POST to the mod's manifest-declared log_url (https only, +-- validated in Manifest.lua). Same shape as get(): opaque handle, per-mod +-- in-flight ceiling, user agent naming the mod. The response body is never +-- returned -- a postLog is fire-and-forget reporting, and the engine has no +-- reason to hand a mod a server's reply. +function Net.postLog(loader, modId, logUrl, body, opts) + if type(body) ~= "string" or body == "" then + return nil, "log body must be a non-empty string" + end + if #body > Net.MAX_BODY then + return nil, ("log body too large (%d bytes, limit %d)"):format(#body, Net.MAX_BODY) + end + opts = type(opts) == "table" and opts or {} + for key in pairs(opts) do + if key ~= "format" then + return nil, ("unknown log option %q (format is the only switch)"):format(tostring(key)) + end + end + local format = opts.format or "text" + if not POST_FORMATS[format] then + return nil, ("unknown log format %q (text and json only)"):format(tostring(format)) + end + local denial = Net.urlDenial(logUrl) + if denial then return nil, denial end + local b = bucket(loader, modId) + if inflight(b) >= Net.MAX_INFLIGHT then + return nil, ("too many requests in flight (limit %d); poll and release " + .. "the ones you have"):format(Net.MAX_INFLIGHT) + end + local payload = body + local contentType = "text/plain" + if format == "json" then + local Json = require("src.link.Json") + payload = Json.encode({ + ts = os.time(), + mod = modId, + format = "json", + body = body, + }) + contentType = "application/json" + end + local id = fetch().post(logUrl, payload, { + userAgent = "gen1recomp-mod/" .. tostring(modId), + contentType = contentType, + maxSeconds = Net.MAX_SECONDS, + }) + local handle = {} + b[handle] = id + return handle +end + -- A copy of the job's state, never the engine's own table. An unknown or -- forged handle reads as an error rather than nil, so a mod that lost track of -- one cannot spin waiting on it forever. diff --git a/src/net/Fetch.lua b/src/net/Fetch.lua index 370ad1a4..3e7d961a 100644 --- a/src/net/Fetch.lua +++ b/src/net/Fetch.lua @@ -132,6 +132,17 @@ function Fetch.get(url, opts) accept = opts.accept, maxSeconds = opts.maxSeconds }) end +-- POST a body to a URL, one-way. The result carries no body: postLog +-- reporting never trusts a server's reply, so the worker surfaces only +-- ok/error and the transport's complaint. +-- opts: { userAgent, contentType, maxSeconds } +function Fetch.post(url, body, opts) + opts = opts or {} + return submit({ kind = "post", url = url, body = body, + userAgent = opts.userAgent or "gen1recomp", + contentType = opts.contentType, maxSeconds = opts.maxSeconds }) +end + -- Download a URL to `saveRel`, a path relative to the LOVE save directory. -- Progress is reported as a 0..1 fraction when `size` is known. function Fetch.download(url, saveRel, opts) diff --git a/src/net/fetch_worker.lua b/src/net/fetch_worker.lua index f86d78f1..f386d7f8 100644 --- a/src/net/fetch_worker.lua +++ b/src/net/fetch_worker.lua @@ -99,6 +99,20 @@ local function doDownload(job) post({ id = job.id, ok = true, path = rel, done = true }) end +local function doPost(job) + if not HostShell then + post({ id = job.id, ok = false, err = "no transport" }) + return + end + local ok, err = HostShell.httpPost(job.url, job.body, job.contentType, + job.userAgent, tonumber(job.maxSeconds) or GET_MAX_SECONDS) + if not ok then + post({ id = job.id, ok = false, err = err or "post failed" }) + return + end + post({ id = job.id, ok = true, done = true }) +end + while true do local job = cmdCh:demand() -- The flag is checked before the job's KIND, so a worker woken by a @@ -114,6 +128,9 @@ while true do elseif job.kind == "get" then local ok, err = pcall(doGet, job) if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end + elseif job.kind == "post" then + local ok, err = pcall(doPost, job) + if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end elseif job.kind == "download" then local ok, err = pcall(doDownload, job) if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end diff --git a/tests/modkit/cases/mod_postlog.lua b/tests/modkit/cases/mod_postlog.lua new file mode 100644 index 00000000..a1089894 --- /dev/null +++ b/tests/modkit/cases/mod_postlog.lua @@ -0,0 +1,169 @@ +-- mod.postLog: one-way log reporting to the manifest-declared log_url. +-- The things this pins are the strict ones -- https-only destination that +-- lives in the manifest (not per-call), a closed list of format switches, +-- a body ceiling, opaque per-mod handles, and refusal without the network +-- permission. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Net = require("src.mods.Net") +local Fetch = require("src.net.Fetch") + +-- Stand in for the worker pool: jobs resolve when the test says so, so no +-- test here touches a socket. +local submitted, nextId, states = {}, 0, {} +Fetch.post = function(url, body, opts) + nextId = nextId + 1 + submitted[nextId] = { url = url, body = body, opts = opts } + states[nextId] = { status = "pending", progress = 0 } + return nextId +end +Fetch.poll = function(id) return states[id] or { status = "error", err = "unknown job" } end +Fetch.isPending = function(id) return (states[id] or {}).status == "pending" end +Fetch.release = function(id) states[id] = nil end +Fetch.cancel = function(id) + if states[id] and states[id].status == "pending" then states[id].status = "cancelled" end +end +Fetch.available = function() return true end + +local LOGGER = { + ["mods/log_sender/manifest.json"] = [[{ + "id": "log_sender", + "name": "Log Sender", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "permissions": ["network"], + "log_url": "https://logs.example.com/logs" + }]], + ["mods/log_sender/main.lua"] = [[ + local mod = ... + mod.exports.send = function(body, opts) + local handle, err = mod:postLog(body, opts) + if not handle then return nil, err end + return handle + end + mod.exports.poll = function(h) return mod.fetch:poll(h) end + mod.exports.release = function(h) return mod.fetch:release(h) end + ]], +} + +local function manifest(id, extra) + return ('{"id": "%s", "name": "T", "version": "1.0.0", "entry": "main.lua", ' + .. '"api": 2%s}'):format(id, extra or "") +end + +local NO_URL = { + ["mods/log_no_url/manifest.json"] = manifest("log_no_url", ', "permissions": ["network"]'), + ["mods/log_no_url/main.lua"] = [[ + local mod = ... + mod.exports.try = function() + local ok, err = pcall(function() return mod:postLog("body") end) + return ok, err + end + ]], +} + +-- ------------------------------------------------ the closed opts list +local run = T.sdk.loadMods({ "mods/log_sender" }, { fs = T.sdk.memfs(LOGGER) }) +T.eq(#run.errors, 0, "the logger mod loads clean (" .. tostring(run.errors[1]) .. ")") +local api = run.loader.exports.log_sender + +local bad, badErr = api.send("body", { format = "xml" }) +T.eq(bad, nil, "an unknown format is refused") +T.check(badErr and badErr:find("text and json only", 1, true), "and names the allowed ones") + +local badKey, keyErr = api.send("body", { envelope = true }) +T.eq(badKey, nil, "an unknown opt key is refused") +T.check(keyErr and keyErr:find("format is the only switch", 1, true), "and says so") + +-- ------------------------------------------------ body validation +local empty, emptyErr = api.send("") +T.eq(empty, nil, "an empty body is refused") +local big = string.rep("x", Net.MAX_BODY + 1) +local bigH, bigErr = api.send(big) +T.eq(bigH, nil, "an oversized body is refused") +T.check(bigErr and bigErr:find("limit", 1, true), "and names the limit") + +-- ------------------------------------------------- text format (default) +local handle, err = api.send("hello log") +T.check(handle ~= nil, "a plain text post returns a handle (" .. tostring(err) .. ")") +T.eq(type(handle), "table", "the handle is opaque, not the engine's job id") +T.eq(api.poll(handle).status, "pending", "a fresh post polls as pending") + +local sent +for _, job in pairs(submitted) do + if job.url == "https://logs.example.com/logs" and job.body == "hello log" then sent = job end +end +T.check(sent ~= nil, "the post reached the pool with the manifest URL") +T.eq(sent.opts.contentType, "text/plain", "plain text posts as text/plain") +T.check(sent.opts.userAgent:find("log_sender", 1, true), + "the request identifies the calling mod") + +-- -------------------------------------------------- json format +local jh, jerr = api.send("line one", { format = "json" }) +T.check(jh ~= nil, "a json post returns a handle (" .. tostring(jerr) .. ")") +local jsent +for _, job in pairs(submitted) do + if job.opts.contentType == "application/json" then jsent = job end +end +T.check(jsent ~= nil, "json posts as application/json") +local decoded = require("src.link.Json").decode(jsent.body) +T.eq(type(decoded), "table", "the json body is a table") +T.eq(decoded.format, "json", "the envelope names its format") +T.eq(decoded.mod, "log_sender", "the envelope names the mod") +T.eq(decoded.body, "line one", "the payload survives the envelope") + +-- completion flows through poll, like get +states[1] = { status = "ok", progress = 1 } +local got = api.poll(handle) +T.eq(got.status, "ok", "a completed post polls ok") + +api.release(handle) +run.release() + +-- --------------------------------------- manifest without log_url refuses +local nurl = T.sdk.loadMods({ "mods/log_no_url" }, { fs = T.sdk.memfs(NO_URL) }) +T.eq(#nurl.errors, 0, "no log_url loads clean (" .. tostring(nurl.errors[1]) .. ")") +local okCall, callErr = nurl.loader.exports.log_no_url.try() +T.check(not okCall and callErr:find("log_url", 1, true), + "postLog without log_url names the missing manifest field") +nurl.release() + +-- ------------------------------------------- manifest validation: the gate +-- log_url without the network permission is a load violation in a strict +-- manifest: the mod declares a network capability it did not opt in to. The +-- violation fires inside manifest validation, so the mod never enters +-- loader.mods at all. +local badManifest = T.sdk.loadMods({ "mods/log_bad" }, { fs = T.sdk.memfs({ + ["mods/log_bad/manifest.json"] = manifest("log_bad", + ', "log_url": "https://logs.example.com/logs"'), + ["mods/log_bad/main.lua"] = "local mod = ...", +}) }) +T.eq(badManifest.mods.log_bad, nil, + "log_url without network: the mod is refused before load") + +-- a non-https log_url is refused even with the permission +local httpManifest = T.sdk.loadMods({ "mods/log_http" }, { fs = T.sdk.memfs({ + ["mods/log_http/manifest.json"] = manifest("log_http", + ', "permissions": ["network"], "log_url": "http://logs.example.com/logs"'), + ["mods/log_http/main.lua"] = "local mod = ...", +}) }) +T.eq(httpManifest.mods.log_http, nil, + "an http log_url: the mod is refused before load") + +-- an api 1 manifest carries no strict surface: log_url is ignored, and the +-- mod loads (its postLog call still refuses -- there is no log_url to use) +local api1 = T.sdk.loadMods({ "mods/log_api1" }, { fs = T.sdk.memfs({ + ["mods/log_api1/manifest.json"] = [[{ + "id": "log_api1", "name": "T", "version": "1.0.0", "entry": "main.lua", + "api": 1, "log_url": "https://logs.example.com/logs" + }]], + ["mods/log_api1/main.lua"] = "local mod = ...", +}) }) +T.eq(#api1.errors, 0, "an api 1 manifest ignores log_url (" + .. tostring(api1.errors[1]) .. ")") +T.check(api1.loader.mods.log_api1 ~= nil, "and the mod loads") + +T.finish("mod_postlog") From 180ce6b2e7e0dd94188a9f18d4dcad468458cf3c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:32:54 +0000 Subject: [PATCH 03/12] build(deps): bump actions/setup-dotnet from 4 to 6 Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 4 to 6. - [Release notes](https://github.com/actions/setup-dotnet/releases) - [Commits](https://github.com/actions/setup-dotnet/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/setup-dotnet dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30ba4d89..f7384709 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -262,7 +262,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: Setup .NET 8 - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: dotnet-version: "8.0.x" - name: Publish gen1tls (win-x64 Native AOT) From 0ac55b0f9e86ca46cb285caae540267f2d813e3b Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:53:33 +0200 Subject: [PATCH 04/12] feat(mods): support conditional option rows --- docs/mod-option-schema.md | 13 ++++++++---- src/mods/ManagerState.lua | 44 ++++++++++++++++++++++++++++++++++++++- tests/mod_ui_tests.lua | 21 +++++++++++++++++++ 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/docs/mod-option-schema.md b/docs/mod-option-schema.md index d111a856..1e6845cf 100644 --- a/docs/mod-option-schema.md +++ b/docs/mod-option-schema.md @@ -46,7 +46,11 @@ 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`. +provide `maxLen`. A row may also use +`visible_if = {key = "mode", equals = "compact"}` or replace `equals` with +`not_equals`. This only hides the in-game menu row; the schema and stored value +remain available, and consumers that do not implement conditions may ignore +the field. 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 @@ -69,9 +73,10 @@ 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. +the version whenever they change the document envelope or the meaning of an +existing field. New optional row fields that older consumers can safely ignore +do not require a bump. Version 1 is therefore the legacy unversioned format as +well as the explicitly versioned format shown above. ## Migration note diff --git a/src/mods/ManagerState.lua b/src/mods/ManagerState.lua index a5634834..721fdb64 100644 --- a/src/mods/ManagerState.lua +++ b/src/mods/ManagerState.lua @@ -986,12 +986,45 @@ end function ManagerState:buildOptionRows(m, schema) local rows = {} local modId = m.id + local byKey, visibilityKeys = {}, {} + for _, row in ipairs(schema) do + if type(row) == "table" and type(row.key) == "string" then + byKey[row.key] = row + local condition = row.visible_if + if type(condition) == "table" and type(condition.key) == "string" then + visibilityKeys[condition.key] = true + end + end + end + local function visible(row) + local condition = row.visible_if + if condition == nil then return true end + if type(condition) ~= "table" or type(condition.key) ~= "string" then + return false + end + local dependency = byKey[condition.key] or { key = condition.key } + local value = self:optionValue(modId, dependency) + if condition.equals ~= nil then return value == condition.equals end + if condition.not_equals ~= nil then return value ~= condition.not_equals end + return false + end + local function refresh(key) + if not visibilityKeys[key] then return end + local preferred = rows[self.cursor] and rows[self.cursor].id + self.optionRows = self:buildOptionRows(m, schema) + for index, candidate in ipairs(self.optionRows) do + if candidate.id == preferred then self.cursor = index break end + end + self.cursor = clampIndex(self.cursor, #self.optionRows) + end for _, row in ipairs(schema) do if type(row) ~= "table" or type(row.key) ~= "string" or row.key == "" or not OPTION_TYPES[row.type] then -- malformed rows are skipped, reported where the errors screen reads Runtime.reportError(modId, "options row skipped: " .. tostring(type(row) == "table" and (row.key or row.type) or row)) + elseif not visible(row) then + -- Keep the row in the schema and stored options, only hide its menu row. elseif row.type == "toggle" then rows[#rows + 1] = { id = row.key, label = row.label or row.key, value = function() @@ -999,6 +1032,7 @@ function ManagerState:buildOptionRows(m, schema) end, step = function() self:setOption(modId, row.key, not self:optionValue(modId, row)) + refresh(row.key) return true end } elseif row.type == "choice" then @@ -1021,6 +1055,7 @@ function ManagerState:buildOptionRows(m, schema) end index = clampIndex(index + dir, #choices) self:setOption(modId, row.key, choices[index][2]) + refresh(row.key) return true end } elseif row.type == "number" then @@ -1036,6 +1071,7 @@ function ManagerState:buildOptionRows(m, schema) step = function(_, dir) local cur = tonumber(self:optionValue(modId, row)) or 0 self:setOption(modId, row.key, clamp(cur + dir * (row.step or 1))) + refresh(row.key) return true end, activate = function() @@ -1044,7 +1080,10 @@ function ManagerState:buildOptionRows(m, schema) max = row.max or 99, start = math.max(1, tonumber(self:optionValue(modId, row)) or 1), onDone = function(qty) - if qty then self:setOption(modId, row.key, clamp(qty)) end + if qty then + self:setOption(modId, row.key, clamp(qty)) + refresh(row.key) + end end, })) end } @@ -1061,6 +1100,7 @@ function ManagerState:buildOptionRows(m, schema) default = self:optionValue(modId, row), onDone = function(name) self:setOption(modId, row.key, name) + refresh(row.key) end, })) end } @@ -1075,6 +1115,8 @@ function ManagerState:buildOptionRows(m, schema) self:setOption(modId, row.key, row.default) end end + self.optionRows = self:buildOptionRows(m, schema) + self.cursor = clampIndex(self.cursor, #self.optionRows) self:notify("DEFAULTS RESTORED") end } return rows diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 6ac0b329..bf10702b 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -1106,6 +1106,27 @@ check(loader.modOptions.okmod.hardcore == false and loader.modOptions.okmod.startMoney == 3000 and loader.modOptions.okmod.tag == "BLUE", "RESET DEFAULTS restores every schema default") + +-- optional conditions keep mode-specific rows compact and refresh in place +local conditionalSchema = { + { key = "mode", label = "MODE", type = "choice", default = "one", + choices = { { "ONE", "one" }, { "TWO", "two" } } }, + { key = "oneOnly", label = "ONE ONLY", type = "toggle", default = false, + visible_if = { key = "mode", equals = "one" } }, + { key = "twoOnly", label = "TWO ONLY", type = "toggle", default = false, + visible_if = { key = "mode", equals = "two" } }, + { key = "notOne", label = "NOT ONE", type = "toggle", default = false, + visible_if = { key = "mode", not_equals = "one" } }, +} +loader.modOptions.condmod = {} +ms.cursor = 1 +ms.optionRows = ms:buildOptionRows({ id = "condmod" }, conditionalSchema) +check(#ms.optionRows == 3 and ms.optionRows[2].id == "oneOnly", + "visible_if uses the controlling row default") +ms.optionRows[1].step(mgame, 1) +check(#ms.optionRows == 4 and ms.optionRows[2].id == "twoOnly" + and ms.optionRows[3].id == "notOne" and ms.cursor == 1, + "editing a controller refreshes conditions without moving the cursor") press(ms, "b") check(ms.screen == "list", "B leaves the options screen") From d99072b44ef380945a5191ae54e9cf0aa88019b8 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:01:16 +0200 Subject: [PATCH 05/12] feat(mods): report field action availability state --- docs/modding.md | 2 ++ src/world/WorldAPI.lua | 6 ++++-- src/world/gen2/WorldAPI.lua | 6 ++++-- tests/modkit/cases/world_field_items.lua | 15 ++++++++++++++- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/docs/modding.md b/docs/modding.md index 4856b6c1..824b3a3a 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -226,6 +226,8 @@ exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the contextual `squirtbottle` key item. Fishing rows include the owned rods that are valid choices. The list is empty while the world is busy, and omits an action whenever its item, move, badge, terrain, or engine state forbids it. +The optional second return is `"world is busy"` during transient input locks +or `"no overworld"` before a playable world exists. Call `mod.world:useFieldAction(id, opts)` to perform a listed action through the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }` diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index 4f4200e9..286e5dd6 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -95,8 +95,10 @@ end -- are listed; callers receive copied labels and never inspect world internals. function WorldAPI:availableFieldActions() local game, ow, out = self.game, self:overworld(), {} - if not (game and game.save and ow and ow.map and ow.player) - or not acceptsMenuInput(game, ow) then return out end + if not (game and game.save and ow and ow.map and ow.player) then + return out, NO_OVERWORLD + end + if not acceptsMenuInput(game, ow) then return out, "world is busy" end local save, inventory = game.save, game.save.inventory or {} local items = game.data and game.data.items or {} diff --git a/src/world/gen2/WorldAPI.lua b/src/world/gen2/WorldAPI.lua index 9c204cf4..b5f1dbd9 100644 --- a/src/world/gen2/WorldAPI.lua +++ b/src/world/gen2/WorldAPI.lua @@ -78,8 +78,10 @@ end -- collision and fishing rules. function WorldAPI:availableFieldActions() local world, game, out = self:overworld(), self.game, {} - if not (world and game and game.save and world.map and world.player) - or not world:acceptsMenuInput() then return out end + if not (world and game and game.save and world.map and world.player) then + return out, NO_OVERWORLD + end + if not world:acceptsMenuInput() then return out, "world is busy" end local inventory = game.save.inventory or {} if (inventory.BICYCLE or 0) > 0 then diff --git a/tests/modkit/cases/world_field_items.lua b/tests/modkit/cases/world_field_items.lua index 095c333a..0e90146e 100644 --- a/tests/modkit/cases/world_field_items.lua +++ b/tests/modkit/cases/world_field_items.lua @@ -34,6 +34,9 @@ function redGame.stack:top() return self.states[#self.states] end local RedAPI = require("src.world.WorldAPI") local red = RedAPI.new(redGame, "fixture") +local unavailable, reason = RedAPI.new({}, "fixture"):availableFieldActions() +T.eq(#unavailable, 0, "Red lists no actions without an overworld") +T.eq(reason, "no overworld", "Red reports a missing overworld") local RedWorld = require("src.world.OverworldController") T.check(type(RedWorld.useBicycle) == "function" and type(RedWorld.useFishingRod) == "function" @@ -59,7 +62,9 @@ T.check(not ok and err == "fishing rod unavailable", T.eq(redWorld.rodUsed, used, "a rejected Red rod changes nothing") redWorld.player.moving = true -T.eq(#red:availableFieldActions(), 0, "Red hides actions while moving") +actions, err = red:availableFieldActions() +T.eq(#actions, 0, "Red hides actions while moving") +T.eq(err, "world is busy", "Red distinguishes a busy world from no actions") ok, err = red:useFieldAction("bicycle") T.check(not ok and err == "world is busy", "Red refuses a stale action while busy") @@ -142,6 +147,9 @@ goldWorld.fieldContext = function(_, mon) return { local GoldAPI = require("src.world.gen2.WorldAPI") local gold = GoldAPI.new(goldGame, "fixture") +unavailable, reason = GoldAPI.new({}, "fixture"):availableFieldActions() +T.eq(#unavailable, 0, "Gold lists no actions without an overworld") +T.eq(reason, "no overworld", "Gold reports a missing overworld") actions = gold:availableFieldActions() byId = {} for _, action in ipairs(actions) do byId[action.id] = action end @@ -170,4 +178,9 @@ T.check(gold:useFieldAction("squirtbottle"), T.eq(goldWorld.itemUsed, "SQUIRTBOTTLE", "Gold delegates the SquirtBottle to its field-item path") +goldWorld.acceptsMenuInput = function() return false end +actions, err = gold:availableFieldActions() +T.eq(#actions, 0, "Gold hides actions while busy") +T.eq(err, "world is busy", "Gold distinguishes a busy world from no actions") + T.finish() From b45d783dee17fd9d9475bc539340030ab4a8aae1 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:09:10 +0200 Subject: [PATCH 06/12] feat(android): add asynchronous canvas readback --- .../jni/love/src/modules/graphics/Canvas.cpp | 11 ++- .../jni/love/src/modules/graphics/Canvas.h | 2 + .../src/modules/graphics/opengl/Canvas.cpp | 74 +++++++++++++++++++ .../love/src/modules/graphics/opengl/Canvas.h | 5 ++ .../love/src/modules/graphics/wrap_Canvas.cpp | 27 +++++++ 5 files changed, 118 insertions(+), 1 deletion(-) diff --git a/mobile/android/love/src/jni/love/src/modules/graphics/Canvas.cpp b/mobile/android/love/src/jni/love/src/modules/graphics/Canvas.cpp index 6d62ad63..7e683a48 100644 --- a/mobile/android/love/src/jni/love/src/modules/graphics/Canvas.cpp +++ b/mobile/android/love/src/jni/love/src/modules/graphics/Canvas.cpp @@ -154,6 +154,16 @@ love::image::ImageData *Canvas::newImageData(love::image::Image *module, int sli return module->newImageData(r.w, r.h, dataformat); } +bool Canvas::requestImageData() +{ + return false; +} + +love::image::ImageData *Canvas::pollImageData(love::image::Image *) +{ + return nullptr; +} + void Canvas::draw(Graphics *gfx, Quad *q, const Matrix4 &t) { if (gfx->isCanvasActive(this)) @@ -232,4 +242,3 @@ StringMap Canvas::settingTypes(Ca } // graphics } // love - diff --git a/mobile/android/love/src/jni/love/src/modules/graphics/Canvas.h b/mobile/android/love/src/jni/love/src/modules/graphics/Canvas.h index f1ff4ac3..4a644053 100644 --- a/mobile/android/love/src/jni/love/src/modules/graphics/Canvas.h +++ b/mobile/android/love/src/jni/love/src/modules/graphics/Canvas.h @@ -81,6 +81,8 @@ public: int getRequestedMSAA() const; virtual love::image::ImageData *newImageData(love::image::Image *module, int slice, int mipmap, const Rect &rect); + virtual bool requestImageData(); + virtual love::image::ImageData *pollImageData(love::image::Image *module); virtual void generateMipmaps() = 0; virtual int getMSAA() const = 0; diff --git a/mobile/android/love/src/jni/love/src/modules/graphics/opengl/Canvas.cpp b/mobile/android/love/src/jni/love/src/modules/graphics/opengl/Canvas.cpp index b43d8746..0d7103bf 100644 --- a/mobile/android/love/src/jni/love/src/modules/graphics/opengl/Canvas.cpp +++ b/mobile/android/love/src/jni/love/src/modules/graphics/opengl/Canvas.cpp @@ -23,6 +23,7 @@ #include "Graphics.h" #include // For min/max +#include namespace love { @@ -197,6 +198,9 @@ Canvas::Canvas(const Settings &settings) , texture(0) , renderbuffer(0) , actualSamples(0) + , readbackBuffer(0) + , readbackFence(nullptr) + , readbackSize(0) { format = getSizedFormat(format); @@ -314,6 +318,14 @@ bool Canvas::loadVolatile() void Canvas::unloadVolatile() { + if (readbackFence != nullptr) + glDeleteSync(readbackFence); + if (readbackBuffer != 0) + glDeleteBuffers(1, &readbackBuffer); + readbackFence = nullptr; + readbackBuffer = 0; + readbackSize = 0; + if (fbo != 0 || renderbuffer != 0 || texture != 0) { // This is a bit ugly, but we need some way to destroy the cached FBO @@ -480,6 +492,68 @@ love::image::ImageData *Canvas::newImageData(love::image::Image *module, int sli return data; } +bool Canvas::requestImageData() +{ + if (readbackFence != nullptr || !isReadable() + || !(GLAD_ES_VERSION_3_0 || GLAD_VERSION_3_2) + || texType != TEXTURE_2D + || (format != PIXELFORMAT_RGBA8 && format != PIXELFORMAT_sRGBA8) + || actualSamples > 0) + return false; + + auto gfx = Module::getInstance(Module::M_GRAPHICS); + if (gfx != nullptr && gfx->isCanvasActive(this)) + throw love::Exception("Canvas:requestImageData cannot be called while that Canvas is active."); + + const size_t size = (size_t) pixelWidth * (size_t) pixelHeight * 4; + if (readbackBuffer == 0) + glGenBuffers(1, &readbackBuffer); + + glBindBuffer(GL_PIXEL_PACK_BUFFER, readbackBuffer); + if (readbackSize != size) + { + glBufferData(GL_PIXEL_PACK_BUFFER, size, nullptr, GL_STREAM_READ); + readbackSize = size; + } + + GLuint currentfbo = gl.getFramebuffer(OpenGL::FRAMEBUFFER_ALL); + gl.bindFramebuffer(OpenGL::FRAMEBUFFER_ALL, getFBO()); + glReadPixels(0, 0, pixelWidth, pixelHeight, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + readbackFence = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0); + gl.bindFramebuffer(OpenGL::FRAMEBUFFER_ALL, currentfbo); + glBindBuffer(GL_PIXEL_PACK_BUFFER, 0); + return readbackFence != nullptr; +} + +love::image::ImageData *Canvas::pollImageData(love::image::Image *module) +{ + if (readbackFence == nullptr) + return nullptr; + + GLenum status = glClientWaitSync(readbackFence, 0, 0); + if (status == GL_TIMEOUT_EXPIRED) + return nullptr; + + glDeleteSync(readbackFence); + readbackFence = nullptr; + if (status == GL_WAIT_FAILED) + return nullptr; + + love::image::ImageData *data = module->newImageData(pixelWidth, pixelHeight, PIXELFORMAT_RGBA8); + glBindBuffer(GL_PIXEL_PACK_BUFFER, readbackBuffer); + void *pixels = glMapBufferRange(GL_PIXEL_PACK_BUFFER, 0, readbackSize, GL_MAP_READ_BIT); + if (pixels == nullptr) + { + glBindBuffer(GL_PIXEL_PACK_BUFFER, 0); + data->release(); + throw love::Exception("Could not map asynchronous Canvas readback."); + } + memcpy(data->getData(), pixels, readbackSize); + glUnmapBuffer(GL_PIXEL_PACK_BUFFER); + glBindBuffer(GL_PIXEL_PACK_BUFFER, 0); + return data; +} + void Canvas::generateMipmaps() { if (getMipmapCount() == 1 || getMipmapMode() == MIPMAPS_NONE) diff --git a/mobile/android/love/src/jni/love/src/modules/graphics/opengl/Canvas.h b/mobile/android/love/src/jni/love/src/modules/graphics/opengl/Canvas.h index 4576cb5f..be5c785d 100644 --- a/mobile/android/love/src/jni/love/src/modules/graphics/opengl/Canvas.h +++ b/mobile/android/love/src/jni/love/src/modules/graphics/opengl/Canvas.h @@ -54,6 +54,8 @@ public: ptrdiff_t getHandle() const override; love::image::ImageData *newImageData(love::image::Image *module, int slice, int mipmap, const Rect &rect) override; + bool requestImageData() override; + love::image::ImageData *pollImageData(love::image::Image *module) override; void generateMipmaps() override; int getMSAA() const override @@ -107,6 +109,9 @@ private: GLenum status; int actualSamples; + GLuint readbackBuffer; + GLsync readbackFence; + size_t readbackSize; static SupportedFormat supportedFormats[PIXELFORMAT_MAX_ENUM]; static SupportedFormat checkedFormats[PIXELFORMAT_MAX_ENUM]; diff --git a/mobile/android/love/src/jni/love/src/modules/graphics/wrap_Canvas.cpp b/mobile/android/love/src/jni/love/src/modules/graphics/wrap_Canvas.cpp index c4bc7aa6..a686555c 100644 --- a/mobile/android/love/src/jni/love/src/modules/graphics/wrap_Canvas.cpp +++ b/mobile/android/love/src/jni/love/src/modules/graphics/wrap_Canvas.cpp @@ -116,6 +116,31 @@ int w_Canvas_newImageData(lua_State *L) return 1; } +int w_Canvas_requestImageData(lua_State *L) +{ + Canvas *canvas = luax_checkcanvas(L, 1); + bool requested = false; + luax_catchexcept(L, [&](){ requested = canvas->requestImageData(); }); + luax_pushboolean(L, requested); + return 1; +} + +int w_Canvas_pollImageData(lua_State *L) +{ + Canvas *canvas = luax_checkcanvas(L, 1); + love::image::Image *image = luax_getmodule(L, love::image::Image::type); + love::image::ImageData *data = nullptr; + luax_catchexcept(L, [&](){ data = canvas->pollImageData(image); }); + if (data == nullptr) + { + lua_pushnil(L); + return 1; + } + luax_pushtype(L, data); + data->release(); + return 1; +} + int w_Canvas_generateMipmaps(lua_State *L) { Canvas *c = luax_checkcanvas(L, 1); @@ -139,6 +164,8 @@ static const luaL_Reg w_Canvas_functions[] = { "getMSAA", w_Canvas_getMSAA }, { "renderTo", w_Canvas_renderTo }, { "newImageData", w_Canvas_newImageData }, + { "requestImageData", w_Canvas_requestImageData }, + { "pollImageData", w_Canvas_pollImageData }, { "generateMipmaps", w_Canvas_generateMipmaps }, { "getMipmapMode", w_Canvas_getMipmapMode }, { 0, 0 } From fcb5d1d348899402c721a169cb6d57970965dabb Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:25:56 +0200 Subject: [PATCH 07/12] feat(mods): expose read-only battle snapshots --- docs/modding.md | 27 ++++ src/battle/BattleAPI.lua | 200 +++++++++++++++++++++++++++++ src/battle/BattleState.lua | 22 +++- src/battle/Catching.lua | 43 +++++-- src/battle/Damage.lua | 34 +++-- src/battle/gen2/BattleAPI.lua | 119 +++++++++++++++++ src/inventory/ItemEffects.lua | 6 + src/mods/Loader.lua | 12 +- src/render/TextBox.lua | 10 ++ tests/mod_battle_snapshot_test.lua | 139 ++++++++++++++++++++ 10 files changed, 583 insertions(+), 29 deletions(-) create mode 100644 src/battle/BattleAPI.lua create mode 100644 src/battle/gen2/BattleAPI.lua create mode 100644 tests/mod_battle_snapshot_test.lua diff --git a/docs/modding.md b/docs/modding.md index 4856b6c1..d3028b70 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -235,6 +235,33 @@ not need generation-specific badge, terrain, bike, fishing, or field-move logic. Action lists are extensible; callers should render the records they understand and ignore unknown ids rather than assuming a fixed list length. +## Read-only battle snapshots + +`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle +record while one is active. Gen 1 (Red, Blue, and Yellow) and Gold expose the +same core fields: +`revision`, `kind`, `catchable`, `prompt`, `message`, `turn`, `player`, +`enemy`, `party`, `moves`, and `items`. Pokémon, moves, messages, and items in +the result are detached records; changing them cannot change the battle. +`revision` stays stable while the visible battle context is unchanged and +advances when it changes, so a UI can skip rebuilding an identical view. + +Pokémon records contain `species`, `name`, `level`, `hp`, `maxHp`, `status`, +and `active` (plus `slot` in `party`). Move records contain `slot`, `id`, +`name`, `pp`, `maxPp`, `type`, `power`, `accuracy`, and `disabled`. Gen 1 also +reports the actual ruleset-aware `displayPower`, `hitChance` percentage, and +`effectiveness` multiplier (`10` neutral, `20` super-effective, `5` +resisted). Item rows contain `id`, `name`, `count`, `ball`, `needsTarget`, and +an optional stock `catchChance` percentage. + +`prompt` describes the currently visible choice (`menu`, `moves`, `party`, +`advance`, `safari`, or `mimic`) and is `locked` when another screen or battle +phase owns input. Generation-specific features remain optional: Gen 1 includes +battle medicine, balls, catch previews, Safari balls, and Mimic choices; +Gold currently returns an empty `items` list rather than guessing at its +pocketed PACK flow. Callers should ignore unknown fields and tolerate absent +optional ones. + ## Rendering pipelines Most registries hand the engine *content*. `render_pipelines` hands it diff --git a/src/battle/BattleAPI.lua b/src/battle/BattleAPI.lua new file mode 100644 index 00000000..9affe7f9 --- /dev/null +++ b/src/battle/BattleAPI.lua @@ -0,0 +1,200 @@ +-- Read-only Gen 1 battle state for companion UIs and accessibility mods. + +local Damage = require("src.battle.Damage") +local ItemEffects = require("src.inventory.ItemEffects") +local TypeChart = require("src.battle.TypeChart") + +local BattleAPI = {} +BattleAPI.__index = BattleAPI + +function BattleAPI.new(game) + return setmetatable({ game = game, revision = 0, signature = nil }, BattleAPI) +end + +local function activeBattle(game) + local states = game and game.stack and game.stack.states or {} + for i = #states, 1, -1 do + if states[i].isBattleState then return states[i], states[#states] end + end +end + +local function monCopy(data, mon, active) + if not mon then return nil end + local def = data.pokemon[mon.species] + return { + species = mon.species, + name = mon.nickname or (def and def.name) or mon.species, + level = mon.level, hp = mon.hp, + maxHp = mon.stats and mon.stats.hp or mon.hp, + status = mon.status, active = active and true or false, + } +end + +local function visibleMessage(battle, top) + local source = top and top.isTextBox and top or top == battle and battle + local lines = source and source.visibleText and source:visibleText() + if not lines then return nil end + local copy = {} + for i, line in ipairs(lines) do copy[i] = tostring(line) end + return copy +end + +local function signature(game, battle, top) + if not battle then return "none" end + local parts = { tostring(battle), tostring(top), battle.phase or "", + tostring(battle.turnCount or 0), tostring(#(battle.queue or {})), + tostring(battle.current), tostring(battle.msgWaiting), + tostring(battle.msgPrompt), tostring(battle.menuIndex), + tostring(battle.mimicIndex), + tostring(battle.safari and battle.safari.balls), + tostring(battle.ghost), tostring(battle.noCatch), + table.concat(visibleMessage(battle, top) or {}, "\n") } + for _, battler in ipairs({ battle.player, battle.enemy }) do + local mon = battler and battler.mon + parts[#parts + 1] = tostring(mon) + parts[#parts + 1] = tostring(mon and mon.hp) + parts[#parts + 1] = tostring(mon and mon.status) + parts[#parts + 1] = table.concat(battler and battler.curTypes or {}, ",") + end + for _, mon in ipairs(game.save.party or {}) do + parts[#parts + 1] = tostring(mon) + parts[#parts + 1] = tostring(mon.hp) + parts[#parts + 1] = tostring(mon.status) + end + local inventory = {} + for id, count in pairs(game.save.inventory or {}) do + if ItemEffects.isBall(id) or ItemEffects.isBattleMedicine(id) then + inventory[#inventory + 1] = id .. "=" .. tostring(count) + end + end + table.sort(inventory) + for _, item in ipairs(inventory) do parts[#parts + 1] = item end + return table.concat(parts, "|") +end + +function BattleAPI:_revision(battle, top) + local nextSignature = signature(self.game, battle, top) + if nextSignature ~= self.signature then + self.signature = nextSignature + self.revision = self.revision + 1 + end + return self.revision +end + +local IMMUNITY_ONLY = { SPECIAL_DAMAGE_EFFECT = true, + SUPER_FANG_EFFECT = true, OHKO_EFFECT = true } + +local function movePreview(battle, move, def) + local record = battle.effectRecord and battle:effectRecord(def.effect) + local power, typeMult = def.power or 0 + if power > 0 and move.id ~= "COUNTER" then + local raw = TypeChart.effectiveness(def.type, battle.enemy.curTypes or {}) + if IMMUNITY_ONLY[def.effect] then + typeMult = raw == 0 and 0 or 10 + elseif not (record and record.chooseDamage) then + typeMult = raw + end + end + local hitChance + if record and record.neverMiss then + hitChance = 100 + elseif not (record and record.gate) + and (power > 0 or (record and record.accuracyChecked)) then + hitChance = battle.enemy.invulnerable and 0 + or Damage.accuracyChance(battle.ruleset, def, + battle.player, battle.enemy) + end + local displayPower = power > 0 and move.id ~= "COUNTER" + and not IMMUNITY_ONLY[def.effect] + and not (record and record.chooseDamage) and power or nil + return typeMult, hitChance, displayPower +end + +local function moveCopies(game, battle) + local out = {} + for slot, move in ipairs((battle.player and battle.player.curMoves) or {}) do + local def = game.data.moves[move.id] or {} + local mult, hitChance, displayPower = movePreview(battle, move, def) + out[slot] = { slot = slot, id = move.id, name = def.name or move.id, + pp = move.pp, + maxPp = (def.pp or move.pp or 0) + + (move.ppUps or 0) * math.floor((def.pp or 0) / 5), + type = def.type, power = def.power, accuracy = def.accuracy, + displayPower = displayPower, hitChance = hitChance, + effectiveness = mult, + disabled = battle.player.disabledSlot == slot } + end + return out +end + +local function itemCopies(game, battle, catchable) + local out = {} + for id, count in pairs(game.save.inventory or {}) do + if count > 0 + and (ItemEffects.isBall(id) or ItemEffects.isBattleMedicine(id)) then + local def = game.data.items[id] or {} + local ball = ItemEffects.isBall(id) + out[#out + 1] = { id = id, name = def.name or id, count = count, + ball = ball, needsTarget = not ball, + catchChance = ball and catchable and battle.catchChance + and battle:catchChance(id) or nil } + end + end + table.sort(out, function(a, b) return a.name < b.name end) + return out +end + +local function mimicCopies(game, battle) + local out = {} + for i, move in ipairs(battle.mimicMoves or {}) do + local def = game.data.moves[move.id] or {} + out[i] = { index = i, slot = move.slot, id = move.id, + name = def.name or move.id } + end + return out +end + +function BattleAPI:snapshot() + local game = self.game + local battle, top = activeBattle(game) + if not battle then return nil end + local kind = battle:battleKind() + local supported = kind ~= "oldman" and kind ~= "link" + local catchable = kind == "wild" and not battle.ghost and not battle.noCatch + local forcedParty = top and top.isPartyMenu and top.battle == battle + and top.forceSwitch + local canAdvance = supported and ((top == battle + and battle.phase == "messages" and battle.current + and (battle.msgWaiting or battle.msgPrompt)) + or (top and top.isTextBox and not top.choice + and (top.waiting or top.done))) + local prompt = "locked" + if canAdvance then prompt = "advance" + elseif supported and forcedParty then prompt = "party" + elseif supported and top == battle and kind == "safari" + and battle.phase == "menu" then prompt = "safari" + elseif supported and top == battle and battle.phase == "mimicSelect" then + prompt = "mimic" + elseif supported and top == battle and battle.phase == "menu" then + prompt = "menu" + elseif supported and top == battle and battle.phase == "moveSelect" then + prompt = "moves" + end + local party = {} + for i, mon in ipairs(game.save.party or {}) do + party[i] = monCopy(game.data, mon, + battle.player and battle.player.mon == mon) + party[i].slot = i + end + return { revision = self:_revision(battle, top), kind = kind, + catchable = catchable, prompt = prompt, + message = visibleMessage(battle, top), turn = battle.turnCount or 0, + player = monCopy(game.data, battle.player and battle.player.mon, true), + enemy = monCopy(game.data, battle.enemy and battle.enemy.mon, true), + party = party, moves = moveCopies(game, battle), + items = itemCopies(game, battle, catchable), + safariBalls = battle.safari and battle.safari.balls or nil, + mimicMoves = mimicCopies(game, battle), mimicIndex = battle.mimicIndex } +end + +return BattleAPI diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 38c1bdd7..6228dc8f 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -38,6 +38,7 @@ local romText = RomText local BattleState = {} BattleState.__index = BattleState BattleState.isOpaque = true +BattleState.isBattleState = true -- Category identity for per-category GAME SPEED (RFC 0007), the same -- style OverworldController.isOverworld already uses. Every battle -- @@ -156,6 +157,13 @@ function BattleState:caughtMarkerVisible() function() return false end, self) == true end +function BattleState:catchChance(ball, rateOverride) + if Runtime.wantsHook("catch.rate") then return nil end + return Catching.chance(ball, self.enemy.mon, self.enemy.def, rateOverride, + { ballDef = self:ballDef(ball), statuses = self.data.statuses, + battle = self }) +end + function BattleState:moveGridNavigation() if self:wideLayout() then return true end if not Runtime.wantsHook("battle.move_grid_navigation") then return false end @@ -1166,7 +1174,7 @@ function BattleState:startMessage(item) local npos = text:find("[\n\v]", pos) local chunk = npos and text:sub(pos, npos - 1) or text:sub(pos) local codes = Font.encode(chunk) - self.lines[#self.lines + 1] = { codes = codes, cont = cont } + self.lines[#self.lines + 1] = { codes = codes, cont = cont, text = chunk } self.total = self.total + #codes if not npos then break end cont = text:sub(npos, npos) == "\v" @@ -1199,6 +1207,18 @@ function BattleState:beginMsgLine() self.shown[#self.shown + 1] = {} end +function BattleState:visibleText() + if self.phase ~= "messages" or not (self.current or self.animPlaying) then + return nil + end + local out, count = {}, #(self.shown or {}) + for i = math.max(1, self.lineIndex - count + 1), self.lineIndex do + local line = self.lines and self.lines[i] + if line then out[#out + 1] = line.text or "" end + end + return #out > 0 and out or nil +end + function BattleState:updateQueue() if self.waitingUI then if self.game.stack:top() ~= self then return true end diff --git a/src/battle/Catching.lua b/src/battle/Catching.lua index d7e6d94f..b4ede2ab 100644 --- a/src/battle/Catching.lua +++ b/src/battle/Catching.lua @@ -27,6 +27,17 @@ Catching.BALLS = BALLS -- divisor, which is what the old per-field `or` defaults resolved to local DEFAULT_BALL = { randMax = 255, hpFactor = 12, wobbleFactor = 150 } +local function stockFactors(def, targetMon, targetDef, rateOverride, statuses) + local rate = rateOverride or targetDef.catchRate + local record = Status.recordFor(statuses, targetMon.status) + local statusBonus = record and record.catchBonus or 0 + local hpQuarter = math.max(1, math.floor(targetMon.hp / 4)) + local factor = def.hpFactor or DEFAULT_BALL.hpFactor + local f = math.min(255, math.floor(math.floor( + targetMon.stats.hp * 255 / factor) / hpQuarter)) + return rate, statusBonus, f, record +end + function Catching.registerInto(registry, _, owner) for id, record in pairs(BALLS) do registry:register(id, record, owner) @@ -41,21 +52,9 @@ end local function stockAttempt(def, targetMon, targetDef, rng, rateOverride, statuses) if def.autoCatch then return true, 3 end local randMax = def.randMax - local rate = rateOverride or targetDef.catchRate - - -- the status subtraction and the wobble bonus come off the merged - -- status record (SLP/FRZ 25 and +10, the rest 12 and +5) local s = targetMon.status - local record = Status.recordFor(statuses, s) - local statusBonus = record and record.catchBonus or 0 - - -- HP factor (X) - local maxhp = targetMon.stats.hp - local hpQuarter = math.max(1, math.floor(targetMon.hp / 4)) - local factor = def.hpFactor or DEFAULT_BALL.hpFactor - -- the 255 cap applies only after BOTH divisions (ItemUseBall keeps - -- the intermediate in 16 bits); capping early collapses the value - local f = math.min(255, math.floor(math.floor(maxhp * 255 / factor) / hpQuarter)) + local rate, statusBonus, f, record = stockFactors( + def, targetMon, targetDef, rateOverride, statuses) local function shakes() local ballFactor2 = def.wobbleFactor or DEFAULT_BALL.wobbleFactor @@ -80,6 +79,22 @@ local function stockAttempt(def, targetMon, targetDef, rng, rateOverride, status return false, shakes() end +-- Exact stock catch probability for read-only previews. A custom attempt +-- function may do anything, so nil is safer than presenting a plausible lie. +function Catching.chance(ball, targetMon, targetDef, rateOverride, opts) + opts = opts or {} + local def = opts.ballDef or BALLS[ball] or DEFAULT_BALL + if def.attempt then return nil end + if def.autoCatch then return 100 end + local rate, statusBonus, f = stockFactors( + def, targetMon, targetDef, rateOverride, opts.statuses) + local outcomes = def.randMax + 1 + local automatic = math.min(outcomes, math.max(0, statusBonus)) + local passed = math.min(outcomes, math.max(0, rate + statusBonus + 1)) + return (automatic + (passed - automatic) * (f + 1) / 256) + * 100 / outcomes +end + -- Returns caught, shakes (0-3). rateOverride replaces the species catch -- rate (the Safari game's BAIT/ROCK-modified wEnemyMonActualCatchRate). -- opts (all optional): ballDef = the merged ball record, statuses = the diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index 47c0962d..894bfd44 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -83,25 +83,35 @@ function Damage.critRoll(ruleset, attacker, moveId, rng, highCrit) return rng(0, 255) < b end --- Accuracy test: rand(0..255) < floor(accuracy * 255 / 100) adjusted by --- accuracy/evasion stages. With oneIn256Miss a max-accuracy move still --- misses on 255. -function Damage.accuracyRoll(ruleset, move, attacker, defender, rng) - rng = rng or love.math.random +-- Exact number of the 256 RNG outcomes that pass MoveHitTest. Keeping the +-- threshold public lets read-only UIs preview the same rules the roll uses. +function Damage.accuracyThreshold(ruleset, move, attacker, defender) -- X ACCURACY sets USING_X_ACCURACY: the move simply never misses -- (MoveHitTest returns before any accuracy math, 1/256 included) - if attacker.xAccuracy then return true end + if attacker.xAccuracy then return 256 end local acc = math.floor(move.accuracy * 255 / 100) + local accuracyStage = attacker.stages and attacker.stages.accuracy or 0 + local evasionStage = defender.stages and defender.stages.evasion or 0 -- CalcHitChance scales by the accuracy stage and the evasion stage as -- two separate ratio multiplications, clamping each result - acc = math.min(255, Stats.applyStage(acc, - attacker.stages and attacker.stages.accuracy or 0)) - acc = math.min(255, Stats.applyStage(acc, - -(defender.stages and defender.stages.evasion or 0))) + acc = math.min(255, Stats.applyStage(acc, accuracyStage)) + acc = math.min(255, Stats.applyStage(acc, -evasionStage)) if not ruleset.oneIn256Miss and move.accuracy >= 100 - and (attacker.stages.accuracy or 0) >= (defender.stages.evasion or 0) then - return true + and accuracyStage >= evasionStage then + return 256 end + return acc +end + +function Damage.accuracyChance(ruleset, move, attacker, defender) + return Damage.accuracyThreshold(ruleset, move, attacker, defender) * 100 / 256 +end + +-- Accuracy test: rand(0..255) < the shared ruleset-aware threshold. +function Damage.accuracyRoll(ruleset, move, attacker, defender, rng) + rng = rng or love.math.random + local acc = Damage.accuracyThreshold(ruleset, move, attacker, defender) + if acc == 256 then return true end return rng(0, 255) < acc end diff --git a/src/battle/gen2/BattleAPI.lua b/src/battle/gen2/BattleAPI.lua new file mode 100644 index 00000000..23ea585e --- /dev/null +++ b/src/battle/gen2/BattleAPI.lua @@ -0,0 +1,119 @@ +-- Read-only Gen 2 battle state with the same shape as mod.battle on Gen 1. + +local BattleAPI = {} +BattleAPI.__index = BattleAPI + +function BattleAPI.new(game) + return setmetatable({ game = game, revision = 0, signature = nil }, BattleAPI) +end + +local function activeBattle(game) + local states = game and game.stack and game.stack.states or {} + local battle + for i = #states, 1, -1 do + local state = states[i] + if state.screenId == "Gen2BattleState" or state.isGen2BattleState then + battle = state + break + end + end + return battle, states[#states] +end + +local function monCopy(data, mon, active) + if not mon then return nil end + local def = data and data.pokemon and data.pokemon[mon.species] + return { species = mon.species, + name = mon.nickname or (def and def.name) or mon.species, + level = mon.level, hp = mon.hp, + maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or mon.hp, + status = mon.status, active = active and true or false } +end + +local function messageCopy(screen) + if not screen.message then return nil end + local lines = {} + for line in tostring(screen.message):gmatch("[^\n]+") do + lines[#lines + 1] = line + end + return #lines > 0 and lines or nil +end + +local function signature(game, screen, top) + if not screen then return "none" end + local battle = screen.battle or {} + local parts = { tostring(screen), tostring(top), tostring(screen.phase), + tostring(screen.message), tostring(screen.messageTimer), + tostring(screen.menuIndex), tostring(screen.moveIndex), + tostring(battle.turn), tostring(battle.over), tostring(battle.outcome) } + for _, mon in ipairs({ battle.player, battle.enemy }) do + parts[#parts + 1] = tostring(mon) + parts[#parts + 1] = tostring(mon and mon.hp) + parts[#parts + 1] = tostring(mon and mon.status) + end + for _, mon in ipairs((game.save and game.save.party) or battle.party or {}) do + parts[#parts + 1] = tostring(mon) + parts[#parts + 1] = tostring(mon.hp) + parts[#parts + 1] = tostring(mon.status) + end + return table.concat(parts, "|") +end + +function BattleAPI:_revision(screen, top) + local nextSignature = signature(self.game, screen, top) + if nextSignature ~= self.signature then + self.signature = nextSignature + self.revision = self.revision + 1 + end + return self.revision +end + +local function moveCopies(game, battle) + local out = {} + for slot, move in ipairs((battle.player and battle.player.moves) or {}) do + local def = (game.data.moves or {})[move.id] or {} + out[slot] = { slot = slot, id = move.id, name = def.name or move.id, + pp = move.pp, maxPp = move.maxPp or def.pp or move.pp, + type = def.type, power = def.power, accuracy = def.accuracy, + disabled = battle:moveDisabled(battle.player, move.id) } + end + return out +end + +local function battleKind(screen) + if screen.tutorial then return "oldman" end + return screen.battle and screen.battle.wild and "wild" or "trainer" +end + +function BattleAPI:snapshot() + local game = self.game + local screen, top = activeBattle(game) + if not screen or not screen.battle then return nil end + local battle = screen.battle + local prompt = "locked" + if top == screen and screen.phase == "menu" then + prompt = "menu" + elseif top == screen and screen.phase == "moves" then + prompt = "moves" + elseif top == screen and screen.message then + prompt = "advance" + elseif top and top.screenId == "Gen2PartyMenu" then + prompt = "party" + end + local party = {} + for i, mon in ipairs((game.save and game.save.party) or battle.party or {}) do + party[i] = monCopy(game.data, mon, mon == battle.player) + party[i].slot = i + end + return { revision = self:_revision(screen, top), kind = battleKind(screen), + catchable = battle.wild and not screen.tutorial, prompt = prompt, + message = messageCopy(screen), turn = battle.turn or 0, + player = monCopy(game.data, battle.player, true), + enemy = monCopy(game.data, battle.enemy, true), + party = party, moves = moveCopies(game, battle), + -- Gold's PACK is pocketed and target selection is screen-owned. Omit it + -- until the engine can expose the same semantic item records as Gen 1. + items = {} } +end + +return BattleAPI diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index dfc34125..a900fec9 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -83,6 +83,12 @@ function ItemEffects.healsHP(id) or id == "REVIVE" or id == "MAX_REVIVE" end +function ItemEffects.isBattleMedicine(id) + return HEAL_AMOUNT[id] ~= nil or STATUS_HEAL[id] ~= nil + or id == "MAX_POTION" or id == "FULL_RESTORE" + or id == "REVIVE" or id == "MAX_REVIVE" +end + -- Does this item need a party-member target? -- 'data' is optional for compat purposes; targeting falls back to itemDef/vanilla detection function ItemEffects.needsTarget(id, itemDef, data) diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 79fb87c5..5dbad269 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1308,7 +1308,7 @@ function Loader:_api(mod) -- mod.world materializes on first touch, like the image helper above: a -- headless load must not drag the world stack in, and the Game the facade -- acts on is still being wired when the entry chunk runs - local world + local world, battle setmetatable(api, { __index = function(_, key) -- mod.game is the live service owner, resolved per generation the way -- mod.world is: src/core/Game.lua's singleton under Gen 1, the Game2 @@ -1317,9 +1317,17 @@ function Loader:_api(mod) -- entry chunk runs. This is what a mod should hold instead of requiring -- src.core.Game, which under Gold hands back a table nothing instantiated. if key == "game" then return loader:_game() end + local game = loader:_game() + if key == "battle" then + if battle then return battle end + local module = game and engineRequire(loader.generation == 2 + and "src.battle.gen2.BattleAPI" or "src.battle.BattleAPI") + if not module then return nil end + battle = module.new(game) + return battle + end if key ~= "world" then return nil end if world then return world end - local game = loader:_game() -- one facade name, one arm per generation: Gold's world is not a stack -- state and its flags are a bitfield, so the resolution differs even -- where the method set does not (src/world/gen2/WorldAPI.lua) diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 3843cbc1..81b46eeb 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -253,6 +253,16 @@ function TextBox:beginLine() table.insert(self.shown, {}) end +function TextBox:visibleText() + local page = self.pages[self.pageIndex] + if not page then return nil end + local out, count = {}, #(self.shown or {}) + for i = math.max(1, self.lineIndex - count + 1), self.lineIndex do + if page[i] ~= nil then out[#out + 1] = page[i] end + end + return #out > 0 and out or nil +end + function TextBox:update(dt) local input = self.game.input self.blink = (self.blink + 1) % 60 diff --git a/tests/mod_battle_snapshot_test.lua b/tests/mod_battle_snapshot_test.lua new file mode 100644 index 00000000..18e9d0b9 --- /dev/null +++ b/tests/mod_battle_snapshot_test.lua @@ -0,0 +1,139 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local S = require("tests.harness").suite("mod battle snapshot") +local check, eq = S.check, S.eq + +check(require("src.battle.BattleState").isBattleState == true, + "Gen 1 battle states carry the discovery marker") + +local TypeChart = require("src.battle.TypeChart") +TypeChart.load({ type_chart = { + types = { NORMAL = { name = "NORMAL", category = "physical" } }, + matchups = {}, +} }) + +local Damage = require("src.battle.Damage") +local attacker, defender = { stages = {} }, { stages = {} } +eq(Damage.accuracyThreshold({ oneIn256Miss = true }, { accuracy = 100 }, + attacker, defender), 255, "faithful accuracy keeps the 1-in-256 miss") +eq(Damage.accuracyThreshold({ oneIn256Miss = false }, { accuracy = 100 }, + attacker, defender), 256, "clean accuracy exposes a certain hit") +local Catching = require("src.battle.Catching") +eq(Catching.chance("MASTER_BALL", { hp = 1, stats = { hp = 1 } }, + { catchRate = 1 }), 100, "Master Ball preview is certain") +check(Catching.chance("MOD_BALL", { hp = 1, stats = { hp = 1 } }, + { catchRate = 1 }, nil, { ballDef = { attempt = function() end } }) == nil, + "custom ball logic does not receive a guessed preview") + +local mon = { species = "TESTMON", level = 5, hp = 18, + stats = { hp = 20 }, moves = {} } +local game = { + data = { + pokemon = { TESTMON = { name = "TESTMON", catchRate = 255 } }, + moves = { TACKLE = { name = "TACKLE", type = "NORMAL", power = 35, + accuracy = 95, pp = 35 } }, + items = { POTION = { name = "POTION" }, + POKE_BALL = { name = "POKE BALL" } }, + }, + save = { party = { mon }, inventory = { POTION = 1, POKE_BALL = 1 } }, + stack = { states = {} }, +} +local battle = { + isBattleState = true, phase = "menu", queue = {}, + ruleset = { oneIn256Miss = true }, + player = { mon = mon, curTypes = { "NORMAL" }, stages = {}, + curMoves = { { id = "TACKLE", pp = 35 } } }, + enemy = { mon = { species = "TESTMON", level = 4, hp = 12, + stats = { hp = 12 }, moves = {} }, curTypes = { "NORMAL" }, stages = {} }, +} +function battle:battleKind() return "wild" end +function battle:effectRecord() return { accuracyChecked = true } end +function battle:visibleText() return { "Wild TESTMON appeared!" } end +function battle:catchChance(ball) + return require("src.battle.Catching").chance(ball, self.enemy.mon, + game.data.pokemon[self.enemy.mon.species]) +end +game.stack.states = { battle } + +local api = require("src.battle.BattleAPI").new(game) +local snapshot = api:snapshot() +check(snapshot and snapshot.kind == "wild" and snapshot.prompt == "menu", + "Gen 1 battle is exposed") +eq(snapshot.player.maxHp, 20, "Gen 1 max HP comes from battle stats") +eq(snapshot.moves[1].name, "TACKLE", "move records are copied") +eq(snapshot.message[1], "Wild TESTMON appeared!", "battle text is copied") +eq(#snapshot.items, 2, "medicine and balls are exposed") +check(type(snapshot.items[1].catchChance) == "number" + or type(snapshot.items[2].catchChance) == "number", + "stock catch chance is available") +snapshot.player.hp = 0 +snapshot.moves[1].pp = 0 +eq(mon.hp, 18, "changing a snapshot cannot change a Pokemon") +eq(battle.player.curMoves[1].pp, 35, + "changing a snapshot cannot change a move") +local same = api:snapshot() +eq(same.revision, snapshot.revision, "unchanged battle keeps its revision") +battle.enemy.mon.hp = 5 +check(api:snapshot().revision > same.revision, + "observable battle changes advance the revision") +game.stack.states = {} +check(api:snapshot() == nil, "Gen 1 returns nil outside a battle") +game.stack.states = { battle } + +local player2 = { species = "CHIKORITA", level = 5, hp = 20, + maxHp = 21, moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } } +local enemy2 = { species = "RATTATA", level = 3, hp = 12, maxHp = 12, + moves = {} } +local battle2 = { player = player2, enemy = enemy2, party = { player2 }, + wild = true, turn = 0 } +function battle2:moveDisabled() return false end +local screen2 = { screenId = "Gen2BattleState", battle = battle2, + phase = "menu", menuIndex = 1, moveIndex = 1 } +local game2 = { + data = { + pokemon = { CHIKORITA = { name = "CHIKORITA" }, + RATTATA = { name = "RATTATA" } }, + moves = { TACKLE = { name = "TACKLE", type = "NORMAL", + power = 35, accuracy = 95, pp = 35 } }, + }, + save = { party = { player2 } }, stack = { states = { screen2 } }, +} + +local api2 = require("src.battle.gen2.BattleAPI").new(game2) +local snapshot2 = api2:snapshot() +check(snapshot2 and snapshot2.kind == "wild" and snapshot2.prompt == "menu", + "Gold battle is discovered through its screen id") +eq(snapshot2.player.maxHp, 21, "Gold max HP uses the mon field") +eq(snapshot2.moves[1].name, "TACKLE", "Gold moves are copied") +snapshot2.player.hp = 0 +snapshot2.moves[1].pp = 0 +eq(player2.hp, 20, "changing a snapshot cannot change a Gold Pokemon") +eq(player2.moves[1].pp, 35, + "changing a snapshot cannot change a Gold move") +screen2.message = "A wild RATTATA appeared!" +screen2.phase = "resolving" +local message2 = api2:snapshot() +eq(message2.prompt, "advance", "Gold message state is exposed") +check(message2.revision > snapshot2.revision, + "Gold battle changes advance the revision") +game2.stack.states = {} +check(api2:snapshot() == nil, "Gold returns nil outside a battle") +game2.stack.states = { screen2 } + +local Loader = require("src.mods.Loader") +local fs = { read = function() end, getInfo = function() end, + getDirectoryItems = function() return {} end } +local mod = { path = "mods/snapshot_test", manifest = { + id = "snapshot_test", version = "1.0.0", permissionSet = {}, +} } +local loader1 = Loader.new({ fs = fs, generation = 1 }) +loader1.game = game +check(loader1:_api(mod).battle:snapshot().kind == "wild", + "mod.battle selects the Gen 1 facade") +local loader2 = Loader.new({ fs = fs, generation = 2 }) +loader2.game = game2 +check(loader2:_api(mod).battle:snapshot().kind == "wild", + "mod.battle selects the Gen 2 facade") + +S.finish() From 3e3566d1b4b4edd08d3a63c4deffa99be291bacd Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Sun, 16 Aug 2026 01:04:01 +0100 Subject: [PATCH 08/12] Fix desktop postLog transport --- src/core/HostShell.lua | 38 +++++++++++---- tests/engine/host_shell_postlog.lua | 75 +++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 10 deletions(-) create mode 100644 tests/engine/host_shell_postlog.lua diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index 7f4a28ed..41068ed7 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -428,10 +428,29 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime) if type(body) ~= "string" then return nil, "missing body" end userAgent = userAgent or "gen1recomp" if HostShell.haveCurl() then - -- --data-binary @- keeps the payload out of argv (command-line length + -- io.popen is one-way on Lua/LuaJIT: its mode is "r" or "w", never + -- "rw". Stage the request body so the response can stay on a read pipe. + local bodyPath = os.tmpname() + local bodyFile, bodyOpenErr = io.open(bodyPath, "wb") + if not bodyFile then + pcall(os.remove, bodyPath) + return nil, "could not create request body: " .. tostring(bodyOpenErr) + end + local bodyOk, bodyErr = pcall(function() + assert(bodyFile:write(body)) + assert(bodyFile:close()) + end) + if not bodyOk then + pcall(function() bodyFile:close() end) + pcall(os.remove, bodyPath) + return nil, "could not write body: " .. tostring(bodyErr) + end + + -- --data-binary @ keeps the payload out of argv (command-line length -- limits on Windows) and preserves every byte including trailing - -- newlines. No -f, matching httpGet: the response body is discarded - -- anyway, and curl's stderr carries the real diagnosis on failure. + -- newlines. The body is staged above because io.popen cannot be opened + -- for both writing and reading. No -f, matching httpGet: the response + -- body is discarded anyway, and curl's stderr carries the diagnosis. local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https " .. "--connect-timeout 10 --max-time %d ") :format(tonumber(maxTime) or 40) @@ -441,18 +460,17 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime) cmd = cmd .. "-H " .. HostShell.quote("Content-Type: " .. contentType) .. " " end cmd = cmd .. "-H " .. HostShell.quote("Content-Length: " .. tostring(#body)) .. " " - .. "--data-binary @- " + .. "--data-binary " .. HostShell.quote("@" .. bodyPath) .. " " .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " " .. HostShell.quote(url) .. " 2>&1" - local pipe = HostShell.popen(cmd, "rw") - if not pipe then return nil, "could not run curl" end - local writeOk, werr = pcall(pipe.write, pipe, body) - if not writeOk then - HostShell.pclose(pipe) - return nil, "could not write body: " .. tostring(werr) + local pipe = HostShell.popen(cmd) + if not pipe then + pcall(os.remove, bodyPath) + return nil, "could not run curl" end local readOk, out = pcall(function() return pipe:read("*a") end) HostShell.pclose(pipe) + pcall(os.remove, bodyPath) if not readOk then return nil, fetchError(url, nil, tostring(out)) end diff --git a/tests/engine/host_shell_postlog.lua b/tests/engine/host_shell_postlog.lua new file mode 100644 index 00000000..2261aec2 --- /dev/null +++ b/tests/engine/host_shell_postlog.lua @@ -0,0 +1,75 @@ +-- HostShell.httpPost must work with Lua/LuaJIT's one-way io.popen. +-- +-- io.popen accepts "r" or "w", not "rw". POST needs both a request body +-- and a response status, so the body is staged in a temporary file and curl +-- is opened read-only for its response. +-- luajit tests/engine/host_shell_postlog.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +local HostShell = require("src.core.HostShell") + +local MARK = "\n__gen1recomp_http__" +local BODY_PATH = "/tmp/gen1recomp-postlog-body-test" +local URL = "https://logs.example.com/logs" +local BODY = "debug log body\n" + +local realOpen = io.open +local realPopen = io.popen +local realTmpname = os.tmpname +local realRemove = os.remove +local realHaveCurl = HostShell.haveCurl + +local openedPath, openedMode, writtenBody +local popenCommand, popenMode, removedPath + +HostShell.haveCurl = function() return true end +os.tmpname = function() return BODY_PATH end +os.remove = function(path) + removedPath = path + return true +end + +io.open = function(path, mode) + openedPath, openedMode = path, mode + return { + write = function(_, value) + writtenBody = value + return true + end, + close = function() return true end, + } +end + +io.popen = function(command, mode) + popenCommand, popenMode = command, mode + return { + read = function() return MARK .. "200" end, + close = function() return true end, + } +end + +local ok, err = HostShell.httpPost(URL, BODY, "text/plain", "gen1recomp-mod/test", 10) + +io.open = realOpen +io.popen = realPopen +os.tmpname = realTmpname +os.remove = realRemove +HostShell.haveCurl = realHaveCurl + +eq(ok, true, "a desktop POST succeeds through the read-only response pipe: " .. tostring(err)) +eq(openedPath, BODY_PATH, "the request body is written to a temporary file") +eq(openedMode, "wb", "the temporary request body is opened for binary writing") +eq(writtenBody, BODY, "the complete log body is staged") +eq(popenMode, "r", "curl is opened in the supported read-only mode") +check(popenCommand:find("--data-binary", 1, true) ~= nil, + "curl reads the staged body with --data-binary") +check(popenCommand:find(BODY_PATH, 1, true) ~= nil, + "curl receives the temporary body path") +check(popenCommand:find(BODY, 1, true) == nil, + "the log body is not placed directly in the command line") +eq(removedPath, BODY_PATH, "the temporary request body is removed") + +T.finish("host shell postlog") From c22888a7fdee327a094ffce80b60241f238470f9 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:43:27 +0200 Subject: [PATCH 09/12] feat(mods): add validated battle menu intents --- docs/modding.md | 19 +++++ src/battle/BattleAPI.lua | 57 +++++++++++++ src/battle/BattleState.lua | 125 +++++++++++++++++------------ src/battle/gen2/BattleAPI.lua | 53 ++++++++++++ src/ui/gen2/BattleState.lua | 106 ++++++++++++++---------- tests/mod_battle_snapshot_test.lua | 109 ++++++++++++++++++++++++- 6 files changed, 374 insertions(+), 95 deletions(-) diff --git a/docs/modding.md b/docs/modding.md index 899f3406..264ad9d5 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -264,6 +264,25 @@ Gold currently returns an empty `items` list rather than guessing at its pocketed PACK flow. Callers should ignore unknown fields and tolerate absent optional ones. +## Battle menu intents + +`mod.battle:submit(intent)` applies a validated choice to the snapshot the mod +just read. Every intent needs a mod-owned, strictly increasing positive +integer `id` and the latest snapshot `revision`. Stale, replayed, covered, or +invalid choices return `nil` plus a reason without changing the battle. + +The shared Red, Blue, Yellow, and Gold intents are: + +- `{ kind = "menu", choice = "fight" }` (`party`, `item`, and `run` are the + other accepted choices) +- `{ kind = "move", slot = 1..4 }` +- `{ kind = "back" }` while the move menu is active + +Menu choices and moves use the same engine methods as the native controls; +`party` and `item` open the native screens rather than exposing or duplicating +their mutable logic. Tutorial, link, Safari, forced, stale, and covered battle +states refuse these core intents. Use `mod.input` for ordinary text advance. + ## Rendering pipelines Most registries hand the engine *content*. `render_pipelines` hands it diff --git a/src/battle/BattleAPI.lua b/src/battle/BattleAPI.lua index 9affe7f9..6d94339f 100644 --- a/src/battle/BattleAPI.lua +++ b/src/battle/BattleAPI.lua @@ -197,4 +197,61 @@ function BattleAPI:snapshot() mimicMoves = mimicCopies(game, battle), mimicIndex = battle.mimicIndex } end +local MENU_CHOICES = { fight = true, party = true, item = true, run = true } + +local function validSlot(slot) + return type(slot) == "number" and slot % 1 == 0 and slot >= 1 +end + +function BattleAPI:submit(intent) + if type(intent) ~= "table" then return nil, "intent must be a table" end + if type(intent.id) ~= "number" or intent.id % 1 ~= 0 or intent.id < 1 then + return nil, "intent id must be a positive integer" + end + if self.lastIntentId and intent.id <= self.lastIntentId then + return nil, "replayed intent" + end + + local battle, top = activeBattle(self.game) + if not battle then return nil, "no battle" end + if intent.revision ~= self:_revision(battle, top) then + return nil, "stale battle context" + end + local kind = battle:battleKind() + if kind == "oldman" or kind == "link" or kind == "safari" then + return nil, "battle kind is not controllable" + end + if top ~= battle then return nil, "battle menu is covered" end + + local ok, err + if intent.kind == "menu" then + if battle.phase ~= "menu" then return nil, "battle menu is not active" end + if not MENU_CHOICES[intent.choice] then + return nil, "unknown battle menu choice" + end + ok, err = battle:chooseMenu(intent.choice) + elseif intent.kind == "move" then + if battle.phase ~= "moveSelect" then + return nil, "move menu is not active" + end + if battle.moveSwapIndex then return nil, "move reorder is active" end + local move = validSlot(intent.slot) and battle.player + and battle.player.curMoves[intent.slot] + if not move then return nil, "invalid move slot" end + if (move.pp or 0) <= 0 then return nil, "move has no PP" end + if battle.player.disabledSlot == intent.slot then + return nil, "move is disabled" + end + ok, err = battle:chooseMove(intent.slot) + elseif intent.kind == "back" then + ok, err = battle:cancelMove() + else + return nil, "unknown battle intent" + end + if not ok then return nil, err end + self.lastIntentId = intent.id + self.signature = nil + return true +end + return BattleAPI diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 6228dc8f..1bf9cd8b 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1951,6 +1951,77 @@ function BattleState:playerHasPP() return false end +-- One semantic path for the native command menu and mod.battle intents. +function BattleState:chooseMenu(choice) + if self.phase ~= "menu" then return nil, "battle menu is not active" end + if not self.player or not self.player.mon or self.player.mon.hp <= 0 + or self:menuLockedAction(self.player) then + return nil, "battle menu is not ready" + end + self:clearTurnFlinches() + if choice == "fight" and self.ghost then + self:say(Strings("%s is too\nscared to move!", self.player.name)) + self.phase = "messages" + self.afterQueue = "menu" + self:act(function() + self:executeAction(self.enemy, self.player, self:enemyAction()) + end) + -- A scared turn still ticks the player's residual effects. + self:queueResidual(self.player, self.enemy) + self:act(function() self:endOfTurn() end) + elseif choice == "fight" then + -- Trapping, Bide, and similar locks skip the move list. + local fightLock = self:fightLockedAction(self.player) + if fightLock then + self:resolveTurn(fightLock) + elseif not self:playerHasPP() then + -- No usable PP goes straight to Struggle. + self:say(Strings("%s has no\nmoves left!", self.player.name)) + self:resolveTurn({ id = "STRUGGLE", pp = 1, struggle = true }) + else + self.phase = "moveSelect" + self.moveIndex = math.min(self.moveIndex, #self.player.curMoves) + self.moveSwapIndex = nil + end + elseif choice == "run" then + self:tryRun() + elseif choice == "item" then + self:openItems() + elseif choice == "party" then + self:openParty() + else + return nil, "unknown battle menu choice" + end + return true +end + +function BattleState:chooseMove(index) + if self.phase ~= "moveSelect" then return nil, "move menu is not active" end + local move = self.player.curMoves[index] + if not move then return nil, "invalid move slot" end + self.moveIndex = index + if self.player.disabledSlot == index then + self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!")) + self.phase = "messages" + self.afterQueue = "menu" + elseif move.pp <= 0 then + self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) + self.phase = "messages" + self.afterQueue = "menu" + else + self.playerMoveListIndex = index + self:resolveTurn(move) + end + return true +end + +function BattleState:cancelMove() + if self.phase ~= "moveSelect" then return nil, "move menu is not active" end + self.moveSwapIndex = nil + self.phase = "menu" + return true +end + function BattleState:swapMoves(i, j) if i == j then return end local moves = self.player.curMoves @@ -2120,42 +2191,7 @@ function BattleState:update(dt) self.menuIndex = row * 2 + col + 1 if input:wasPressed("a") then require("src.core.Sound").play(self.data, "Press_AB") - local choice = ({ "fight", "pkmn", "item", "run" })[self.menuIndex] - if choice == "fight" and self.ghost then - self:say(Strings("%s is too\nscared to move!", self.player.name)) - self.phase = "messages" - self.afterQueue = "menu" - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) - -- the scared turn still ticks the player's residual (PrintGhostText - -- -> ExecutePlayerMoveDone, core.asm:3056, 3275-3279) - self:queueResidual(self.player, self.enemy) - self:act(function() self:endOfTurn() end) - elseif choice == "fight" then - -- After the menu: own trapping/Bide or foe Wrap skips the move - -- list and forces the locked action (core.asm:320-329) - local fightLock = self:fightLockedAction(self.player) - if fightLock then - self:resolveTurn(fightLock) - return - end - if not self:playerHasPP() then - -- _NoMovesLeftText, then Struggle engages - self:say(Strings("%s has no\nmoves left!", self.player.name)) - self:resolveTurn({ id = "STRUGGLE", pp = 1, struggle = true }) - return - end - self.phase = "moveSelect" - self.moveIndex = math.min(self.moveIndex, #self.player.curMoves) - self.moveSwapIndex = nil - elseif choice == "run" then - self:tryRun() - elseif choice == "item" then - self:openItems() - else - self:openParty() - end + self:chooseMenu(({ "fight", "party", "item", "run" })[self.menuIndex]) end return end @@ -2184,8 +2220,7 @@ function BattleState:update(dt) end elseif input:wasPressed("b") then require("src.core.Sound").play(self.data, "Press_AB") - self.moveSwapIndex = nil - self.phase = "menu" + self:cancelMove() elseif input:wasPressed("a") then require("src.core.Sound").play(self.data, "Press_AB") if self.moveSwapIndex then @@ -2193,19 +2228,7 @@ function BattleState:update(dt) self.moveSwapIndex = nil return end - local mv = moves[self.moveIndex] - if self.player.disabledSlot == self.moveIndex then - self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!")) - self.phase = "messages" - self.afterQueue = "menu" - elseif mv.pp <= 0 then - self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) - self.phase = "messages" - self.afterQueue = "menu" - else - self.playerMoveListIndex = self.moveIndex - self:resolveTurn(mv) - end + self:chooseMove(self.moveIndex) end return end diff --git a/src/battle/gen2/BattleAPI.lua b/src/battle/gen2/BattleAPI.lua index 23ea585e..e8381964 100644 --- a/src/battle/gen2/BattleAPI.lua +++ b/src/battle/gen2/BattleAPI.lua @@ -116,4 +116,57 @@ function BattleAPI:snapshot() items = {} } end +local MENU_CHOICES = { fight = true, party = true, item = true, run = true } + +local function validSlot(slot) + return type(slot) == "number" and slot % 1 == 0 and slot >= 1 +end + +function BattleAPI:submit(intent) + if type(intent) ~= "table" then return nil, "intent must be a table" end + if type(intent.id) ~= "number" or intent.id % 1 ~= 0 or intent.id < 1 then + return nil, "intent id must be a positive integer" + end + if self.lastIntentId and intent.id <= self.lastIntentId then + return nil, "replayed intent" + end + + local screen, top = activeBattle(self.game) + if not screen or not screen.battle then return nil, "no battle" end + if intent.revision ~= self:_revision(screen, top) then + return nil, "stale battle context" + end + if screen.tutorial then return nil, "battle kind is not controllable" end + if top ~= screen then return nil, "battle menu is covered" end + + local battle = screen.battle + local ok, err + if intent.kind == "menu" then + if screen.phase ~= "menu" then return nil, "battle menu is not active" end + if not MENU_CHOICES[intent.choice] then + return nil, "unknown battle menu choice" + end + ok, err = screen:chooseMenu(intent.choice) + elseif intent.kind == "move" then + if screen.phase ~= "moves" then return nil, "move menu is not active" end + if screen.moveSwapIndex then return nil, "move reorder is active" end + local move = validSlot(intent.slot) and battle.player + and battle.player.moves and battle.player.moves[intent.slot] + if not move then return nil, "invalid move slot" end + if (move.pp or 0) <= 0 then return nil, "move has no PP" end + if battle:moveDisabled(battle.player, move.id) then + return nil, "move is disabled" + end + ok, err = screen:chooseMove(intent.slot) + elseif intent.kind == "back" then + ok, err = screen:cancelMove() + else + return nil, "unknown battle intent" + end + if not ok then return nil, err end + self.lastIntentId = intent.id + self.signature = nil + return true +end + return BattleAPI diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 1e118fcd..4d149c2a 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -121,6 +121,8 @@ local TEXT_ASK_FORGET_MOVE = Strings.source( -- Gen 1 uses. The second label is the two-glyph ligature (charmap -- $e1/$e2), which is what makes it fit a six-tile column. local MENU = { "FIGHT", "", "PACK", "RUN" } +local MENU_ACTION = { FIGHT = "fight", [""] = "party", + PACK = "item", RUN = "run" } local MENU_BOX_X = 8 local MENU_COL_SPACING = 6 @@ -1660,6 +1662,64 @@ function BattleState:playerMoves() return (self.battle and self.battle.player and self.battle.player.moves) or {} end +-- One semantic path for the native command menu and mod.battle intents. +function BattleState:chooseMenu(choice) + if self.phase ~= "menu" then return nil, "battle menu is not active" end + if choice == "fight" then + -- CheckPlayerHasUsableMoves skips MoveSelectionScreen and uses Struggle. + local fighter = self.battle and self.battle.player + if fighter and #self:playerMoves() > 0 + and not self.battle:hasUsableMoves(fighter) then + self:submit({ kind = "move", move = Battle.STRUGGLE }) + else + self.phase = "moves" + -- MoveSelectionScreen reopens on the last used move, clamped if the + -- moveset shrank since then. + local moves = self:playerMoves() + self.moveIndex = math.max(1, + math.min(self.moveIndex or 1, math.max(1, #moves))) + end + elseif choice == "run" then + self:submit({ kind = "run" }) + elseif choice == "item" then + if self.tutorial then + self:openTutorialPack() + elseif self.contest then + self:throwParkBall() + else + self:openPack() + end + elseif choice == "party" then + self:openParty() + else + return nil, "unknown battle menu choice" + end + return true +end + +function BattleState:chooseMove(index) + if self.phase ~= "moves" then return nil, "move menu is not active" end + local move = self:playerMoves()[index] + if not move then return nil, "invalid move slot" end + self.moveIndex = index + self.moveSwapIndex = nil + if (move.pp or 0) <= 0 then + self:refuseMove(TEXT_NO_PP_LEFT) + elseif self.battle:moveDisabled(self.battle.player, move.id) then + self:refuseMove(TEXT_MOVE_DISABLED) + else + self:submit({ kind = "move", move = move.id }) + end + return true +end + +function BattleState:cancelMove() + if self.phase ~= "moves" then return nil, "move menu is not active" end + self.moveSwapIndex = nil + self.phase = "menu" + return true +end + -- MoveSelectionScreen's `.pressed_select` (engine/battle/core.asm:5320-5374). -- SELECT marks a slot, SELECT again swaps the marked slot with the one under -- the cursor, and A or B clears the mark without swapping (the A arm opens @@ -1833,37 +1893,7 @@ function BattleState:update(_dt) or self.menuIndex - 2 elseif input:wasPressed("a") then self:playSfx("Sfx_ReadText2") - local choice = MENU[self.menuIndex] - if choice == "FIGHT" then - -- `call .CheckPlayerHasUsableMoves / ret z` (engine/battle/core.asm - -- :5058-5059): a mon with nothing to spend never sees the list. - local fighter = self.battle and self.battle.player - if fighter and #self:playerMoves() > 0 - and not self.battle:hasUsableMoves(fighter) then - return self:submit({ kind = "move", move = Battle.STRUGGLE }) - end - self.phase = "moves" - -- MoveSelectionScreen seeds wMenuCursorY from wCurMoveNum + 1 - -- (engine/battle/core.asm:5111) and the A-press writes the picked row - -- back, so the list reopens on the move used last turn; only - -- SendOutPlayerMon and CleanUpBattleRAM zero it. Clamp rather than - -- reset, for a moveset that shrank (Mimic, a forgotten slot). - local moves = self:playerMoves() - self.moveIndex = math.max(1, - math.min(self.moveIndex or 1, math.max(1, #moves))) - elseif choice == "RUN" then - self:submit({ kind = "run" }) - elseif choice == "PACK" then - if self.tutorial then - self:openTutorialPack() - elseif self.contest then - self:throwParkBall() - else - self:openPack() - end - else - self:openParty() - end + self:chooseMenu(MENU_ACTION[MENU[self.menuIndex]]) end return end @@ -1884,22 +1914,12 @@ function BattleState:update(_dt) elseif input:wasPressed("b") then -- B leaves the list, and a mark never survives it self:playSfx("Sfx_ReadText2") - self.moveSwapIndex = nil - self.phase = "menu" + self:cancelMove() elseif input:wasPressed("a") then -- `xor a / ld [wSwappingMove], a` opens the A arm: choosing a move -- cancels a pending swap rather than performing it self:playSfx("Sfx_ReadText2") - self.moveSwapIndex = nil - local move = moves[self.moveIndex] - if not move then return end - -- `.no_pp_left` and `.move_disabled` both end on `jp MoveSelectionScreen` - -- (engine/battle/core.asm:5213-5246): neither spends the turn. - if (move.pp or 0) <= 0 then return self:refuseMove(TEXT_NO_PP_LEFT) end - if self.battle:moveDisabled(self.battle.player, move.id) then - return self:refuseMove(TEXT_MOVE_DISABLED) - end - self:submit({ kind = "move", move = move.id }) + self:chooseMove(self.moveIndex) end return end diff --git a/tests/mod_battle_snapshot_test.lua b/tests/mod_battle_snapshot_test.lua index 18e9d0b9..863fb00a 100644 --- a/tests/mod_battle_snapshot_test.lua +++ b/tests/mod_battle_snapshot_test.lua @@ -4,7 +4,8 @@ love = love or require("tests.love_stub") local S = require("tests.harness").suite("mod battle snapshot") local check, eq = S.check, S.eq -check(require("src.battle.BattleState").isBattleState == true, +local Gen1BattleState = require("src.battle.BattleState") +check(Gen1BattleState.isBattleState == true, "Gen 1 battle states carry the discovery marker") local TypeChart = require("src.battle.TypeChart") @@ -50,6 +51,18 @@ local battle = { function battle:battleKind() return "wild" end function battle:effectRecord() return { accuracyChecked = true } end function battle:visibleText() return { "Wild TESTMON appeared!" } end +function battle:menuLockedAction() return nil end +function battle:chooseMenu(choice) + self.chosenMenu = choice + if choice == "fight" then self.phase = "moveSelect" end + return true +end +function battle:chooseMove(slot) + self.chosenMove = slot + self.phase = "messages" + return true +end +function battle:cancelMove() self.phase = "menu" return true end function battle:catchChance(ball) return require("src.battle.Catching").chance(ball, self.enemy.mon, game.data.pokemon[self.enemy.mon.species]) @@ -81,6 +94,35 @@ game.stack.states = {} check(api:snapshot() == nil, "Gen 1 returns nil outside a battle") game.stack.states = { battle } +local menu = api:snapshot() +local ok, err = api:submit({ id = 1, revision = menu.revision - 1, + kind = "menu", choice = "fight" }) +check(not ok and err == "stale battle context", + "Gen 1 rejects a stale intent") +ok, err = api:submit({ id = 1, revision = menu.revision, + kind = "menu", choice = "missing" }) +check(not ok and err == "unknown battle menu choice", + "Gen 1 rejects an unknown menu choice") +check(api:submit({ id = 1, revision = menu.revision, + kind = "menu", choice = "fight" }), "Gen 1 accepts a menu intent") +eq(battle.chosenMenu, "fight", "Gen 1 uses the semantic menu path") +ok, err = api:submit({ id = 1, revision = menu.revision, + kind = "menu", choice = "fight" }) +check(not ok and err == "replayed intent", "Gen 1 rejects a replayed intent") +local moveMenu = api:snapshot() +ok, err = api:submit({ id = 2, revision = moveMenu.revision, + kind = "move", slot = 9 }) +check(not ok and err == "invalid move slot", + "Gen 1 rejects an invalid move slot") +check(api:submit({ id = 2, revision = moveMenu.revision, + kind = "move", slot = 1 }), "Gen 1 accepts a valid move") +eq(battle.chosenMove, 1, "Gen 1 uses the semantic move path") +battle.phase = "moveSelect" +local back = api:snapshot() +check(api:submit({ id = 3, revision = back.revision, kind = "back" }), + "Gen 1 accepts move-menu back") +eq(battle.phase, "menu", "Gen 1 back restores the command menu") + local player2 = { species = "CHIKORITA", level = 5, hp = 20, maxHp = 21, moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } } local enemy2 = { species = "RATTATA", level = 3, hp = 12, maxHp = 12, @@ -90,6 +132,17 @@ local battle2 = { player = player2, enemy = enemy2, party = { player2 }, function battle2:moveDisabled() return false end local screen2 = { screenId = "Gen2BattleState", battle = battle2, phase = "menu", menuIndex = 1, moveIndex = 1 } +function screen2:chooseMenu(choice) + self.chosenMenu = choice + if choice == "fight" then self.phase = "moves" end + return true +end +function screen2:chooseMove(slot) + self.chosenMove = slot + self.phase = "resolving" + return true +end +function screen2:cancelMove() self.phase = "menu" return true end local game2 = { data = { pokemon = { CHIKORITA = { name = "CHIKORITA" }, @@ -121,6 +174,60 @@ game2.stack.states = {} check(api2:snapshot() == nil, "Gold returns nil outside a battle") game2.stack.states = { screen2 } +screen2.message = nil +screen2.phase = "menu" +local menu2 = api2:snapshot() +ok, err = api2:submit({ id = 1, revision = menu2.revision - 1, + kind = "menu", choice = "fight" }) +check(not ok and err == "stale battle context", + "Gold rejects a stale intent") +ok, err = api2:submit({ id = 1, revision = menu2.revision, + kind = "menu", choice = "missing" }) +check(not ok and err == "unknown battle menu choice", + "Gold rejects an unknown menu choice") +check(api2:submit({ id = 1, revision = menu2.revision, + kind = "menu", choice = "fight" }), "Gold accepts a menu intent") +eq(screen2.chosenMenu, "fight", "Gold uses the semantic menu path") +local moveMenu2 = api2:snapshot() +ok, err = api2:submit({ id = 2, revision = moveMenu2.revision, + kind = "move", slot = 9 }) +check(not ok and err == "invalid move slot", + "Gold rejects an invalid move slot") +check(api2:submit({ id = 2, revision = moveMenu2.revision, + kind = "move", slot = 1 }), "Gold accepts a valid move") +eq(screen2.chosenMove, 1, "Gold uses the semantic move path") +screen2.phase = "moves" +local back2 = api2:snapshot() +check(api2:submit({ id = 3, revision = back2.revision, kind = "back" }), + "Gold accepts move-menu back") +eq(screen2.phase, "menu", "Gold back restores the command menu") + +do + local Data = require("tests.modkit").fixtures.fresh() + local Pokemon = require("src.pokemon.Pokemon") + local SaveData = require("src.core.SaveData") + local save = SaveData.newGame() + save.party = { Pokemon.new(Data, "FIXMON_A", 20) } + local pressed = {} + local game3 = { data = Data, save = save, input = { + wasPressed = function(_, key) return pressed[key] == true end, + isDown = function() return false end, + }, stack = { states = {} } } + function game3.stack:top() return self.states[#self.states] end + function game3.stack:push(state) self.states[#self.states + 1] = state end + local real = Gen1BattleState.newWild(game3, "FIXMON_B", 12) + real.phase, real.queue, real.introSlide = "menu", {}, nil + game3.stack.states = { real } + pressed.a = true + real:update(1 / 60) + pressed.a = nil + eq(real.phase, "moveSelect", "native Gen 1 FIGHT uses the semantic path") + pressed.b = true + real:update(1 / 60) + pressed.b = nil + eq(real.phase, "menu", "native Gen 1 move-menu back still works") +end + local Loader = require("src.mods.Loader") local fs = { read = function() end, getInfo = function() end, getDirectoryItems = function() return {} end } From e1d233d026ba06d6ecde90624c792abcc2efea97 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:07:54 +0200 Subject: [PATCH 10/12] fix(android): rebind secondary displays after hotplug --- .../java/org/love2d/android/GameActivity.java | 68 ++++++++++++++++++- tests/engine/android_host_extension_test.lua | 10 +++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index 7a30850c..cec24cde 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -366,6 +366,7 @@ public class GameActivity extends SDLActivity { Log.d("GameActivity", "Cancelling vibration"); vibrator.cancel(); } + unregisterSecondaryDisplayListener(); onHostDestroy(); super.onDestroy(); } @@ -376,6 +377,7 @@ public class GameActivity extends SDLActivity { Log.d("GameActivity", "Cancelling vibration"); vibrator.cancel(); } + unregisterSecondaryDisplayListener(); teardownSecondaryDisplay(); onHostPause(); super.onPause(); @@ -385,6 +387,7 @@ public class GameActivity extends SDLActivity { public void onResume() { super.onResume(); onHostResume(); + if (secondaryEnabled) registerSecondaryDisplayListener(); setupSecondaryDisplay(); } @@ -1405,6 +1408,7 @@ public class GameActivity extends SDLActivity { // in src/jni/love/src/common/android.cpp. private static volatile SecondaryPresentation secondaryPresentation; private static volatile boolean secondaryEnabled = false; + private SecondaryDisplayMonitor secondaryDisplayMonitor; private static final int MAX_SECONDARY_TOUCHES = 32; private static final java.util.ArrayDeque secondaryTouches = new java.util.ArrayDeque<>(); @@ -1416,11 +1420,44 @@ public class GameActivity extends SDLActivity { if (self == null) return; self.runOnUiThread(new Runnable() { @Override public void run() { - if (on) setupSecondaryDisplay(); else teardownSecondaryDisplay(); + if (on) { + self.registerSecondaryDisplayListener(); + setupSecondaryDisplay(); + } else { + self.unregisterSecondaryDisplayListener(); + teardownSecondaryDisplay(); + } } }); } + private void registerSecondaryDisplayListener() { + if (secondaryDisplayMonitor != null || android.os.Build.VERSION.SDK_INT < 17) return; + SecondaryDisplayMonitor monitor = new SecondaryDisplayMonitor(this); + if (monitor.register()) secondaryDisplayMonitor = monitor; + } + + private void unregisterSecondaryDisplayListener() { + SecondaryDisplayMonitor monitor = secondaryDisplayMonitor; + secondaryDisplayMonitor = null; + if (monitor != null) monitor.unregister(); + } + + private static void refreshSecondaryDisplay() { + GameActivity self = (GameActivity) mSingleton; + if (self == null || !secondaryEnabled) return; + SecondaryPresentation current = secondaryPresentation; + Display display = current == null ? null : current.getDisplay(); + SecondaryDisplayMonitor monitor = self.secondaryDisplayMonitor; + if (current == null) { + setupSecondaryDisplay(); + } else if (display == null || monitor == null + || !monitor.hasDisplay(display.getDisplayId())) { + teardownSecondaryDisplay(); + setupSecondaryDisplay(); + } + } + private static void setupSecondaryDisplay() { GameActivity self = (GameActivity) mSingleton; if (self == null || !secondaryEnabled || secondaryPresentation != null) return; @@ -1466,6 +1503,35 @@ public class GameActivity extends SDLActivity { } } + @android.annotation.TargetApi(17) + private static class SecondaryDisplayMonitor + implements android.hardware.display.DisplayManager.DisplayListener { + private final android.hardware.display.DisplayManager manager; + + SecondaryDisplayMonitor(GameActivity activity) { + manager = (android.hardware.display.DisplayManager) + activity.getSystemService(Context.DISPLAY_SERVICE); + } + + boolean register() { + if (manager == null) return false; + manager.registerDisplayListener(this, new Handler(Looper.getMainLooper())); + return true; + } + + void unregister() { + manager.unregisterDisplayListener(this); + } + + boolean hasDisplay(int displayId) { + return manager.getDisplay(displayId) != null; + } + + @Override public void onDisplayAdded(int displayId) { refreshSecondaryDisplay(); } + @Override public void onDisplayRemoved(int displayId) { refreshSecondaryDisplay(); } + @Override public void onDisplayChanged(int displayId) { refreshSecondaryDisplay(); } + } + @Keep public static boolean hasSecondaryDisplay() { return secondaryPresentation != null; diff --git a/tests/engine/android_host_extension_test.lua b/tests/engine/android_host_extension_test.lua index 13d25a5e..aabef3f7 100644 --- a/tests/engine/android_host_extension_test.lua +++ b/tests/engine/android_host_extension_test.lua @@ -49,6 +49,16 @@ check(position("onHostPause();") < position("super.onPause();"), check(position("onHostDestroy();") < position("super.onDestroy();"), "destroy hook runs before SDL destruction") +check(source:find("DisplayManager.DisplayListener", 1, true) + and source:find("registerDisplayListener", 1, true) + and source:find("unregisterDisplayListener", 1, true), + "secondary displays are monitored while the activity is active") +check(position("if (secondaryEnabled) registerSecondaryDisplayListener();") < + position("setupSecondaryDisplay();"), + "secondary display monitoring starts before initial discovery") +check(source:find("!monitor.hasDisplay(display.getDisplayId())", 1, true), + "a disconnected active display is rebound without replacing a live one") + check(not source:lower():find("openxr", 1, true), "generic Android activity must not require OpenXR") check(not source:find("QuestActivity", 1, true) and From 4b7a4daf2cb9f95afa68200e8395ce830b2d9b48 Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Sun, 16 Aug 2026 03:29:33 +0100 Subject: [PATCH 11/12] HostShell: stage postLog bodies via OS temp env, not tmpnam tmpnam() on the Windows CRT returns a bare, CWD-relative name (e.g. \sb4c.2), and io.open on it fails with Permission denied when the game's working directory is not writable -- a Program Files (or otherwise protected) install. postLog then dies before curl runs: the mod reports a send failure and no bytes leave the machine (confirmed on a Windows install: "could not create request body: \sb4c.2: Permission denied"). Stage the request body under the OS temp contract instead: TEMP/TMP on Windows (always set, always per-user writable), TMPDIR with a /tmp fallback on POSIX. The transport stays on plain io/os -- no love.filesystem dependency. Tests updated to mock os.getenv and assert the staged path sits under the temp dir; 10/10 checks pass. --- src/core/HostShell.lua | 18 ++++++++++++++++-- tests/engine/host_shell_postlog.lua | 20 +++++++++++++------- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index 41068ed7..fe19021c 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -429,8 +429,22 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime) userAgent = userAgent or "gen1recomp" if HostShell.haveCurl() then -- io.popen is one-way on Lua/LuaJIT: its mode is "r" or "w", never - -- "rw". Stage the request body so the response can stay on a read pipe. - local bodyPath = os.tmpname() + -- "rw". Stage the request body so the response can stay on a read + -- pipe. The staging directory comes from the OS temp contract, never + -- tmpnam(): the CRT's tmpnam() can return a name relative to the process + -- working directory, and a game installed under Program Files has no + -- writable CWD -- io.open would fail before curl ever runs and postLog + -- would silently drop the send. TEMP/TMP are per-user writable on + -- Windows; TMPDIR (with /tmp fallback) covers POSIX. No love.filesystem: + -- the sandbox-era transport stays on plain io/os. + local function stagingPath() + local dir = os.getenv("TEMP") or os.getenv("TMP") + if not dir or dir == "" then dir = os.getenv("TMPDIR") or "/tmp" end + local sep = dir:find("\\") and "\\" or "/" + return dir .. sep .. ("gen1recomp-post-%d.tmp"):format( + (os.time() % 1000000) * 100 + math.random(0, 99)) + end + local bodyPath = stagingPath() local bodyFile, bodyOpenErr = io.open(bodyPath, "wb") if not bodyFile then pcall(os.remove, bodyPath) diff --git a/tests/engine/host_shell_postlog.lua b/tests/engine/host_shell_postlog.lua index 2261aec2..a15fb22d 100644 --- a/tests/engine/host_shell_postlog.lua +++ b/tests/engine/host_shell_postlog.lua @@ -12,13 +12,13 @@ local check, eq = T.check, T.eq local HostShell = require("src.core.HostShell") local MARK = "\n__gen1recomp_http__" -local BODY_PATH = "/tmp/gen1recomp-postlog-body-test" +local STAGE_DIR = "/tmp/gen1recomp-postlog-stage" local URL = "https://logs.example.com/logs" local BODY = "debug log body\n" local realOpen = io.open local realPopen = io.popen -local realTmpname = os.tmpname +local realGetenv = os.getenv local realRemove = os.remove local realHaveCurl = HostShell.haveCurl @@ -26,7 +26,12 @@ local openedPath, openedMode, writtenBody local popenCommand, popenMode, removedPath HostShell.haveCurl = function() return true end -os.tmpname = function() return BODY_PATH end +os.getenv = function(name) + if name == "TEMP" or name == "TMP" or name == "TMPDIR" then + return STAGE_DIR + end + return realGetenv(name) +end os.remove = function(path) removedPath = path return true @@ -55,21 +60,22 @@ local ok, err = HostShell.httpPost(URL, BODY, "text/plain", "gen1recomp-mod/test io.open = realOpen io.popen = realPopen -os.tmpname = realTmpname +os.getenv = realGetenv os.remove = realRemove HostShell.haveCurl = realHaveCurl eq(ok, true, "a desktop POST succeeds through the read-only response pipe: " .. tostring(err)) -eq(openedPath, BODY_PATH, "the request body is written to a temporary file") +check(type(openedPath) == "string" and openedPath:find(STAGE_DIR .. "/gen1recomp-post-", 1, true) == 1, "the request body is staged under the OS temp dir") +check(openedPath and openedPath:sub(-4) == ".tmp", "the staged body carries a .tmp name") eq(openedMode, "wb", "the temporary request body is opened for binary writing") eq(writtenBody, BODY, "the complete log body is staged") eq(popenMode, "r", "curl is opened in the supported read-only mode") check(popenCommand:find("--data-binary", 1, true) ~= nil, "curl reads the staged body with --data-binary") -check(popenCommand:find(BODY_PATH, 1, true) ~= nil, +check(openedPath and popenCommand:find(openedPath, 1, true) ~= nil, "curl receives the temporary body path") check(popenCommand:find(BODY, 1, true) == nil, "the log body is not placed directly in the command line") -eq(removedPath, BODY_PATH, "the temporary request body is removed") +eq(removedPath, openedPath, "the staged request body is removed") T.finish("host shell postlog") From 39df5bdfa6858efe11fe5a66473fb56aea9aa27a Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Sun, 16 Aug 2026 03:31:36 +0100 Subject: [PATCH 12/12] Raise postLog body ceiling to 512 KiB A diagnostic ring (boot evidence + recent lines + status) routinely exceeds 64 KiB on a long session: a 651-line evidence ring measured ~90 KB and was rejected with "log body too large" (mod.postLog returned nil and the send was dropped). The transport stages the body to a file and streams it via curl, so the ceiling is a budget, not a memory spike. 512 KiB is generous for real support logs while staying far under the 5 MiB the reference loghook endpoint accepts. --- src/mods/Net.lua | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/mods/Net.lua b/src/mods/Net.lua index c54f0a68..7cf67c84 100644 --- a/src/mods/Net.lua +++ b/src/mods/Net.lua @@ -32,9 +32,14 @@ local Net = {} Net.MAX_INFLIGHT = 4 -- Clamp on the caller's timeout, so a mod cannot pin a worker indefinitely. Net.MAX_SECONDS = 30 --- A log body ceiling. Debug logs are kilobytes, and a server operator has no --- reason to accept a mod uploading arbitrary megabytes to its endpoint. -Net.MAX_BODY = 65536 +-- A log body ceiling. A diagnostic ring (boot evidence + recent lines + +-- status) routinely exceeds 64 KiB on a long session, so the ceiling is +-- 512 KiB: generous for real support logs, still far under the 5 MiB the +-- reference loghook endpoint accepts, and small enough that a misbehaving +-- mod cannot upload arbitrary megabytes. The body is staged to a file and +-- streamed by the transport, so the ceiling is a budget, not a memory +-- spike; callers that stay under it never notice it. +Net.MAX_BODY = 512 * 1024 local function fetch() return require("src.net.Fetch")