From 83682f011df5039c4ea7042141590d38ca7e21d5 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 12 Aug 2026 09:22:30 -0400 Subject: [PATCH 01/13] grandmas-kitchn --- CONTRIBUTING-mods.md | 44 +++- docs/new-features.md | 1 + mod-sandbox-notice.txt | 100 ++++++++ mods/timekeepers_hut | 1 + src/mods/AssetTransform.lua | 22 +- src/mods/Loader.lua | 81 +++++-- src/mods/ManagerState.lua | 21 +- src/mods/Manifest.lua | 16 +- src/mods/Runtime.lua | 5 + src/mods/SafePath.lua | 38 +++ src/mods/Sandbox.lua | 227 ++++++++++++++++++ .../title_checkpoint_cold_start.lua | 21 +- tests/mod_loader_tests.lua | 10 +- tests/mod_manifest_tests.lua | 14 +- tests/mod_render_tests.lua | 44 ++-- tests/modkit/cases/checkpoints.lua | 7 +- .../modkit/cases/platform_lifecycle_hooks.lua | 33 ++- tests/modkit/cases/sandbox.lua | 207 ++++++++++++++++ tests/modkit/cases/storage.lua | 10 +- .../cases/title_playthrough_context.lua | 26 +- tests/modkit_tests.lua | 10 +- 21 files changed, 794 insertions(+), 144 deletions(-) create mode 100644 mod-sandbox-notice.txt create mode 120000 mods/timekeepers_hut create mode 100644 src/mods/SafePath.lua create mode 100644 src/mods/Sandbox.lua create mode 100644 tests/modkit/cases/sandbox.lua diff --git a/CONTRIBUTING-mods.md b/CONTRIBUTING-mods.md index ccd55ea1..d6148691 100644 --- a/CONTRIBUTING-mods.md +++ b/CONTRIBUTING-mods.md @@ -242,7 +242,47 @@ with a `MK4xx` finding per site and an `unresolved:` note, with a file and a line, for every reach a static scan could not follow. Neither substitutes for a real Gold boot. -### 5. `mod.card` +### 5. What a mod's code can reach + +Your code runs in a sandbox (`src/mods/Sandbox.lua`), not against the +engine's globals. Every chunk you author gets it: `main.lua`, your +`options_schema`, and anything you `load()` yourself. What is absent: + +| Absent | Use instead | +| --- | --- | +| `io`, and `require("io")` | `mod:read` for your own files, `mod.storage` to persist | +| `os.getenv`, `os.execute`, `os.remove`, `os.rename`, `os.exit` | nothing; `os.time`/`os.date`/`os.clock` still work | +| `package`, `dofile`, `loadfile`, `debug`, `getfenv`, `setfenv` | `require` for the supported engine modules | +| `require("ffi")`, `require("love.*")` | the `love` table you are given | +| `love.filesystem` | `mod.storage` (per-mod, per-playthrough) and `mod:read` | +| `love.thread`, `love.system`, `love.event` | `mod.events`, `mod.hooks` | + +The rest of `love` passes through unchanged, so graphics, audio, timers and +input work as they always have. + +Three consequences worth knowing before you write against it: + +- **Your globals are yours.** `_G` inside a mod is that mod's own table. Two + mods no longer share a namespace, and neither can reach the engine's. To + publish something to another mod, put it on `mod.exports` and let them + `mod.find("your_id").exports` — the channel that was always the intended + one. The same goes for the standard library: `string`, `table` and `math` + are per-mod copies, so patching one is a local decision. +- **Paths cannot climb.** `mod:read`, `mod.assets:path` and `mod.assets:image` + join to your own directory, and `..`, absolute paths and drive letters are + refused. So are `entry` and `options_schema` in your manifest. +- **Ship source, not bytecode.** A precompiled entry file is refused. + +`permissions` in the manifest is still a disclosure the manager shows the +player, and `network` now gates `require("socket")` and friends. There is no +permission that grants raw filesystem access, because no mod needs one: +everything a mod legitimately writes is already scoped by +`mod.storage` or the asset-transform derived root. + +If your mod used one of the absent globals, the fix is almost always +`mod.storage`. Open an issue if you have a case it does not cover. + +### 6. `mod.card` The manifest is the *engine's* contract: identity, load order, dependencies, permissions, profile. The card is the *human-facing* one: who made this, @@ -263,7 +303,7 @@ Two fields deserve their own note: distributed mod never carries ROM-derived bytes, not even in its preview images. -### 6. Tags +### 7. Tags Lowercase kebab strings, open vocabulary. The showcase generator lowercases and de-dupes. A recommended starting set: `beginner`, diff --git a/docs/new-features.md b/docs/new-features.md index 4870e740..e85215a8 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -19,6 +19,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow * **Soft reset button combination** * **Keyboard and controller rebinding** * **Mod profiles** with separate mod settings and save slots +* **Sandboxed mods**: an installed mod can read only its own folder and write only its own storage, so it cannot reach the rest of your device * **Improved launcher and save editor UI**, including background downloads and update checks * **Direct-launch options** for shortcuts, Steam entries, and handheld frontends * **Custom boot branding** diff --git a/mod-sandbox-notice.txt b/mod-sandbox-notice.txt new file mode 100644 index 00000000..a936f3aa --- /dev/null +++ b/mod-sandbox-notice.txt @@ -0,0 +1,100 @@ +Discord announcement for mod authors: the mod sandbox. +Paste each block below as its own message (Discord caps a message at 2000 +characters). Delete the ===== MESSAGE N ===== separator lines first. +Discord does not render markdown tables, so the lists below are code blocks. + + +===== MESSAGE 1 ===== + +# Heads up for mod authors: mods now run in a sandbox + +**Short version:** if your mod uses `io`, `os.getenv`, `os.execute` or `love.filesystem`, it will stop loading in the next release. There is a replacement for every legitimate use, and it is usually one line. + +## What changed + +Until now, a mod's Lua code ran against the engine's own globals. That meant any installed mod had full read and write access to the player's entire filesystem: their documents, their photos, anything their user account could touch. The `permissions` field in `manifest.json` did not actually enforce anything. + +That was our design mistake, not yours. If your mod used `io`, it was using the API exactly as it was handed to you. + +Starting with the next release, mod code runs in a restricted environment. A mod can read its own folder and write its own storage. It cannot name a path outside the game, in any spelling. + + +===== MESSAGE 2 ===== + +## What is no longer available + +``` +GONE USE INSTEAD +io.* and require("io") mod:read("file") for your own files + mod.storage to persist data +os.getenv / execute / remove nothing. os.time, os.date and + / rename / exit os.clock still work +package, dofile, loadfile, debug, require still resolves the + getfenv, setfenv supported engine modules +require("ffi") the love table you already have +require("love.filesystem") the love table you already have +love.filesystem mod.storage (scoped per mod and + per playthrough) and mod:read +love.thread, love.system, love.event mod.events, mod.hooks +``` + +Everything else in `love` is unchanged: graphics, audio, timers and input all work exactly as before. So does the whole standard library apart from the lines above. + + +===== MESSAGE 3 ===== + +## Three smaller changes in the same release + +**1. Your globals are your own.** `_G` inside a mod is now that mod's private table. If you were passing data to another mod through a global, put it on `mod.exports` and let them read it with `mod.find("your_id").exports`. That was always the intended channel, and unlike a global it survives load order and tells you the other mod's version. + +**2. Paths cannot climb out of your folder.** `mod:read`, `mod.assets:path` and `mod.assets:image` refuse `..`, absolute paths and drive letters, as do `entry` and `options_schema` in your manifest. + +**3. Ship source, not bytecode.** A precompiled `main.lua` is refused. + + +===== MESSAGE 4 ===== + +## Check your mod in one command + +```sh +grep -rnE '\bio\.|os\.(getenv|execute|remove|rename|exit|tmpname)|love\.(filesystem|thread|system|event)|require\("(io|os|debug|package|ffi|love\.)' your_mod/ +``` + +No output means you are fine and nothing about your mod changes. + +If you do get hits, the load-time errors name the replacement directly, so you can also just run your mod and read what it tells you: + +```sh +python3 tools/modkit.py validate mods/your_mod --base imported +``` + + +===== MESSAGE 5 ===== + +## The most common case + +Nearly every real use of `io` in a mod was "save some state of my own", and that is what `mod.storage` is for. It is data-only, already scoped to your mod and the current playthrough, and it survives the player switching profiles. + +```lua +-- before +local f = io.open("my_settings.txt", "w") +f:write(value) +f:close() + +-- after +mod.storage:write(game, "settings", { value = value }) +local saved = mod.storage:read(game, "settings") +``` + + +===== MESSAGE 6 ===== + +## About `permissions` + +There is no permission that grants raw filesystem access, and there will not be one. Nothing a mod legitimately does needs it, and a permission that can be requested is a permission that gets requested. `permissions` is still shown to the player in the mod manager, and `network` now genuinely gates the networking modules. + +## If you have a case this does not cover + +Please open an issue rather than working around it. If there is a real thing mods need to do that the sandbox blocks, that is a gap in the API and we want to fix it properly, with a scoped engine call rather than a hole. + +Thanks for building for this project, and sorry for the churn. This one was worth the disruption: it means a player installing your mod is trusting it with the game, and not with their whole machine. diff --git a/mods/timekeepers_hut b/mods/timekeepers_hut new file mode 120000 index 00000000..df2790bb --- /dev/null +++ b/mods/timekeepers_hut @@ -0,0 +1 @@ +/Users/bryanbassett/Documents/development/pokemon-gen1-recomp-project/.bazinga/mods/timekeepers_hut \ No newline at end of file diff --git a/src/mods/AssetTransform.lua b/src/mods/AssetTransform.lua index f73a98c6..f6612b4e 100644 --- a/src/mods/AssetTransform.lua +++ b/src/mods/AssetTransform.lua @@ -16,6 +16,7 @@ local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") +local SafePath = require("src.mods.SafePath") local unpack = table.unpack or unpack local loadstring = loadstring or load @@ -31,25 +32,8 @@ AssetTransform.SOURCE_ROOT = SOURCE_ROOT AssetTransform.DERIVED_ROOT = DERIVED_ROOT -- ------- path sandbox - --- a relative path that cannot climb out of the root it is joined to -local function safeRelative(rel) - if type(rel) ~= "string" or rel == "" then return nil end - if rel:sub(1, 1) == "/" then return nil end - if rel:find("\\", 1, true) then return nil end - for segment in rel:gmatch("[^/]+") do - if segment == ".." or segment == "." then return nil end - end - return rel -end - -local function requireRelative(rel, what) - local safe = safeRelative(rel) - if not safe then - error(("%s must stay inside its root, got %q"):format(what, tostring(rel)), 0) - end - return safe -end +-- shared with mod:read and the manifest's own paths (src/mods/SafePath.lua) +local requireRelative = SafePath.require -- ------- the restricted context diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 6c446095..798f6def 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -12,6 +12,8 @@ local Manifest = require("src.mods.Manifest") local Merge = require("src.mods.Merge") local ModTargets = require("src.mods.ModTargets") local Registry = require("src.mods.Registry") +local SafePath = require("src.mods.SafePath") +local Sandbox = require("src.mods.Sandbox") local Schemas = require("src.mods.Schemas") local Semver = require("src.mods.Semver") local Events = require("src.mods.Events") @@ -72,10 +74,12 @@ local function orderedIds(mods, filter) return ids end --- ------- dev-mode permissions tripwire --- Attribution only: the shim delegates unconditionally and blocks nothing. --- Installed once per process and only when the loader runs in dev mode, so a --- player build has zero interposition. +-- ------- the require gate +-- Two jobs in one interposition. The engine_internals/network scan is +-- attribution only and stays dev-mode: it warns and delegates. The +-- Sandbox.moduleDenial check is not -- require("io") would hand back +-- package.loaded.io and undo the whole mod environment -- so it is installed +-- in player builds too, for any boot that has mods on it. local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 } @@ -135,7 +139,8 @@ end local function scanRequire(name) local modId = Runtime.currentMod - if not modId or type(name) ~= "string" then return end + if type(modId) ~= "string" then modId = Runtime.modRequire end + if type(modId) ~= "string" or type(name) ~= "string" then return end local granted = devShim.permissions[modId] or {} local function warnOnce(permission) local key = modId .. "|" .. permission .. "|" .. name @@ -188,6 +193,7 @@ function Loader:_installDevShim() for id, mod in pairs(self.mods) do devShim.permissions[id] = mod.manifest.permissionSet end + devShim.dev = self.dev if devShim.installed then return end devShim.installed = true local delegate = require @@ -195,12 +201,22 @@ function Loader:_installDevShim() -- only the mod's own call is the mod's doing; whatever that module -- requires in turn is the engine wiring itself up if devShim.depth == 0 then - scanRequire(name) + -- Backstop for the deny list Sandbox.envFor's require already applies: + -- an engine module requiring io is the engine wiring itself up, a mod + -- doing it is the hole this closes, and any future path that runs mod + -- code without a sandbox env still lands here. + local owner = Runtime.currentMod or Runtime.modRequire + if owner or callerIsMod(3) then + local id = type(owner) == "string" and owner or nil + local denial = Sandbox.moduleDenial(name, devShim.permissions[id]) + if denial then error(("[%s] %s"):format(id or "mod", denial), 0) end + end + if devShim.dev or devShim.generation ~= 1 then scanRequire(name) end -- The Gen 1 name a mod asked for, answered by the Gen 2 arm behind it. -- Engine code keeps the real module: src/render/PaletteFX.lua:776 -- requires src.core.Game on both generations and means it. if devShim.generation ~= 1 and Gen2Compat.serves(name) - and callerIsMod(3) then + and (owner or callerIsMod(3)) then local adapter = Gen2Compat.resolve(name, Runtime.currentMod) if adapter then local key = "adapter|" .. name @@ -235,7 +251,7 @@ function Loader.new(opts) events = Events.new(), hooks = Hooks.new(), content = {}, assets = {}, exports = {}, migrations = {}, order = {}, modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {}, - modInput = {}, + modInput = {}, modEnv = {}, fs = (opts and opts.fs) or (love and love.filesystem), dev = dev, -- Which generation this boot is (1 or 2). Fixed at construction: the @@ -358,11 +374,13 @@ function Loader:_writeOptionSchemas() -- demand; using it here means older mods do not need to migrate to -- mod.options:define just to appear in a launcher settings screen. if schema == nil and mod.manifest.options_schema and self.fs.load then - local chunk = self.fs.load(mod.path .. "/" .. mod.manifest.options_schema) - if chunk then - local ok, rows = pcall(chunk) - if ok and type(rows) == "table" then schema = rows end - end + local ok, rows = pcall(function() + local path = SafePath.join(mod.path, mod.manifest.options_schema, + "options_schema") + local chunk = Sandbox.loadFile(self.fs, path, self:_modEnv(mod)) + return chunk and chunk() + end) + if ok and type(rows) == "table" then schema = rows end end if schema ~= nil then mods[id] = schema @@ -1091,9 +1109,11 @@ function Loader:_api(mod) -- assets keeps the v1 alias to the content accessors and adds the file -- helpers on top, so mod.assets.pokemon and mod.assets:image both resolve api.assets = setmetatable({ - path = function(_, relative) return mod.path .. "/" .. relative end, + path = function(_, relative) + return SafePath.join(mod.path, relative, "mod.assets:path") + end, image = function(_, relative) - local full = mod.path .. "/" .. relative + local full = SafePath.join(mod.path, relative, "mod.assets:image") local cached = loader.imageCache[full] if cached then return cached end assert(love and love.graphics, @@ -1103,9 +1123,10 @@ function Loader:_api(mod) return image end, }, { __index = api.content }) + -- the mod's own directory and nothing above it: PhysFS already refuses a + -- climb, but loader.fs is injectable and has no such floor function api:read(relative) - local path = self.path .. "/" .. relative - return loader.fs.read(path) + return loader.fs.read(SafePath.join(self.path, relative, "mod:read")) end -- 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 @@ -1149,9 +1170,21 @@ function Loader:_game() return engineRequire("src.core.Game") end +-- The environment every chunk this mod authors runs in, built once per mod so +-- its entry file and its options_schema share one globals table. +function Loader:_modEnv(mod) + local id = mod.manifest.id + local env = self.modEnv[id] + if not env then + env = Sandbox.envFor({ modId = id, permissions = mod.manifest.permissionSet }) + self.modEnv[id] = env + end + return env +end + function Loader:_loadMod(mod) - local path = mod.path .. "/" .. mod.manifest.entry - local chunk, err = self.fs.load(path) + local path = SafePath.join(mod.path, mod.manifest.entry, "manifest entry") + local chunk, err = Sandbox.loadFile(self.fs, path, self:_modEnv(mod)) if not chunk then error(err or ("unable to load " .. path)) end local api = self:_api(mod) local result = chunk(api) @@ -1349,10 +1382,12 @@ function Loader:load(data) -- every touch: a mod captures the facade at file scope, before Game2 has a -- save or a world (src/mods/Gen2Compat.lua). Gen2Compat.bind(function() return self:_game() end) - -- Dev mode wants the permissions tripwire; a Gold boot with mods on it wants - -- the Gen 1-only require report, which is the difference between "the mod - -- does nothing" and knowing why. A Gold boot with no mods pays nothing. - if self.dev or (self.generation ~= 1 and next(self.mods) ~= nil) then + -- Any boot with mods on it needs the gate, because require("io") is how a + -- mod would walk out of Sandbox.envFor. Dev mode adds the permissions + -- tripwire on top, and a Gold boot the Gen 1-only require report -- the + -- difference between "the mod does nothing" and knowing why. A boot with no + -- mods pays nothing. + if self.dev or next(self.mods) ~= nil then self:_installDevShim() end for _, mod in ipairs(ordered) do diff --git a/src/mods/ManagerState.lua b/src/mods/ManagerState.lua index 40cf0ab7..88e5bf4f 100644 --- a/src/mods/ManagerState.lua +++ b/src/mods/ManagerState.lua @@ -8,6 +8,8 @@ local Font = require("src.render.Font") local GameVersion = require("src.core.GameVersion") local ModTargets = require("src.mods.ModTargets") local Runtime = require("src.mods.Runtime") +local SafePath = require("src.mods.SafePath") +local Sandbox = require("src.mods.Sandbox") local SaveData = require("src.core.SaveData") local Semver = require("src.mods.Semver") local Version = require("src.core.Version") @@ -936,13 +938,18 @@ function ManagerState:schemaFor(m) local schema = loader.optionSchemas and loader.optionSchemas[m.id] if schema == nil and m.options_schema and m.path and loader.fs and loader.fs.load then - local chunk = loader.fs.load(m.path .. "/" .. m.options_schema) - if chunk then - local ok, rows = pcall(chunk) - if ok and type(rows) == "table" then - schema = rows - if loader.optionSchemas then loader.optionSchemas[m.id] = schema end - end + -- mod-authored code, so it runs in the same sandbox the entry chunk does + local ok, rows = pcall(function() + local path = SafePath.join(m.path, m.options_schema, "options_schema") + local mod = loader.mods and loader.mods[m.id] + local env = mod and loader._modEnv and loader:_modEnv(mod) + or Sandbox.envFor() + local chunk = Sandbox.loadFile(loader.fs, path, env) + return chunk and chunk() + end) + if ok and type(rows) == "table" then + schema = rows + if loader.optionSchemas then loader.optionSchemas[m.id] = schema end end end return schema diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index a6312c41..373797a8 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -3,6 +3,7 @@ -- that need to stat a file, this owns shape, vocabulary and range grammar. local Logger = require("src.core.Logger") local ModTargets = require("src.mods.ModTargets") +local SafePath = require("src.mods.SafePath") local Semver = require("src.mods.Semver") local Version = require("src.core.Version") @@ -144,6 +145,9 @@ function Manifest.validate(raw, path) assert(type(raw.name) == "string" and raw.name ~= "", "manifest name is required") assert(type(raw.version) == "string" and raw.version ~= "", "manifest version is required") assert(type(raw.entry) == "string" and raw.entry ~= "", "manifest entry is required") + -- every manifest path is joined to the mod's own directory, so none of them + -- may climb out of it (src/mods/SafePath.lua) + local entry = SafePath.require(raw.entry, "manifest entry") -- absent means 1: full v1 compat, schema violations downgrade to warnings assert(raw.api == nil or tonumber(raw.api) ~= nil, "manifest api must be a number") @@ -229,19 +233,24 @@ function Manifest.validate(raw, path) local affectsLink = profile ~= "content" and not language if type(raw.affects_link) == "boolean" then affectsLink = raw.affects_link end - local function optionalFile(value, field) + local function optionalString(value, field) if value == nil then return nil end assert(type(value) == "string" and value ~= "", field .. " must be a file path") return value end + local function optionalFile(value, field) + local text = optionalString(value, field) + return text and SafePath.require(text, field) + end + local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible) return { id = raw.id, name = raw.name, version = raw.version, - entry = raw.entry, + entry = entry, api = api, priority = tonumber(raw.priority) or 0, dependencies = array(raw.dependencies), @@ -265,7 +274,8 @@ function Manifest.validate(raw, path) permissionSet = permissionSet, options_schema = optionalFile(raw.options_schema, "options_schema"), assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"), - force_enable_env = optionalFile(raw.force_enable_env, "force_enable_env"), + -- an env var name, not a path, so it keeps the plain string check + force_enable_env = optionalString(raw.force_enable_env, "force_enable_env"), path = path, raw = raw, } diff --git a/src/mods/Runtime.lua b/src/mods/Runtime.lua index 1128dedc..0b2c94a8 100644 --- a/src/mods/Runtime.lua +++ b/src/mods/Runtime.lua @@ -28,6 +28,11 @@ Runtime.errors = nil -- permissions tripwire knows there is nobody to attribute to Runtime.currentMod = nil +-- set by the sandbox's require for the duration of one mod-initiated require, +-- so the loader's gate can still attribute a lazy one made long after +-- currentMod went back to nil (src/mods/Sandbox.lua) +Runtime.modRequire = nil + function Runtime.install(events, hooks, errors) Runtime.events, Runtime.hooks = events, hooks Runtime.errors = errors diff --git a/src/mods/SafePath.lua b/src/mods/SafePath.lua new file mode 100644 index 00000000..1272acc4 --- /dev/null +++ b/src/mods/SafePath.lua @@ -0,0 +1,38 @@ +-- One relative-path grammar for every path a mod supplies: a mod names files +-- inside its own directory and nowhere else. love.filesystem (PhysFS) already +-- refuses "..", absolute paths and backslashes, but Loader.new takes an +-- injected fs that has no such floor, so the rule lives here and not in +-- whichever filesystem happens to be underneath. +local SafePath = {} + +-- The normalized path, or nil when it could climb out of the root it is about +-- to be joined to. "." segments are dropped rather than rejected so a +-- manifest that says "./main.lua" still loads. +function SafePath.safe(rel) + if type(rel) ~= "string" or rel == "" then return nil end + if rel:sub(1, 1) == "/" then return nil end + if rel:find("\\", 1, true) then return nil end + if rel:match("^%a:") then return nil end -- windows drive-relative + local parts = {} + for segment in rel:gmatch("[^/]+") do + if segment == ".." then return nil end + if segment ~= "." then parts[#parts + 1] = segment end + end + if #parts == 0 then return nil end + return table.concat(parts, "/") +end + +function SafePath.require(rel, what) + local safe = SafePath.safe(rel) + if not safe then + error(("%s must stay inside its root, got %q"):format(what, tostring(rel)), 0) + end + return safe +end + +-- root .. "/" .. rel, with the traversal check in between +function SafePath.join(root, rel, what) + return root .. "/" .. SafePath.require(rel, what or "path") +end + +return SafePath diff --git a/src/mods/Sandbox.lua b/src/mods/Sandbox.lua new file mode 100644 index 00000000..1e18a4cf --- /dev/null +++ b/src/mods/Sandbox.lua @@ -0,0 +1,227 @@ +-- The environment a mod's own code runs in. Every chunk a mod authors -- the +-- entry file, an options_schema, anything it hands to load() -- runs against +-- this table instead of _G, so the only paths it can name are the ones the +-- engine hands it (mod:read, mod.storage, mod.assets). +-- +-- What this is and is not: raw io/os/ffi are the only way to name a file +-- outside the game tree at all, and they are absent here, so the reported +-- "any mod can rewrite anything in your home directory" hole closes by +-- construction. Inside the LÖVE tree this is defense in depth, not a security +-- boundary: an engine module reached through require, or ImageData:encode, +-- still writes in the save directory. +-- +-- Lua 5.1/LuaJIT is the target, so setfenv is the mechanism; the 5.2+ arm +-- exists because AssetTransform's sandbox needed it and getting this wrong +-- silently hands the chunk the real globals. + +local Runtime = require("src.mods.Runtime") +local SafePath = require("src.mods.SafePath") + +local Sandbox = {} + +-- Modules that hand a mod the disk, a raw socket or a fresh Lua state no +-- matter what this file removes from the environment. package.loaded.io is +-- the one call that would undo every other rule here. +local DENIED = { + io = "the filesystem", os = "the filesystem", debug = "the debug library", + package = "the module loader", ffi = "arbitrary C calls", +} + +-- Same idea one level up: love.filesystem is reachable by name, and +-- love.thread starts a Lua state this sandbox has no say over. +local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true } + +-- The wire, which is what the network permission governs. +local NETWORK = { socket = true, enet = true, http = true, https = true, + ssl = true, mime = true, ltn12 = true } + +local function head(name) + return (name:match("^([^%.]+)")) or name +end + +-- nil when the require is allowed, else the message to fail it with. +function Sandbox.moduleDenial(name, permissionSet) + if type(name) ~= "string" then return nil end + local root = head(name) + local reason = DENIED[root] + if reason then + return ("%s is not available to mods (it grants %s); use mod.storage, " + .. "mod:read and the engine API instead"):format(name, reason) + end + if DENIED_PREFIX[root] and name ~= root then + return ("%s is not available to mods; use mod.storage, mod:read and the " + .. "engine API instead"):format(name) + end + if NETWORK[root] and not (permissionSet or {}).network then + return ("%s needs the \"network\" permission in manifest.json"):format(name) + end + return nil +end + +-- ------- the love facade + +-- Dropped, not narrowed: filesystem writes anywhere in the save directory +-- (including another mod's storage), thread opens a Lua state with a full +-- standard library, system.openURL launches whatever it is handed, and event +-- lets a mod quit the game out from under the player. Everything else LÖVE +-- exposes passes through, so a new module in a future LÖVE is available +-- without an edit here. +-- value is the replacement to name in the error, or true when there is none +local BLOCKED_LOVE = { + filesystem = "mod.storage and mod:read", thread = true, + system = true, event = true, +} + +local loveProxy +local function loveFacade() + if loveProxy or not _G.love then return loveProxy end + loveProxy = setmetatable({}, { + __index = function(_, key) + local hint = BLOCKED_LOVE[key] + if hint then + error(("love.%s is not available to mods%s"):format(key, + type(hint) == "string" and (", use " .. hint) or ""), 2) + end + return _G.love[key] + end, + __newindex = function(_, key) + error(("mods cannot assign love.%s"):format(tostring(key)), 2) + end, + }) + return loveProxy +end + +-- ------- the environment + +-- Absent on purpose: io, package, dofile, loadfile, getfenv, setfenv, debug, +-- newproxy, module. os keeps only the clock -- getenv is how the reported +-- exploit found the user's home directory. +local SAFE_OS = { time = true, date = true, clock = true, difftime = true } + +-- Per-mod copies, not the shared tables: a mod that assigns string.trim or +-- replaces table.insert changes its own view and nobody else's. The functions +-- are the same objects, so state behind them (math.randomseed's RNG) is +-- unaffected -- only the namespace is private. +local function copy(source) + if type(source) ~= "table" then return source end + local out = {} + for key, value in pairs(source) do out[key] = value end + return out +end + +local function baseGlobals() + local safeOs = {} + for key in pairs(SAFE_OS) do safeOs[key] = os[key] end + return { + assert = assert, error = error, ipairs = ipairs, next = next, + pairs = pairs, pcall = pcall, xpcall = xpcall, select = select, + tonumber = tonumber, tostring = tostring, type = type, unpack = unpack, + rawequal = rawequal, rawget = rawget, rawset = rawset, rawlen = rawlen, + setmetatable = setmetatable, getmetatable = getmetatable, print = print, + collectgarbage = collectgarbage, _VERSION = _VERSION, + coroutine = copy(coroutine), math = copy(math), string = copy(string), + table = copy(table), bit = copy(bit), jit = jit, os = safeOs, + } +end + +-- setfenv on 5.1/LuaJIT; on 5.2+ the env has to be handed to load itself, so +-- a caller there compiles through Sandbox.compile instead. +function Sandbox.bind(chunk, env) + if setfenv then setfenv(chunk, env) end + return chunk +end + +-- Bytecode is unreviewable and, on LuaJIT, a way out of any sandbox built out +-- of environments. Mods ship source. +local function rejectBytecode(source, what) + if type(source) == "string" and source:sub(1, 1) == "\27" then + return nil, (what or "chunk") .. ": mods must ship Lua source, not bytecode" + end + return true +end + +function Sandbox.compile(source, chunkname, env) + local ok, err = rejectBytecode(source, chunkname) + if not ok then return nil, err end + if setfenv then + local chunk, compileErr = loadstring(source, chunkname) + if not chunk then return nil, compileErr end + return setfenv(chunk, env) + end + return load(source, chunkname, "t", env) +end + +-- The load() a mod sees. Lua 5.1 gives a loaded chunk the GLOBAL environment +-- rather than the caller's, so without this every sandboxed mod is one +-- load(mod:read(...)) away from the real _G -- which is exactly how the +-- multi-file mods in mods/ are written. +local function sandboxedLoad(env) + return function(chunk, chunkname) + if type(chunk) == "function" then + local parts = {} + while true do + local piece = chunk() + if piece == nil or piece == "" then break end + parts[#parts + 1] = piece + end + chunk = table.concat(parts) + end + if type(chunk) ~= "string" then return nil, "load expects a string or reader" end + return Sandbox.compile(chunk, chunkname or "=(load)", env) + end +end + +-- The require a mod sees: the deny list lives here rather than on a stack +-- walk, because pcall(require, "io") puts a C frame where the walk would look. +-- Runtime.modRequire is how the loader's gate identifies the caller for the +-- Gen 2 facade once Runtime.currentMod has gone back to nil (a mod requiring +-- lazily from an event handler). +local function sandboxedRequire(modId, permissionSet) + return function(name, ...) + local denial = Sandbox.moduleDenial(name, permissionSet) + if denial then error(("[%s] %s"):format(modId or "mod", denial), 2) end + local previous = Runtime.modRequire + Runtime.modRequire = modId or true + local ok, result = pcall(_G.require, name, ...) + Runtime.modRequire = previous + if not ok then error(result, 0) end + return result + end +end + +function Sandbox.envFor(opts) + opts = opts or {} + local env = baseGlobals() + env.love = loveFacade() + env.require = sandboxedRequire(opts.modId, opts.permissions) + local loader = sandboxedLoad(env) + env.load = loader + env.loadstring = loader + -- a mod's globals are its own: two mods no longer share a namespace, and + -- neither can reach the engine's + env._G = env + return env +end + +-- fs.load keeps the real filesystem's handling of the file; the environment is +-- swapped after the fact. The 5.2+ arm has to go back to source, which is the +-- only reason fs.read is touched here. +function Sandbox.loadFile(fs, path, env) + if fs.read then + local ok, err = rejectBytecode(fs.read(path), path) + if not ok then return nil, err end + end + if setfenv then + local chunk, err = fs.load(path) + if not chunk then return nil, err end + return setfenv(chunk, env) + end + local source = fs.read and fs.read(path) + if not source then return nil, "unable to read " .. path end + return Sandbox.compile(source, "@" .. path, env) +end + +Sandbox.safePath = SafePath.safe +Sandbox.requirePath = SafePath.require + +return Sandbox diff --git a/tests/integration/title_checkpoint_cold_start.lua b/tests/integration/title_checkpoint_cold_start.lua index 6f754155..bd1ceae7 100644 --- a/tests/integration/title_checkpoint_cold_start.lua +++ b/tests/integration/title_checkpoint_cold_start.lua @@ -72,10 +72,11 @@ local function writeProbe() fs.write("mods/cold_start_probe/manifest.json", '{"id":"cold_start_probe","name":"cold start probe","version":"1.0.0",' .. '"entry":"main.lua","api":2,"profile":"content"}') + -- mod.exports, not _G: a mod's globals are its own (src/mods/Sandbox.lua) fs.write("mods/cold_start_probe/main.lua", [[ return function(mod) - _G.COLD_STORAGE = mod.storage - _G.COLD_CHECKPOINTS = mod.checkpoints + mod.exports.storage = mod.storage + mod.exports.checkpoints = mod.checkpoints end ]]) end @@ -125,11 +126,12 @@ if phase == "capture" then local game = runtime(SaveData.newGame({ version = "red" }), false) loader.game = game assert(loader:load({}) == true) - assert(_G.COLD_STORAGE:write(game, "history/index", { newest = "q0001" })) - local checkpoint = assert(_G.COLD_CHECKPOINTS:capture(game)) - assert(_G.COLD_STORAGE:write(game, "history/q0001", checkpoint)) + local probe = assert(loader.exports.cold_start_probe) + assert(probe.storage:write(game, "history/index", { newest = "q0001" })) + local checkpoint = assert(probe.checkpoints:capture(game)) + assert(probe.storage:write(game, "history/q0001", checkpoint)) local id = assert(game.save.meta.playthroughId) - assert(_G.COLD_CHECKPOINTS:ensureNormalSave(game, checkpoint)) + assert(probe.checkpoints:ensureNormalSave(game, checkpoint)) local normal = assert(SaveData.load("red")) assert(normal.meta.playthroughId == id) fs.write("cold-start-witness.lua", SaveSerializer.encode({ playthroughId = id })) @@ -139,15 +141,16 @@ else title.save.options = { volume = 7, bindings = {} } loader.game = title assert(loader:load({}) == true) - local selected = assert(_G.COLD_STORAGE:selected(title)) + local probe = assert(loader.exports.cold_start_probe) + local selected = assert(probe.storage:selected(title)) local witness = assert(SaveSerializer.decode(assert(fs.read("cold-start-witness.lua")))) assert(selected:context().playthroughId == witness.playthroughId) assert(selected:read("history/index").newest == "q0001") local checkpoint = assert(selected:read("history/q0001")) - assert(_G.COLD_CHECKPOINTS:resume(title, checkpoint)) + assert(probe.checkpoints:resume(title, checkpoint)) assert(title.save.meta.playthroughId == witness.playthroughId) assert(title.save.options.volume == 7) - assert(SaveSerializer.encode(_G.COLD_CHECKPOINTS:capture(title)) + assert(SaveSerializer.encode(probe.checkpoints:capture(title)) == SaveSerializer.encode(checkpoint)) local normal = assert(SaveData.load("red")) assert(normal.meta.playthroughId == witness.playthroughId) diff --git a/tests/mod_loader_tests.lua b/tests/mod_loader_tests.lua index bdbf0784..6f0d0fae 100644 --- a/tests/mod_loader_tests.lua +++ b/tests/mod_loader_tests.lua @@ -102,20 +102,19 @@ if not headlessOk then error(headlessErr) end love = love or require("tests.love_stub") -- ------- discovery, dependency order, merge --- "addon" sorts before "base" so only the dependency edge can order them -_G.MOD_TEST_ORDER = {} +-- "addon" sorts before "base" so only the dependency edge can order them. +-- loader.order is the engine's own record of what ran when; a mod cannot +-- append to a shared global any more (src/mods/Sandbox.lua). local files = { ["mods/addon/manifest.json"] = manifestJson("addon", '["base"]'), ["mods/addon/main.lua"] = [[ return function(mod) - _G.MOD_TEST_ORDER[#_G.MOD_TEST_ORDER + 1] = "addon" mod.content.pokemon:override("MODMON", { name = "ADDONMON" }) end ]], ["mods/base/manifest.json"] = manifestJson("base"), ["mods/base/main.lua"] = [[ return function(mod) - _G.MOD_TEST_ORDER[#_G.MOD_TEST_ORDER + 1] = "base" mod.content.pokemon:register("MODMON", { name = "BASEMON" }) mod.content.music:register("MOD_SONG", { file = "song.ogg" }) end @@ -126,7 +125,7 @@ local loader = Loader.new({ fs = memfs(files) }) check(loader:load(data) == true, "headless load succeeds with injected fs") check(loader.mods.addon ~= nil and loader.mods.base ~= nil, "discovery finds both mods") -check(_G.MOD_TEST_ORDER[1] == "base" and _G.MOD_TEST_ORDER[2] == "addon", +check(loader.order[1] == "base" and loader.order[2] == "addon", "topo-sort runs the dependency before its dependent") check(data.pokemon.MODMON ~= nil and data.pokemon.MODMON.name == "ADDONMON", "registered content merges into data") @@ -411,6 +410,5 @@ local StateStack = require("src.core.StateStack") while StateStack:top() do StateStack:pop() end require("src.core.Music").stop() Runtime.install(savedEvents, savedHooks) -_G.MOD_TEST_ORDER = nil S.finish() diff --git a/tests/mod_manifest_tests.lua b/tests/mod_manifest_tests.lua index f5384339..0193ad64 100644 --- a/tests/mod_manifest_tests.lua +++ b/tests/mod_manifest_tests.lua @@ -341,7 +341,8 @@ check(cycleStatus.innocent.state == "loaded" and cycleData.items.FINE ~= nil, "a mod beside the cycle loads normally") -- ------- inter-mod exports and find -_G.MOD_FIND_RESULTS = {} +-- probes report through mod.exports: a mod's globals are its own +-- (src/mods/Sandbox.lua) local exportLoader = Loader.new({ fs = memfs({ ["mods/colorlib/manifest.json"] = manifestJson("colorlib"), ["mods/colorlib/main.lua"] = [[ @@ -356,7 +357,7 @@ end }), ["mods/daynight/main.lua"] = [[ return function(mod) - local results = _G.MOD_FIND_RESULTS + local results = mod.exports local color = mod.find("colorlib") results.depVersion = color.version results.tint = color.exports.tint("dusk") @@ -371,7 +372,7 @@ end ["options.lua"] = "return { mods = { shelved = false } }", }) }) check(exportLoader:load({}) == true, "the export fixture loads clean") -local found = _G.MOD_FIND_RESULTS +local found = exportLoader.exports.daynight check(found.tint == "tinted:dusk", "find returns the other mod's live export table") check(found.depVersion == "1.0.0", "the handle carries the other mod's version") check(found.optional == true, "an enabled optional dependency is findable") @@ -380,10 +381,8 @@ check(found.disabled == nil, "find returns nil for a disabled mod") check(found.method == true, "mod:find is tolerated alongside mod.find") check(exportLoader.order[1] == "colorlib", "a hard dependency executes before its dependent") -_G.MOD_FIND_RESULTS = nil -- ------- the rest of the v2 mod object -_G.MOD_OBJECT_PROBE = {} local objectLoader = Loader.new({ fs = memfs({ ["mods/probe/manifest.json"] = manifestJson("probe", { api = "2", description = '"probing"', priority = "3", @@ -391,7 +390,7 @@ local objectLoader = Loader.new({ fs = memfs({ ["mods/probe/data.txt"] = "hello from the mod dir", ["mods/probe/main.lua"] = [[ return function(mod) - local probe = _G.MOD_OBJECT_PROBE + local probe = mod.exports probe.id, probe.version, probe.path = mod.id, mod.version, mod.path probe.manifestApi = mod.manifest.api mod.manifest.api = 99 @@ -423,7 +422,7 @@ end ["options.lua"] = "return { modOptions = { probe = { volume = 4 } } }", }) }) check(objectLoader:load({ pokemon = {} }) == true, "the mod object fixture loads clean") -local probe = _G.MOD_OBJECT_PROBE +local probe = objectLoader.exports.probe check(probe.id == "probe" and probe.version == "1.0.0" and probe.path == "mods/probe", "identity fields are present") check(probe.manifestApi == 2 and objectLoader.mods.probe.manifest.api == 2, @@ -449,7 +448,6 @@ check(probe.onceCount == 1 and probe.stillHeard == true, "events:once fires once and does not skip the listener behind it") check(tostring(probe.forgery):find("may only emit", 1, true) ~= nil, "a mod cannot emit outside its own event namespace") -_G.MOD_OBJECT_PROBE = nil -- a failing entry chunk takes its exports, commands and migrations with it local residueLoader = Loader.new({ fs = memfs({ diff --git a/tests/mod_render_tests.lua b/tests/mod_render_tests.lua index 871abca2..b41b24d8 100644 --- a/tests/mod_render_tests.lua +++ b/tests/mod_render_tests.lua @@ -87,13 +87,19 @@ end -- ------- a mod registers two pipelines and the engine dispatches them -local trace = {} - +-- The probe table is the mod's, published through mod.exports: a mod's +-- globals are its own now (src/mods/Sandbox.lua). The world/present folds +-- accept only a real Canvas, so the mod makes concrete ones to return and the +-- test pins identity through the dispatch. local FILES = { ["mods/painter/manifest.json"] = manifest("painter", ',"priority":10'), ["mods/painter/main.lua"] = [[ local mod = ... - local T = _G.__RENDER_TEST + local T = mod.exports + T.trace, T.available = {}, true + T.worldOut = love.graphics.newCanvas(2, 2) + T.blurOut = love.graphics.newCanvas(2, 2) + T.gradeOut = love.graphics.newCanvas(2, 2) mod.content.render_pipelines:register("diorama", { label = "DIORAMA", levels = { "OFF", "LOW", "HIGH" }, @@ -101,8 +107,6 @@ local FILES = { priority = 20, available = function() return T.available end, update = function(dt, level) T.trace[#T.trace + 1] = "update:" .. level end, - -- the folds composite only a real Canvas, so the mod hands back the - -- canvases the test pre-created (see T.worldOut / T.blurOut / T.gradeOut) drawWorld = function(ctx) T.trace[#T.trace + 1] = "world:" .. tostring(ctx.tag) return T.worldOut @@ -123,17 +127,12 @@ local FILES = { ]], } -_G.__RENDER_TEST = { trace = trace, available = true } --- the world/present folds accept only a real Canvas, so give the mod concrete --- ones to return and pin identity through the dispatch -_G.__RENDER_TEST.worldOut = love.graphics.newCanvas(2, 2) -_G.__RENDER_TEST.blurOut = love.graphics.newCanvas(2, 2) -_G.__RENDER_TEST.gradeOut = love.graphics.newCanvas(2, 2) - local data = {} local loader = Loader.new({ fs = memfs(FILES) }) local okLoad = loader:load(data) check(okLoad, "the pipeline mod loads clean: " .. table.concat(loader.errors, "; ")) +local RT = loader.exports.painter +local trace = RT.trace Pipelines.install(data) check(type(data.render_pipelines) == "table", @@ -169,24 +168,24 @@ Pipelines.setLevel("grade", 1) eq(Pipelines.worldPipeline(), "diorama", "the eligible world pipeline claims the world pass") -eq(Pipelines.drawWorld("diorama", { tag = "ctx" }), _G.__RENDER_TEST.worldOut, +eq(Pipelines.drawWorld("diorama", { tag = "ctx" }), RT.worldOut, "drawWorld returns the mod's canvas") eq(trace[#trace], "world:ctx", "drawWorld received the frame context") -eq(Pipelines.worldPresent(_G.__RENDER_TEST.worldOut), _G.__RENDER_TEST.blurOut, +eq(Pipelines.worldPresent(RT.worldOut), RT.blurOut, "worldPresent folds its canvas over the world image") eq(Pipelines.wantsPresent(), true, "a live present pass asks for the canvas") -eq(Pipelines.present(_G.__RENDER_TEST.gradeOut), _G.__RENDER_TEST.gradeOut, +eq(Pipelines.present(RT.gradeOut), RT.gradeOut, "present folds its canvas over the finished composite") -- ------- the hardware gate -_G.__RENDER_TEST.available = false +RT.available = false eq(Pipelines.worldPipeline(), nil, "an unavailable pipeline never takes the world pass") eq(Pipelines.worldPresent("world-canvas"), "world-canvas", "an unavailable pipeline's worldPresent is skipped") -_G.__RENDER_TEST.available = true +RT.available = true eq(Pipelines.worldPipeline(), "diorama", "availability is re-read each frame") -- ------- the gate governs input, never the draw @@ -282,7 +281,8 @@ local SLOPPY = { ["mods/sloppy/manifest.json"] = manifest("sloppy"), ["mods/sloppy/main.lua"] = [[ local mod = ... - local T = _G.__SLOPPY + local T = mod.exports + T.ran = 0 mod.content.render_pipelines:register("sloppy", { label = "SLOPPY", present = function(canvas) @@ -302,20 +302,20 @@ local SLOPPY = { }) ]], } -_G.__SLOPPY = { ran = 0 } local sloppyData = {} local sloppyLoader = Loader.new({ fs = memfs(SLOPPY) }) sloppyLoader:load(sloppyData) +local SL = sloppyLoader.exports.sloppy Pipelines.install(sloppyData) local composite = love.graphics.newCanvas(4, 4) Pipelines.setLevel("sloppy", 1) for _, bad in ipairs({ "just-a-string", true, 42 }) do - _G.__SLOPPY.ret = bad + SL.ret = bad eq(Pipelines.present(composite), composite, "a present returning a " .. type(bad) .. " leaves the composite untouched") end -check(_G.__SLOPPY.ran == 3, "the present callback still ran each frame") +check(SL.ran == 3, "the present callback still ran each frame") check(Pipelines.eligible("sloppy") == true, "a non-canvas return does not retire the pipeline as broken") Pipelines.setLevel("sloppy", 0) @@ -334,11 +334,9 @@ eq(love.graphics.getCanvas(), "engine-canvas", eq(love.graphics.getBlendMode(), "alpha", "a present that changed blend mode cannot leak it past the fold") Pipelines.setLevel("dirty", 0) -_G.__SLOPPY = nil Pipelines.reset() Pipelines.install(nil) -_G.__RENDER_TEST = nil -- ------- and with no mods at all, the whole subsystem is inert diff --git a/tests/modkit/cases/checkpoints.lua b/tests/modkit/cases/checkpoints.lua index adea5f90..faf19c3d 100644 --- a/tests/modkit/cases/checkpoints.lua +++ b/tests/modkit/cases/checkpoints.lua @@ -139,20 +139,20 @@ local files = { ["mods/probe/manifest.json"] = '{"id":"probe","name":"probe","version":"1.0.0",' .. '"entry":"main.lua","api":2,"profile":"content"}', + -- mod.exports, not _G: a mod's globals are its own (src/mods/Sandbox.lua) ["mods/probe/main.lua"] = [[ -return function(mod) _G.MOD_CHECKPOINTS = mod.checkpoints end +return function(mod) mod.exports.checkpoints = mod.checkpoints end ]], } local game, ow = makeGame() local loader = Loader.new({ fs = memfs(files) }) loader.game = game T.check(loader:load({}) == true, "checkpoint fixture mod loads") -local checkpoints = _G.MOD_CHECKPOINTS +local checkpoints = (loader.exports.probe or {}).checkpoints T.check(type(checkpoints) == "table", "Loader exposes mod.checkpoints through the public mod object") if type(checkpoints) ~= "table" then Runtime.events, Runtime.hooks = savedEvents, savedHooks - _G.MOD_CHECKPOINTS = nil T.finish() end @@ -435,7 +435,6 @@ end Runtime.events, Runtime.hooks = savedEvents, savedHooks Runtime.currentMod = nil -_G.MOD_CHECKPOINTS = nil love.math.getRandomState = oldGetRandomState love.math.setRandomState = oldSetRandomState diff --git a/tests/modkit/cases/platform_lifecycle_hooks.lua b/tests/modkit/cases/platform_lifecycle_hooks.lua index 88d95336..6a2d70f5 100644 --- a/tests/modkit/cases/platform_lifecycle_hooks.lua +++ b/tests/modkit/cases/platform_lifecycle_hooks.lua @@ -23,16 +23,16 @@ local FIXTURE = { extraPolls = extraPolls + 1 if not paused then nextFn(game, dt) end end) + local vetoQuit = false mod.hooks:wrap("core.quit_to_launcher", function(nextFn) - if os.getenv("FIXTURE_VETO_QUIT") == "1" then return false end + if vetoQuit then return false end return nextFn() end) - -- test-only knobs, read back through mod.storage-free globals since - -- this fixture never leaves the process - _G.__fixturePlatformBridge = { - setPaused = function(v) paused = v end, - extraPolls = function() return extraPolls end, - } + -- test-only knobs on mod.exports: a sandboxed mod has no shared _G to + -- smuggle them through (src/mods/Sandbox.lua) + mod.exports.setPaused = function(v) paused = v end + mod.exports.extraPolls = function() return extraPolls end + mod.exports.setVetoQuit = function(v) vetoQuit = v end ]], } @@ -43,23 +43,23 @@ do T.eq(#run.errors, 0, "the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")") + local bridge = run.loader.exports.fix_platform_bridge local calls = 0 local fakeGame = { update = function(self, dt) calls = calls + 1 end } - _G.__fixturePlatformBridge.setPaused(false) + bridge.setPaused(false) PlatformHooks.update(fakeGame, 1 / 60) T.eq(calls, 1, "unpaused: vanilla Game:update runs") - T.eq(_G.__fixturePlatformBridge.extraPolls(), 1, + T.eq(bridge.extraPolls(), 1, "the subscriber's wrapper runs every frame") - _G.__fixturePlatformBridge.setPaused(true) + bridge.setPaused(true) PlatformHooks.update(fakeGame, 1 / 60) T.eq(calls, 1, "paused: vanilla Game:update is skipped") - T.eq(_G.__fixturePlatformBridge.extraPolls(), 2, + T.eq(bridge.extraPolls(), 2, "the subscriber keeps polling every frame while paused") run.release() - _G.__fixturePlatformBridge = nil end -- core.quit_to_launcher: a subscriber can veto without the vanilla @@ -70,11 +70,8 @@ do T.eq(#run.errors, 0, "the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")") - local realGetenv = os.getenv - os.getenv = function(name) - if name == "FIXTURE_VETO_QUIT" then return "1" end - return realGetenv(name) - end + local bridge = run.loader.exports.fix_platform_bridge + bridge.setVetoQuit(true) local vanillaCalls = 0 local vetoed = PlatformHooks.quitToLauncher(function() vanillaCalls = vanillaCalls + 1 @@ -82,7 +79,7 @@ do end) T.eq(vetoed, false, "a subscriber can veto the return-to-launcher decision") T.eq(vanillaCalls, 0, "a veto never evaluates the vanilla condition") - os.getenv = realGetenv + bridge.setVetoQuit(false) local passed = PlatformHooks.quitToLauncher(function() return true end) T.eq(passed, true, "with no veto, the vanilla decision passes through unchanged") diff --git a/tests/modkit/cases/sandbox.lua b/tests/modkit/cases/sandbox.lua new file mode 100644 index 00000000..dff6767a --- /dev/null +++ b/tests/modkit/cases/sandbox.lua @@ -0,0 +1,207 @@ +-- T4: the mod sandbox (src/mods/Sandbox.lua). A mod's own chunks run against +-- an environment with no io, no os beyond the clock, and no way to name a path +-- outside its own directory, so a mod cannot reach the player's filesystem. +-- Every case here is an escape a mod would actually try. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Manifest = require("src.mods.Manifest") +local Sandbox = require("src.mods.Sandbox") +local SafePath = require("src.mods.SafePath") + +local function manifest(id, extra) + return ('{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua",' + .. '"api":2%s}'):format(id, id, extra or "") +end + +-- what a probe reports back; pcall'd so one broken assumption does not take +-- the whole entry chunk down and hide the rest +local PROBE = [[ + local mod = ... + local out = mod.exports + out.io = io + out.package = package + out.dofile = dofile + out.loadfile = loadfile + out.setfenv = setfenv + out.getfenv = getfenv + out.debug = debug + out.osGetenv = os.getenv + out.osExecute = os.execute + out.osRemove = os.remove + out.osTime = type(os.time) + out.stringOk = ("a"):rep(3) + + local function attempt(fn, ...) + local ok, err = pcall(fn, ...) + if ok then return false end + return tostring(err) + end + out.requireIo = attempt(require, "io") + out.requireOs = attempt(require, "os") + out.requireDebug = attempt(require, "debug") + out.requirePackage = attempt(require, "package") + out.requireFfi = attempt(require, "ffi") + out.requireLoveFs = attempt(require, "love.filesystem") + out.requireSocket = attempt(require, "socket") + -- called from a nested Lua frame rather than straight off pcall, which is + -- the shape a stack-walking gate reads differently + out.requireIoNested = attempt(function() return require("io") end) + out.requireSemver = select(2, pcall(require, "src.mods.Semver")) + + out.loveFilesystem = attempt(function() return love.filesystem end) + out.loveThread = attempt(function() return love.thread end) + out.loveSystem = attempt(function() return love.system end) + out.loveGraphics = type(love.graphics) + out.loveAssign = attempt(function() love.filesystem = {} end) + + -- the multi-file pattern mods/timekeepers_hut uses: a chunk loaded from the + -- mod's own source must inherit the sandbox, not the real globals + local child = load("return io, os.getenv, _G") + local childIo, childGetenv, childG = child() + out.childIo = childIo + out.childGetenv = childGetenv + out.childSharesEnv = childG == _G + + out.readEscape = attempt(function() return mod:read("../../secret.txt") end) + out.readAbsolute = attempt(function() return mod:read("/etc/hosts") end) + out.readBackslash = attempt(function() return mod:read("..\\secret.txt") end) + out.assetsEscape = attempt(function() return mod.assets:path("../../x.png") end) + out.readOwn = mod:read("data/note.txt") + + _G.SANDBOX_LEAK = "escaped" + out.globalsAreOwn = _G ~= nil and _G.SANDBOX_LEAK == "escaped" + -- a mod stomping the standard library must not reach the engine + table.insert = function() error("stomped") end + string.format = function() error("stomped") end +]] + +local FILES = { + ["mods/fix_sandbox/manifest.json"] = manifest("fix_sandbox"), + ["mods/fix_sandbox/main.lua"] = PROBE, + ["mods/fix_sandbox/data/note.txt"] = "own file", +} + +local run = T.sdk.loadMods({ "mods/fix_sandbox" }, { fs = T.sdk.memfs(FILES) }) +T.eq(#run.errors, 0, + "the probe mod loads clean (" .. tostring(run.errors[1]) .. ")") +local out = run.loader.exports.fix_sandbox or {} + +-- ------- the standard library a mod does not get + +T.eq(out.io, nil, "io is absent from the mod environment") +T.eq(out.package, nil, "package is absent, so package.loaded is unreachable") +T.eq(out.dofile, nil, "dofile is absent") +T.eq(out.loadfile, nil, "loadfile is absent") +T.eq(out.setfenv, nil, "setfenv is absent, so a mod cannot swap its own env") +T.eq(out.getfenv, nil, "getfenv is absent, so a mod cannot read the real _G out") +T.eq(out.debug, nil, "the debug library is absent") +T.eq(out.osGetenv, nil, "os.getenv is absent -- it is how the report's exploit " + .. "found the user's home directory") +T.eq(out.osExecute, nil, "os.execute is absent") +T.eq(out.osRemove, nil, "os.remove is absent") +T.eq(out.osTime, "function", "os.time still works: the clock is not the hole") +T.eq(out.stringOk, "aaa", "the safe standard library is intact") + +-- ------- require, the one call that would undo all of the above + +T.check(out.requireIo and out.requireIo:find("not available to mods", 1, true), + "require(\"io\") is refused: " .. tostring(out.requireIo)) +T.check(out.requireOs ~= false, "require(\"os\") is refused") +T.check(out.requireDebug ~= false, "require(\"debug\") is refused") +T.check(out.requirePackage ~= false, "require(\"package\") is refused") +T.check(out.requireFfi ~= false, "require(\"ffi\") is refused: it is arbitrary C") +T.check(out.requireLoveFs ~= false, "require(\"love.filesystem\") is refused") +T.check(out.requireIoNested ~= false, + "require(\"io\") from a nested frame is refused the same way") +T.check(out.requireSocket and out.requireSocket:find("network", 1, true), + "a network module names the permission it needs: " .. tostring(out.requireSocket)) +T.eq(type(out.requireSemver), "table", + "the supported engine requires still resolve") + +-- ------- the love facade + +T.check(out.loveFilesystem and out.loveFilesystem:find("mod.storage", 1, true), + "love.filesystem is refused and names the replacement") +T.check(out.loveThread ~= false, "love.thread is refused: it opens a full Lua state") +T.check(out.loveSystem ~= false, "love.system is refused: openURL launches anything") +T.eq(out.loveGraphics, "table", "the rest of love passes through") +T.check(out.loveAssign ~= false, "a mod cannot assign into the love facade") + +-- ------- env propagation and isolation + +T.eq(out.childIo, nil, + "a chunk a mod load()s inherits the sandbox (5.1 would hand it the real _G)") +T.eq(out.childGetenv, nil, "the child chunk gets the same reduced os") +T.check(out.childSharesEnv, "the child chunk shares the mod's own globals table") +T.check(out.globalsAreOwn, "a mod's globals write to its own table") +T.eq(_G.SANDBOX_LEAK, nil, "and never reach the engine's _G") +T.eq(("%d"):format(1), "1", + "a mod stomping string.format cannot reach the engine's copy") +do + local probe = {} + table.insert(probe, "still works") + T.eq(probe[1], "still works", + "nor table.insert -- each mod gets its own standard-library namespace") +end + +-- ------- paths + +T.check(out.readEscape and out.readEscape:find("must stay inside", 1, true), + "mod:read cannot climb out of the mod directory: " .. tostring(out.readEscape)) +T.check(out.readAbsolute ~= false, "mod:read refuses an absolute path") +T.check(out.readBackslash ~= false, "mod:read refuses a backslash climb") +T.check(out.assetsEscape ~= false, "mod.assets:path refuses a climb") +T.eq(out.readOwn, "own file", "and the mod's own files still read") +run.release() + +-- ------- the grammar itself + +for _, bad in ipairs({ "../x", "a/../../x", "/etc/hosts", "C:/Windows/x", + "..\\x", "a\\b", "..", ".", "" }) do + T.eq(SafePath.safe(bad), nil, ("SafePath rejects %q"):format(bad)) +end +T.eq(SafePath.safe("maps/NEW_BARK_TOWN.lua"), "maps/NEW_BARK_TOWN.lua", + "an ordinary relative path passes") +T.eq(SafePath.safe("./main.lua"), "main.lua", + "a leading ./ is normalized rather than rejected, so older manifests load") + +-- ------- manifest paths are untrusted input too + +T.check(not pcall(Manifest.validate, + { id = "evil", name = "evil", version = "1.0.0", entry = "../../../evil.lua" }), + "a manifest cannot point entry outside the mod directory") +T.check(not pcall(Manifest.validate, + { id = "evil", name = "evil", version = "1.0.0", entry = "main.lua", + options_schema = "../../options.lua" }), + "nor options_schema") +T.check(pcall(Manifest.validate, + { id = "fine", name = "fine", version = "1.0.0", entry = "main.lua" }), + "an ordinary manifest still validates") + +-- ------- bytecode + +do + local bad = { + ["mods/fix_bytecode/manifest.json"] = manifest("fix_bytecode"), + ["mods/fix_bytecode/main.lua"] = string.dump(function() end), + } + local bytecodeRun = T.sdk.loadMods({ "mods/fix_bytecode" }, + { fs = T.sdk.memfs(bad) }) + T.eq(#bytecodeRun.errors, 1, "a mod that ships bytecode fails to load") + T.check(tostring(bytecodeRun.errors[1]):find("bytecode", 1, true), + "and says why: " .. tostring(bytecodeRun.errors[1])) + bytecodeRun.release() +end + +-- ------- the sandbox is not opt-in + +do + local env = Sandbox.envFor({ modId = "probe" }) + T.eq(env.io, nil, "a bare Sandbox.envFor is already closed") + T.eq(env._G, env, "_G points at the sandbox, not the real globals") + T.check(not pcall(env.require, "io"), "and its require refuses io") +end + +T.finish("sandbox") diff --git a/tests/modkit/cases/storage.lua b/tests/modkit/cases/storage.lua index 56564c31..a48d0131 100644 --- a/tests/modkit/cases/storage.lua +++ b/tests/modkit/cases/storage.lua @@ -66,12 +66,13 @@ end local files = { ["mods/alpha/manifest.json"] = manifest("alpha"), + -- mod.exports, not _G: a mod's globals are its own (src/mods/Sandbox.lua) ["mods/alpha/main.lua"] = [[ -return function(mod) _G.MOD_STORAGE_ALPHA = mod.storage end +return function(mod) mod.exports.storage = mod.storage end ]], ["mods/beta/manifest.json"] = manifest("beta"), ["mods/beta/main.lua"] = [[ -return function(mod) _G.MOD_STORAGE_BETA = mod.storage end +return function(mod) mod.exports.storage = mod.storage end ]], } local fs = memfs(files) @@ -80,12 +81,12 @@ local current = game("red", "play-a") loader.game = current T.check(loader:load({}) == true, "storage fixture mods load") -local alpha, beta = _G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA +local alpha = (loader.exports.alpha or {}).storage +local beta = (loader.exports.beta or {}).storage T.check(type(alpha) == "table" and type(beta) == "table", "Loader exposes mod.storage through the public mod object") if type(alpha) ~= "table" or type(beta) ~= "table" then Runtime.events, Runtime.hooks = savedEvents, savedHooks - _G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA = nil, nil T.finish() end @@ -176,6 +177,5 @@ T.eq(next(emptyFiles), nil, "no-mod boot creates no storage paths or files") Runtime.events, Runtime.hooks = savedEvents, savedHooks Runtime.currentMod = nil -_G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA = nil, nil T.finish() diff --git a/tests/modkit/cases/title_playthrough_context.lua b/tests/modkit/cases/title_playthrough_context.lua index ab40f26d..0e483213 100644 --- a/tests/modkit/cases/title_playthrough_context.lua +++ b/tests/modkit/cases/title_playthrough_context.lua @@ -57,13 +57,16 @@ local files = { ["mods/probe/manifest.json"] = '{"id":"probe","name":"probe","version":"1.0.0",' .. '"entry":"main.lua","api":2,"profile":"content"}', + -- mod.exports, not _G: a mod's globals are its own (src/mods/Sandbox.lua) ["mods/probe/main.lua"] = [[ return function(mod) - _G.MOD_TITLE_STORAGE = mod.storage - _G.MOD_TITLE_CHECKPOINTS = mod.checkpoints + local out = mod.exports + out.storage = mod.storage + out.checkpoints = mod.checkpoints + out.restoreCount = 0 mod.events:on("checkpoint.restored", function(ev) - _G.MOD_TITLE_RESTORE_COUNT = (_G.MOD_TITLE_RESTORE_COUNT or 0) + 1 - _G.MOD_TITLE_RESTORE_KIND = ev.kind + out.restoreCount = out.restoreCount + 1 + out.restoreKind = ev.kind end) end ]], @@ -82,7 +85,8 @@ local loader = Loader.new({ fs = fs }) loader.game = active T.check(loader:load({}) == true, "title-context fixture mod loads") -local storage = _G.MOD_TITLE_STORAGE +local probe = loader.exports.probe or {} +local storage = probe.storage T.check(type(storage) == "table", "loader exposes the public storage facade") if type(storage) == "table" then local written, writeCode, writeMessage = storage:write(active, "history/index", { @@ -178,7 +182,7 @@ if type(storage) == "table" then end local runtime = makeRuntime(active.save, false) - local checkpoints = _G.MOD_TITLE_CHECKPOINTS + local checkpoints = probe.checkpoints T.check(type(checkpoints) == "table", "loader exposes the public checkpoint facade") local checkpoint = checkpoints and checkpoints:capture(runtime) T.check(type(checkpoint) == "table", @@ -226,9 +230,9 @@ if type(storage) == "table" then "title bootstrap never rewrites the first normal save") T.same(checkpoints:capture(titleRuntime), checkpoint, "bootstrapped overworld differentially recaptures the selected checkpoint") - T.eq(_G.MOD_TITLE_RESTORE_COUNT, 1, + T.eq(probe.restoreCount, 1, "a successfully verified title resume emits checkpoint.restored exactly once") - T.eq(_G.MOD_TITLE_RESTORE_KIND, "overworld", + T.eq(probe.restoreKind, "overworld", "title resume lifecycle reports the reconstructed checkpoint kind") -- Force a failure after restoreCheckpointSave has already installed the @@ -255,7 +259,7 @@ if type(storage) == "table" then version = "red", meta = { playthroughId = originalId }, }, fs).savedAt, anchoredAt, "failed title reconstruction never rewrites the normal Pokémon save") - T.eq(_G.MOD_TITLE_RESTORE_COUNT, 1, + T.eq(probe.restoreCount, 1, "failed title reconstruction emits no additional restored lifecycle event") end @@ -290,10 +294,6 @@ end Runtime.events, Runtime.hooks = savedEvents, savedHooks Runtime.currentMod = nil -_G.MOD_TITLE_STORAGE = nil -_G.MOD_TITLE_CHECKPOINTS = nil -_G.MOD_TITLE_RESTORE_COUNT = nil -_G.MOD_TITLE_RESTORE_KIND = nil SaveData.resetSlotState() SaveData.loadOptions = originalLoadOptions love.filesystem = realFs diff --git a/tests/modkit_tests.lua b/tests/modkit_tests.lua index 90b795f9..fc5758cc 100644 --- a/tests/modkit_tests.lua +++ b/tests/modkit_tests.lua @@ -125,8 +125,11 @@ local hotFiles = { ["mods/hot_mod/main.lua"] = [[ return function(mod) mod.content.pokemon:patch("FIXMON_A", { baseStats = { speed = 99 } }) + -- counted on mod.exports, not _G: a mod's globals are its own + -- (src/mods/Sandbox.lua), and a reload gives it a fresh table + local out = mod.exports mod.events:on("game.ready", function() - _G.MODKIT_TEST_READY = (_G.MODKIT_TEST_READY or 0) + 1 + out.ready = (out.ready or 0) + 1 end) end ]], @@ -148,7 +151,6 @@ local function freshHotData() return d end -_G.MODKIT_TEST_READY = 0 local hotData = freshHotData() local game = { data = hotData, save = { modData = {} } } local bootLoader = Loader.new({ fs = hotFs }) @@ -209,7 +211,8 @@ check(deepEqual(hotData.pokemon.FIXMON_B, fixture.load().pokemon.FIXMON_B), "untouched base record is byte-identical after reload") check(flushed >= 1, "reload flushed the registered caches") check(summary:find("reloaded 1 mods", 1, true) ~= nil, "reload summary counts") -check(_G.MODKIT_TEST_READY >= 1, "game.ready re-reaches re-subscribed mods") +check((game.mods.exports.hot_mod or {}).ready >= 1, + "game.ready re-reaches re-subscribed mods") ChipAudio.stopMusic = savedStopMusic check(musicStops >= 1, "reload stops chip music through the cache bus") Sound.play(beepData, "Fix_Beep") @@ -228,7 +231,6 @@ local broken = HotReload.run(game, { fs = hotFs }) check(#broken.errors > 0, "broken edit lands in the error feed") check(hotData.pokemon.FIXMON_A.baseStats.speed == 45, "broken mod rolls back to pristine base") -_G.MODKIT_TEST_READY = nil -- ------- dev console: repl, verbs, tracer, input isolation From d38faab03ca3f9726b4992be48c1507c69e862c1 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 12 Aug 2026 09:49:43 -0400 Subject: [PATCH 02/13] Delete mod-sandbox-notice.txt --- mod-sandbox-notice.txt | 100 ----------------------------------------- 1 file changed, 100 deletions(-) delete mode 100644 mod-sandbox-notice.txt diff --git a/mod-sandbox-notice.txt b/mod-sandbox-notice.txt deleted file mode 100644 index a936f3aa..00000000 --- a/mod-sandbox-notice.txt +++ /dev/null @@ -1,100 +0,0 @@ -Discord announcement for mod authors: the mod sandbox. -Paste each block below as its own message (Discord caps a message at 2000 -characters). Delete the ===== MESSAGE N ===== separator lines first. -Discord does not render markdown tables, so the lists below are code blocks. - - -===== MESSAGE 1 ===== - -# Heads up for mod authors: mods now run in a sandbox - -**Short version:** if your mod uses `io`, `os.getenv`, `os.execute` or `love.filesystem`, it will stop loading in the next release. There is a replacement for every legitimate use, and it is usually one line. - -## What changed - -Until now, a mod's Lua code ran against the engine's own globals. That meant any installed mod had full read and write access to the player's entire filesystem: their documents, their photos, anything their user account could touch. The `permissions` field in `manifest.json` did not actually enforce anything. - -That was our design mistake, not yours. If your mod used `io`, it was using the API exactly as it was handed to you. - -Starting with the next release, mod code runs in a restricted environment. A mod can read its own folder and write its own storage. It cannot name a path outside the game, in any spelling. - - -===== MESSAGE 2 ===== - -## What is no longer available - -``` -GONE USE INSTEAD -io.* and require("io") mod:read("file") for your own files - mod.storage to persist data -os.getenv / execute / remove nothing. os.time, os.date and - / rename / exit os.clock still work -package, dofile, loadfile, debug, require still resolves the - getfenv, setfenv supported engine modules -require("ffi") the love table you already have -require("love.filesystem") the love table you already have -love.filesystem mod.storage (scoped per mod and - per playthrough) and mod:read -love.thread, love.system, love.event mod.events, mod.hooks -``` - -Everything else in `love` is unchanged: graphics, audio, timers and input all work exactly as before. So does the whole standard library apart from the lines above. - - -===== MESSAGE 3 ===== - -## Three smaller changes in the same release - -**1. Your globals are your own.** `_G` inside a mod is now that mod's private table. If you were passing data to another mod through a global, put it on `mod.exports` and let them read it with `mod.find("your_id").exports`. That was always the intended channel, and unlike a global it survives load order and tells you the other mod's version. - -**2. Paths cannot climb out of your folder.** `mod:read`, `mod.assets:path` and `mod.assets:image` refuse `..`, absolute paths and drive letters, as do `entry` and `options_schema` in your manifest. - -**3. Ship source, not bytecode.** A precompiled `main.lua` is refused. - - -===== MESSAGE 4 ===== - -## Check your mod in one command - -```sh -grep -rnE '\bio\.|os\.(getenv|execute|remove|rename|exit|tmpname)|love\.(filesystem|thread|system|event)|require\("(io|os|debug|package|ffi|love\.)' your_mod/ -``` - -No output means you are fine and nothing about your mod changes. - -If you do get hits, the load-time errors name the replacement directly, so you can also just run your mod and read what it tells you: - -```sh -python3 tools/modkit.py validate mods/your_mod --base imported -``` - - -===== MESSAGE 5 ===== - -## The most common case - -Nearly every real use of `io` in a mod was "save some state of my own", and that is what `mod.storage` is for. It is data-only, already scoped to your mod and the current playthrough, and it survives the player switching profiles. - -```lua --- before -local f = io.open("my_settings.txt", "w") -f:write(value) -f:close() - --- after -mod.storage:write(game, "settings", { value = value }) -local saved = mod.storage:read(game, "settings") -``` - - -===== MESSAGE 6 ===== - -## About `permissions` - -There is no permission that grants raw filesystem access, and there will not be one. Nothing a mod legitimately does needs it, and a permission that can be requested is a permission that gets requested. `permissions` is still shown to the player in the mod manager, and `network` now genuinely gates the networking modules. - -## If you have a case this does not cover - -Please open an issue rather than working around it. If there is a real thing mods need to do that the sandbox blocks, that is a gap in the API and we want to fix it properly, with a scoped engine call rather than a hole. - -Thanks for building for this project, and sorry for the churn. This one was worth the disruption: it means a player installing your mod is trusting it with the game, and not with their whole machine. From e44769a48a4e885ff0522dd0ca6e694464237288 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:13:32 +0200 Subject: [PATCH 03/13] Expose read-only device power info to mods --- CONTRIBUTING-mods.md | 3 +- docs/modding.md | 14 +++++++ docs/rfcs/0008-device-power-info.md | 49 ++++++++++++++++++++++ src/mods/Loader.lua | 10 +++++ src/mods/Sandbox.lua | 2 +- tests/modkit/cases/device_power_info.lua | 52 ++++++++++++++++++++++++ tests/modkit/cases/sandbox.lua | 3 +- 7 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 docs/rfcs/0008-device-power-info.md create mode 100644 tests/modkit/cases/device_power_info.lua diff --git a/CONTRIBUTING-mods.md b/CONTRIBUTING-mods.md index d6148691..7f1dc558 100644 --- a/CONTRIBUTING-mods.md +++ b/CONTRIBUTING-mods.md @@ -255,7 +255,8 @@ engine's globals. Every chunk you author gets it: `main.lua`, your | `package`, `dofile`, `loadfile`, `debug`, `getfenv`, `setfenv` | `require` for the supported engine modules | | `require("ffi")`, `require("love.*")` | the `love` table you are given | | `love.filesystem` | `mod.storage` (per-mod, per-playthrough) and `mod:read` | -| `love.thread`, `love.system`, `love.event` | `mod.events`, `mod.hooks` | +| `love.thread`, `love.event` | `mod.events`, `mod.hooks` | +| `love.system` | `mod.device:powerInfo()` for battery information | The rest of `love` passes through unchanged, so graphics, audio, timers and input work as they always have. diff --git a/docs/modding.md b/docs/modding.md index cfb70eb0..629ced50 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -514,3 +514,17 @@ local both = mod.datetime:dateTime(game, createdAt) The live `game` supplies only the current option context. Formatting never mutates the save, options, or timestamp, and invalid timestamps return `"----"`. + +## Device power information + +Sandboxed mods can read the host's battery state without receiving the rest +of `love.system`: + +```lua +local state, percent = mod.device:powerInfo() +``` + +`state` follows LÖVE's values: `"unknown"`, `"battery"`, `"nobattery"`, +`"charging"`, or `"charged"`. `percent` is `0` through `100`, or `nil` when +the platform cannot report it. The facade is read-only and does not expose +URL launching, clipboard access, or other system operations. diff --git a/docs/rfcs/0008-device-power-info.md b/docs/rfcs/0008-device-power-info.md new file mode 100644 index 00000000..cafe668f --- /dev/null +++ b/docs/rfcs/0008-device-power-info.md @@ -0,0 +1,49 @@ +# RFC 0008 — Read-only device power information for sandboxed mods + +## Status + +Proposed. Engine: `Loader.lua`, `Sandbox.lua`. Test: +`tests/modkit/cases/device_power_info.lua`. + +## Motivation + +A handheld UI mod can show the player's battery state and warn before power +loss. The sandbox correctly removes `love.system` because that module also +launches URLs and exposes other host operations, but it leaves no scoped way +to read the harmless power values that LÖVE already provides. + +## The decision it extends + +Extends the mod sandbox in `src/mods/Sandbox.lua`: blocked host modules stay +blocked while legitimate operations receive narrow engine-owned facades. + +## The exact API delta + +Add `mod.device:powerInfo() -> state, percent`. + +The engine calls `love.system.getPowerInfo()` outside the mod sandbox and +returns only its first two values. `state` is one of LÖVE's standard power +states. `percent` is `0` through `100` or `nil`. When the platform has no +power-information backend, the result is `"unknown", nil`. + +No permission grants access to `love.system`; URL launching, clipboard access, +OS identification, and the module table itself remain unavailable. + +## Migration note for existing mods + +Mods that used `love.system.getPowerInfo()` replace that call with +`mod.device:powerInfo()`. No other mod changes. + +## Parity tests + +- **No mod:** loading no mods does not call the platform power backend. +- **Mod API:** a fixture mod loaded through the public loader receives state + and percentage through `mod.device`, while the existing sandbox suite keeps + proving that direct `love.system` access is refused. +- **Unavailable backend:** the public facade returns `"unknown", nil` rather + than inventing battery data or failing mod load. + +## Deprecation etiquette + +Nothing deprecated. The facade is additive; the sandbox's `love.system` block +remains in force. diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 798f6def..15a7e0a6 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -999,6 +999,16 @@ function Loader:_api(mod) return DateTime.dateTime(game, timestamp) end, }, + -- The read-only part of love.system that device UIs legitimately need. + -- Do not expose the module: openURL and clipboard access stay sandboxed. + device = { + powerInfo = function() + local getPowerInfo = love and love.system and love.system.getPowerInfo + if not getPowerInfo then return "unknown", nil end + local state, percent = getPowerInfo() + return state, percent + end, + }, -- namespaced per mod; M11 backs these with save.modData / -- options.modOptions, the shape mods compile against is already final save = { diff --git a/src/mods/Sandbox.lua b/src/mods/Sandbox.lua index 1e18a4cf..eb261888 100644 --- a/src/mods/Sandbox.lua +++ b/src/mods/Sandbox.lua @@ -69,7 +69,7 @@ end -- value is the replacement to name in the error, or true when there is none local BLOCKED_LOVE = { filesystem = "mod.storage and mod:read", thread = true, - system = true, event = true, + system = "mod.device:powerInfo() for battery information", event = true, } local loveProxy diff --git a/tests/modkit/cases/device_power_info.lua b/tests/modkit/cases/device_power_info.lua new file mode 100644 index 00000000..f66f6af1 --- /dev/null +++ b/tests/modkit/cases/device_power_info.lua @@ -0,0 +1,52 @@ +-- A sandboxed mod may read battery state without receiving love.system and +-- its process-launching surface. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +local FIXTURE = { + ["mods/power_probe/manifest.json"] = [[{ + "id": "power_probe", + "name": "Power Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/power_probe/main.lua"] = [[ + local mod = ... + mod.exports.state, mod.exports.percent = mod.device:powerInfo() + ]], +} + +local saved = T.love.system.getPowerInfo +local calls = 0 +T.love.system.getPowerInfo = function() + calls = calls + 1 + return "charging", 42, 900 +end + +local vanilla = T.sdk.loadNone({}) +T.eq(calls, 0, "no mod leaves the device power backend cold") +vanilla.release() + +local run = T.sdk.loadMods({ "mods/power_probe" }, + { fs = T.sdk.memfs(FIXTURE) }) +T.eq(#run.errors, 0, + "the sandboxed power probe loads clean (" .. tostring(run.errors[1]) .. ")") +local out = run.loader.exports.power_probe or {} +T.eq(out.state, "charging", "the public facade reports battery state") +T.eq(out.percent, 42, "the public facade reports battery percentage") +T.eq(calls, 1, "one facade read makes one platform call") +run.release() + +T.love.system.getPowerInfo = nil +local unavailable = T.sdk.loadMods({ "mods/power_probe" }, + { fs = T.sdk.memfs(FIXTURE) }) +out = unavailable.loader.exports.power_probe or {} +T.eq(out.state, "unknown", "a missing platform backend has a stable state") +T.eq(out.percent, nil, "a missing platform backend has no invented percentage") +unavailable.release() + +T.love.system.getPowerInfo = saved +T.finish("device_power_info") diff --git a/tests/modkit/cases/sandbox.lua b/tests/modkit/cases/sandbox.lua index dff6767a..d9f629ea 100644 --- a/tests/modkit/cases/sandbox.lua +++ b/tests/modkit/cases/sandbox.lua @@ -125,7 +125,8 @@ T.eq(type(out.requireSemver), "table", T.check(out.loveFilesystem and out.loveFilesystem:find("mod.storage", 1, true), "love.filesystem is refused and names the replacement") T.check(out.loveThread ~= false, "love.thread is refused: it opens a full Lua state") -T.check(out.loveSystem ~= false, "love.system is refused: openURL launches anything") +T.check(out.loveSystem and out.loveSystem:find("mod.device:powerInfo()", 1, true), + "love.system is refused and names the scoped power replacement") T.eq(out.loveGraphics, "table", "the rest of love passes through") T.check(out.loveAssign ~= false, "a mod cannot assign into the love facade") From bde606f966ca5f58e351889a38d6427b50cc6f79 Mon Sep 17 00:00:00 2001 From: Myles Resnick Date: Thu, 13 Aug 2026 11:19:06 -0400 Subject: [PATCH 04/13] Permission-gated step bridge for sandboxed mods The sandbox blocks love.system and love.filesystem, which orphans the native step bridge (#452, #489): its one consumer can no longer call syncHealthSteps or read steps_pending.json (#1186). Adds a "steps" manifest permission (shown to the player like the others) gating a mod.steps facade: available() probes the bridge quietly, sync() forwards the async refresh, poll() hands the mod its copy of a delivery. The engine owns the pending file -- mods never name a path and receive only { steps, from, to }. Without the permission the acting calls name it, following the network gate. No new events, hooks or registries; nothing removed. RFC 0009. Tests: tests/modkit/cases/steps_bridge.lua (no-mod cold bridge, permissioned sync/poll, per-mod copies, contract-field filtering, malformed-delivery drop, unpermissioned refusal, bridgeless build). Co-Authored-By: Claude Fable 5 --- CONTRIBUTING-mods.md | 2 +- docs/modding.md | 24 ++++ docs/rfcs/0009-step-bridge-permission.md | 68 +++++++++++ src/mods/Loader.lua | 28 ++++- src/mods/Manifest.lua | 3 +- src/mods/Sandbox.lua | 3 +- src/mods/Steps.lua | 84 ++++++++++++++ tests/modkit/cases/steps_bridge.lua | 141 +++++++++++++++++++++++ 8 files changed, 349 insertions(+), 4 deletions(-) create mode 100644 docs/rfcs/0009-step-bridge-permission.md create mode 100644 src/mods/Steps.lua create mode 100644 tests/modkit/cases/steps_bridge.lua diff --git a/CONTRIBUTING-mods.md b/CONTRIBUTING-mods.md index 7f1dc558..ccdcf5ad 100644 --- a/CONTRIBUTING-mods.md +++ b/CONTRIBUTING-mods.md @@ -256,7 +256,7 @@ engine's globals. Every chunk you author gets it: `main.lua`, your | `require("ffi")`, `require("love.*")` | the `love` table you are given | | `love.filesystem` | `mod.storage` (per-mod, per-playthrough) and `mod:read` | | `love.thread`, `love.event` | `mod.events`, `mod.hooks` | -| `love.system` | `mod.device:powerInfo()` for battery information | +| `love.system` | `mod.device:powerInfo()` for battery information; `mod.steps` (with the `steps` permission) for the step bridge | The rest of `love` passes through unchanged, so graphics, audio, timers and input work as they always have. diff --git a/docs/modding.md b/docs/modding.md index 629ced50..69519e2b 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -528,3 +528,27 @@ local state, percent = mod.device:powerInfo() `"charging"`, or `"charged"`. `percent` is `0` through `100`, or `nil` when the platform cannot report it. The facade is read-only and does not expose URL launching, clipboard access, or other system operations. + +## Real-world steps + +On iOS and Android the game counts the player's real-world steps natively +(HealthKit / the hardware step counter). A mod reaches that bridge through +the `steps` permission in `manifest.json`, which the player sees in the +mod manager like every other permission: + +```lua +if mod.steps:available() then + mod.steps:sync() -- async; OS consent sheet on first use +end +-- later, at a quiet moment: +local walk = mod.steps:poll() -- { steps = n, from = ?, to = ? } or nil +``` + +`available()` is `false` on builds without the bridge (desktop) and for +mods without the permission, so a probe is always safe. `sync()` asks the +platform to refresh its count and returns whether there was a bridge to +ask. `poll()` returns the next delivery for this mod — the engine consumes +the native side's pending file itself, each permissioned mod receives its +own copy of a delivery, and steps are anchored natively so the same walk +is never delivered twice. Without the permission, `sync` and `poll` raise +an error naming it. diff --git a/docs/rfcs/0009-step-bridge-permission.md b/docs/rfcs/0009-step-bridge-permission.md new file mode 100644 index 00000000..b0462c31 --- /dev/null +++ b/docs/rfcs/0009-step-bridge-permission.md @@ -0,0 +1,68 @@ +# RFC 0009 — Permission-gated step bridge for sandboxed mods + +## Status + +Proposed. Engine: `Steps.lua` (new), `Loader.lua`, `Manifest.lua`, +`Sandbox.lua`. Test: `tests/modkit/cases/steps_bridge.lua`. Issue: #1186. + +## Motivation + +The iOS and Android builds count the player's real-world steps natively +(#452, #489), exposed to Lua as `love.system.syncHealthSteps()` and +delivered as `steps_pending.json` in the save-directory root. The sandbox +correctly blocks both — `love.system` also launches URLs, and the file API +names paths — but that leaves the bridge with no consumer: the mod that +step counting was built for (Pokéwalker, steps→EXP) can no longer be +written. + +## The decision it extends + +Extends the mod sandbox in `src/mods/Sandbox.lua` (blocked host modules +stay blocked; legitimate operations receive narrow engine-owned facades) +and the permission model `network` established: a `manifest.json` +permission the player sees in the mod manager that genuinely gates a +capability. + +## The exact API delta + +A new manifest permission token, `steps`, and a `mod.steps` facade: + +- `mod.steps:available() -> boolean` — whether this build carries the + native bridge. Answers `false` without the permission, so a probe stays + quiet. +- `mod.steps:sync() -> boolean` — asks the platform to refresh its count + (async; the OS consent sheet still appears on first use, exactly as + before the sandbox). `false` when there is no bridge. +- `mod.steps:poll() -> { steps = n, from = iso?, to = iso? } | nil` — the + next delivery for this mod, engine-consumed from the pending file. Each + permissioned mod receives its own copy of a delivery. + +Without the permission, `sync` and `poll` raise an error naming the +missing permission, the way the network gate does. The engine owns the +pending file: mods never learn its name or location, and only the three +contract fields travel. No new event, hook, or registry names. + +## Migration note for existing mods + +Mods that called `love.system.syncHealthSteps()` and read +`steps_pending.json` themselves add `"steps"` to `permissions` and switch +to `mod.steps:sync()` / `mod.steps:poll()`. No other mod changes. + +## Parity tests + +- **No mod:** with nothing installed the bridge is never called and a + pending file on disk is left untouched. +- **Mod API:** a fixture mod with the permission syncs and receives a + delivery through the public loader; two permissioned mods both receive + the same walk; a second poll returns nil. +- **No permission:** `available()` is false and the acting calls name the + missing permission; the sandbox suite keeps proving direct + `love.system` access is refused. +- **Malformed delivery:** a bad or empty pending file is dropped whole + rather than crashing a poll (the native anchor only advances on a + successful sync, so nothing is lost). + +## Deprecation etiquette + +Nothing deprecated. The facade is additive; the sandbox's `love.system` +and `love.filesystem` blocks remain in force. diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 15a7e0a6..7253d5b2 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -20,6 +20,7 @@ local Events = require("src.mods.Events") local Gen2Compat = require("src.mods.Gen2Compat") local Hooks = require("src.mods.Hooks") local Runtime = require("src.mods.Runtime") +local Steps = require("src.mods.Steps") local Loader = {} Loader.__index = Loader @@ -251,7 +252,7 @@ function Loader.new(opts) events = Events.new(), hooks = Hooks.new(), content = {}, assets = {}, exports = {}, migrations = {}, order = {}, modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {}, - modInput = {}, modEnv = {}, + modInput = {}, modEnv = {}, stepsQueues = {}, fs = (opts and opts.fs) or (love and love.filesystem), dev = dev, -- Which generation this boot is (1 or 2). Fixed at construction: the @@ -1009,6 +1010,30 @@ function Loader:_api(mod) return state, percent end, }, + -- The native step bridge (#1186), behind the "steps" permission the + -- player sees in the mod manager: sync asks the platform to refresh + -- its count, poll hands this mod its copy of what the bridge + -- delivered. The engine owns the pending file -- a mod never names a + -- path, it only receives { steps, from, to }. available() answers + -- false without the permission (a probe stays quiet); the calls that + -- would do something name the missing permission instead, the way the + -- network gate does. + steps = (function() + if mod.manifest.permissionSet.steps then + loader.stepsQueues[modId] = loader.stepsQueues[modId] or {} + return { + available = function() return Steps.available() end, + sync = function() return Steps.sync() end, + poll = function() return Steps.poll(loader, modId) end, + } + end + local function refuse() + error(('[%s] mod.steps needs the "steps" permission in ' + .. "manifest.json"):format(modId), 2) + end + return { available = function() return false end, + sync = refuse, poll = refuse } + end)(), -- namespaced per mod; M11 backs these with save.modData / -- options.modOptions, the shape mods compile against is already final save = { @@ -1226,6 +1251,7 @@ function Loader:_rollback(modId) self.optionSchemas[modId] = nil self.migrations[modId] = nil self.modSave[modId] = nil + self.stepsQueues[modId] = nil end -- a mod that explicitly swears it stays link-compatible while writing into a diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index 373797a8..58c7fa26 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -10,7 +10,8 @@ local Version = require("src.core.Version") local Manifest = {} Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true } -Manifest.PERMISSIONS = { network = true, filesystem = true, engine_internals = true } +Manifest.PERMISSIONS = { network = true, filesystem = true, + engine_internals = true, steps = true } -- link-relevant registries; a mod that writes into one of these while -- declaring affects_link = false gets an attributed warning from the loader diff --git a/src/mods/Sandbox.lua b/src/mods/Sandbox.lua index eb261888..195ad3d2 100644 --- a/src/mods/Sandbox.lua +++ b/src/mods/Sandbox.lua @@ -69,7 +69,8 @@ end -- value is the replacement to name in the error, or true when there is none local BLOCKED_LOVE = { filesystem = "mod.storage and mod:read", thread = true, - system = "mod.device:powerInfo() for battery information", event = true, + system = "mod.device:powerInfo() for battery information, mod.steps for " + .. "the step bridge", event = true, } local loveProxy diff --git a/src/mods/Steps.lua b/src/mods/Steps.lua new file mode 100644 index 00000000..4261d89a --- /dev/null +++ b/src/mods/Steps.lua @@ -0,0 +1,84 @@ +-- The scoped seam for the native step bridge (#1186). +-- +-- The iOS/Android builds count the player's real-world steps natively +-- (#452, #489) and deliver them by writing steps_pending.json into the +-- save-directory root. Before the sandbox, the Pokéwalker mod called +-- love.system.syncHealthSteps() and consumed that file itself; the sandbox +-- blocks both, which is correct -- love.system launches URLs and the file +-- API names paths -- but it left the bridge with no consumer at all. +-- +-- This module is the narrow replacement, gated by the "steps" permission +-- in manifest.json (the network model: a permission the player sees that +-- genuinely gates a capability). The engine owns the file: mods never +-- learn its name or location, they receive only the three contract fields +-- ({ steps, from, to }), each permissioned mod gets its own copy, and the +-- merge-don't-overwrite anchor semantics stay on the native side where +-- they always lived. +-- +-- No frame pump: the file is looked for lazily when a mod polls, so a +-- build with no permissioned mod installed never touches the bridge or +-- the disk. + +local Json = require("src.link.Json") + +local Steps = {} + +-- The native contract's drop point, in the save-directory root (see +-- mobile/ios and mobile/android step bridges). +Steps.PENDING = "steps_pending.json" + +local function bridge() + return _G.love and _G.love.system and _G.love.system.syncHealthSteps +end + +-- Whether this build carries the native bridge. Desktop builds do not; +-- a mod uses this to stay dormant without probing love.system. +function Steps.available() + return bridge() ~= nil +end + +-- Ask the native side to refresh its count. Async: the result lands in +-- the pending file and comes back through a later poll. The platform's +-- own consent sheet (HealthKit / ACTIVITY_RECOGNITION) still appears on +-- first use, exactly as it did pre-sandbox. false when there is no +-- bridge to ask. +function Steps.sync() + local fn = bridge() + if not fn then return false end + fn() + return true +end + +-- Consume the pending file, if one has appeared, and fan its payload out +-- to every permissioned mod's queue. Only the contract fields travel; +-- anything else in the file stays in the file's grave. A malformed or +-- empty delivery is dropped whole -- the native anchor only advances on a +-- successful sync, so nothing is lost to a bad write. +function Steps.pump(loader) + local fs = _G.love and _G.love.filesystem + if not (fs and fs.getInfo(Steps.PENDING, "file")) then return end + local raw = fs.read(Steps.PENDING) + fs.remove(Steps.PENDING) + if not raw then return end + local ok, decoded = pcall(Json.decode, raw) + if not ok or type(decoded) ~= "table" then return end + local steps = tonumber(decoded.steps) + if not steps or steps <= 0 then return end + local payload = { steps = steps, from = decoded.from, to = decoded.to } + for _, queue in pairs(loader.stepsQueues) do + queue[#queue + 1] = { steps = payload.steps, from = payload.from, + to = payload.to } + end +end + +-- The next delivery for this mod, or nil. Each permissioned mod consumes +-- its own queue, so two mods both see the same walk (pre-sandbox, whoever +-- read the file first won). +function Steps.poll(loader, modId) + Steps.pump(loader) + local queue = loader.stepsQueues[modId] + if not queue then return nil end + return table.remove(queue, 1) +end + +return Steps diff --git a/tests/modkit/cases/steps_bridge.lua b/tests/modkit/cases/steps_bridge.lua new file mode 100644 index 00000000..37199c64 --- /dev/null +++ b/tests/modkit/cases/steps_bridge.lua @@ -0,0 +1,141 @@ +-- The "steps" permission gates the native step bridge (#1186): a +-- permissioned mod syncs and polls deliveries without ever seeing +-- love.system or the pending file; an unpermissioned mod gets a quiet +-- available() = false and loud, permission-naming refusals from the +-- calls that would act. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Steps = require("src.mods.Steps") + +local WALKER = { + ["mods/step_walker/manifest.json"] = [[{ + "id": "step_walker", + "name": "Step Walker", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "permissions": ["steps"] + }]], + ["mods/step_walker/main.lua"] = [[ + local mod = ... + mod.exports.available = mod.steps:available() + mod.exports.synced = mod.steps:sync() + mod.exports.poll = function() return mod.steps:poll() end + ]], +} + +local SECOND = { + ["mods/step_second/manifest.json"] = [[{ + "id": "step_second", + "name": "Step Second", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "permissions": ["steps"] + }]], + ["mods/step_second/main.lua"] = [[ + local mod = ... + mod.exports.poll = function() return mod.steps:poll() end + ]], +} + +local UNPERMISSIONED = { + ["mods/step_probe/manifest.json"] = [[{ + "id": "step_probe", + "name": "Step Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/step_probe/main.lua"] = [[ + local mod = ... + mod.exports.available = mod.steps:available() + local ok, err = pcall(function() return mod.steps:sync() end) + mod.exports.refused = not ok and tostring(err) or false + ]], +} + +local function merged(...) + local out = {} + for _, fixture in ipairs({ ... }) do + for path, body in pairs(fixture) do out[path] = body end + end + return out +end + +local savedSync = T.love.system.syncHealthSteps +local syncCalls = 0 +T.love.system.syncHealthSteps = function() + syncCalls = syncCalls + 1 + return true +end + +-- no mod: the bridge stays cold and a pending delivery stays on disk +T.love.filesystem.write(Steps.PENDING, '{"steps": 4312}') +local vanilla = T.sdk.loadNone({}) +T.eq(syncCalls, 0, "no mod leaves the step bridge cold") +T.check(T.love.filesystem.read(Steps.PENDING) ~= nil, + "no mod leaves the pending delivery untouched") +vanilla.release() + +-- a permissioned mod syncs and receives the delivery +local run = T.sdk.loadMods({ "mods/step_walker", "mods/step_second" }, + { fs = T.sdk.memfs(merged(WALKER, SECOND)) }) +T.eq(#run.errors, 0, + "the permissioned mods load clean (" .. tostring(run.errors[1]) .. ")") +local walker = run.loader.exports.step_walker +T.eq(walker.available, true, "available() sees the bridge") +T.eq(walker.synced, true, "sync() reaches the bridge") +T.eq(syncCalls, 1, "one sync call makes one bridge call") + +local delivery = walker.poll() +T.check(delivery and delivery.steps == 4312, + "poll() hands the mod the delivered step count") +T.check(T.love.filesystem.read(Steps.PENDING) == nil, + "the engine consumed the pending file, not the mod") +T.eq(walker.poll(), nil, "a delivery is handed out once per mod") + +local second = run.loader.exports.step_second +local secondDelivery = second.poll() +T.check(secondDelivery and secondDelivery.steps == 4312, + "a second permissioned mod receives its own copy of the walk") +T.check(secondDelivery ~= delivery, "copies, not a shared table") + +-- contract fields only, and a malformed delivery is dropped whole +T.love.filesystem.write(Steps.PENDING, + '{"steps": 12, "from": "a", "to": "b", "path": "/etc/passwd"}') +delivery = walker.poll() +T.eq(delivery.steps, 12, "the steps field travels") +T.eq(delivery.from, "a", "the from field travels") +T.eq(delivery.path, nil, "fields outside the contract do not travel") +T.love.filesystem.write(Steps.PENDING, "not json at all") +T.eq(walker.poll(), nil, "a malformed delivery is dropped, not raised") +T.check(T.love.filesystem.read(Steps.PENDING) == nil, + "and the bad file does not wedge the pump") +run.release() + +-- without the permission: quiet probe, loud act +local probe = T.sdk.loadMods({ "mods/step_probe" }, + { fs = T.sdk.memfs(UNPERMISSIONED) }) +T.eq(#probe.errors, 0, + "the unpermissioned mod loads clean (" .. tostring(probe.errors[1]) .. ")") +local out = probe.loader.exports.step_probe +T.eq(out.available, false, "available() is quietly false without the permission") +T.check(out.refused and out.refused:find('"steps" permission', 1, true), + "sync() without the permission names it") +probe.release() + +-- no bridge on this build: sync reports it, nothing raises +T.love.system.syncHealthSteps = nil +local ashore = T.sdk.loadMods({ "mods/step_walker" }, + { fs = T.sdk.memfs(WALKER) }) +local dry = ashore.loader.exports.step_walker +T.eq(dry.available, false, "available() is false without a native bridge") +T.eq(dry.synced, false, "sync() reports there was no bridge to ask") +ashore.release() + +T.love.system.syncHealthSteps = savedSync +T.love.filesystem.remove(Steps.PENDING) +T.finish("steps_bridge") From 7b8cb127a8182f8cde95e2e428fba538a33907eb Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:41:26 +0200 Subject: [PATCH 05/13] feat(gen2): share battle UI visibility hooks --- docs/mod-api-gen2-compat.md | 5 +++-- docs/modding.md | 1 + src/ui/gen2/BattleState.lua | 23 ++++++++++++++++++++-- tests/engine/gate_gen2_mod_api.lua | 1 + tests/mod_qol_hooks_tests.lua | 31 ++++++++++++++++++++++++++++++ 5 files changed, 57 insertions(+), 4 deletions(-) diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index fbb61d6e..754901a1 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -533,8 +533,9 @@ gains a field instead of the name gaining a prefix. `pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`, `battle.crit`, `battle.accuracy`, `battle.turn_order`, `battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`, - `catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm` - and `battle.catch_exp`. One payload difference: Gen 1's vanilla + `catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`, + `battle.catch_exp`, `battle.bottom_ui_visible` and + `battle.status_hud_visible`. One payload difference: Gen 1's vanilla `battle.low_health_alarm` link reads `ctx.battle.data`, and Gold's battle screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through diff --git a/docs/modding.md b/docs/modding.md index e3a45fa2..3b799c00 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -455,6 +455,7 @@ identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`, `battle.bottom_ui_visible` and `battle.status_hud_visible` independently control the battle text/menu layer and the HP/status panels. Both receive `(next, state)` and default to `true`, so vanilla rendering is unchanged. +Both hooks apply to Gen 1 and Gen 2 battles. Text boxes and YES/NO prompts pushed above a battle inherit a `false` result for that battle, so hiding the bottom layer cannot leave their white backing behind under another overlay. Text boxes also pass through the hook as their diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index d3dfbd76..5c78e670 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -173,6 +173,18 @@ end function BattleState:wantsFillScale() return true end function BattleState:drawsWidescreen() return true end +function BattleState:bottomUIVisible() + if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end + return Runtime.call("battle.bottom_ui_visible", function() return true end, + self) ~= false +end + +function BattleState:statusHUDVisible() + if not Runtime.wantsHook("battle.status_hud_visible") then return true end + return Runtime.call("battle.status_hud_visible", function() return true end, + self) ~= false +end + -- opts: battle (a Battle), onDone(outcome), save function BattleState.new(game, opts) opts = opts or {} @@ -3079,6 +3091,7 @@ end function BattleState:drawHud() local wasBattle = Font.useBattleExtra(true) local enemy, player = self:activeMon("enemy"), self:activeMon("player") + local showStatus = self:statusHUDVisible() -- Enemy HUD (DrawEnemyHUD clears (1,0) 4 rows x 11 cols): -- name at (1,0); PrintLevel at (6,1) with the gender symbol at (9,1); @@ -3087,7 +3100,7 @@ function BattleState:drawHud() -- runs, so a shake or a slide does not drag the HP bar with it. -- And nothing at all before UpdateEnemyHUD has ever run: the intro bands -- slide in over a blanked tilemap (core.asm:8554/8564). - if self.showEnemyHud and not self:hudCleared("enemy") then + if showStatus and self.showEnemyHud and not self:hudCleared("enemy") then Chrome.print(self:name(enemy), 1, 0) -- PrintLevel writes at the coordinate it is given and then LEFT-aligns -- the digits after it, so the glyph is pinned to column 6 whether the level @@ -3127,7 +3140,8 @@ function BattleState:drawHud() -- BattleMenu's own tutorial arm skips UpdateBattleHuds as well. The DUDE's -- half of the screen is his back-pic and nothing more. -- Nor before SendOutPlayerMon's own UpdatePlayerHUD (core.asm:3838). - if not player or not self.showPlayerHud or self:hudCleared("player") then + if not showStatus or not player or not self.showPlayerHud + or self:hudCleared("player") then Font.useBattleExtra(wasBattle) return end @@ -3226,6 +3240,11 @@ function BattleState:drawPanel() end self:drawHud() + if not self:bottomUIVisible() then + love.graphics.setColor(1, 1, 1, 1) + return + end + -- Message box across the bottom, with the menu window over its right half -- -- the cart draws the prompt into the full-width box and then opens the menu -- on top, so the tail of a long name is simply covered. diff --git a/tests/engine/gate_gen2_mod_api.lua b/tests/engine/gate_gen2_mod_api.lua index 0cbcf0d7..cc5c2ca4 100644 --- a/tests/engine/gate_gen2_mod_api.lua +++ b/tests/engine/gate_gen2_mod_api.lua @@ -420,6 +420,7 @@ local GEN2_HOOKS = { -- that a Gen 1 mod reaching through ctx.battle.data instead of calling -- nextFn gets nil there). "battle.catch_exp", "battle.low_health_alarm", "battle.overlay", + "battle.bottom_ui_visible", "battle.status_hud_visible", -- One pic path resolver for both games: the Gen 1 site is the SHARED -- src/pokemon/Sprites.lua and Gold's own battle screen calls the same hook -- with the Gen 1 ctx keys plus `letter` and `shiny`, which Red has no diff --git a/tests/mod_qol_hooks_tests.lua b/tests/mod_qol_hooks_tests.lua index d2d8efb0..d4b82643 100644 --- a/tests/mod_qol_hooks_tests.lua +++ b/tests/mod_qol_hooks_tests.lua @@ -168,8 +168,11 @@ end do local BattleState = require("src.battle.BattleState") + local Gen2BattleState = require("src.ui.gen2.BattleState") check(BattleState.bottomUIVisible({ phase = "menu" }), "battle bottom UI is visible without a mod") + check(Gen2BattleState.bottomUIVisible({ phase = "menu" }), + "Gold battle bottom UI is visible without a mod") local seen local unsub = wrap("battle.bottom_ui_visible", function(_, state) seen = state @@ -177,6 +180,8 @@ do end) check(not BattleState.bottomUIVisible({ phase = "messages" }), "a mod can hide the battle text and menu layer") + check(not Gen2BattleState.bottomUIVisible({ phase = "menu" }), + "the same hook hides Gold's battle text and menu layer") local text = setmetatable({}, TextBox) text:draw() check(seen == text, "pushed text boxes use the same visibility hook") @@ -200,15 +205,41 @@ do check(BattleState.bottomUIVisible({ phase = "moveSelect" }), "battle bottom UI returns when the hook is removed") + check(Gen2BattleState.bottomUIVisible({ phase = "moves" }), + "Gold battle bottom UI returns when the hook is removed") + + local Chrome = require("src.ui.gen2.Chrome") + local clear, box, print = Chrome.clear, Chrome.box, Chrome.print + local boxes = 0 + Chrome.clear = function() end + Chrome.box = function() boxes = boxes + 1 end + Chrome.print = function() end + local panel = setmetatable({ + battle = { player = {}, enemy = {} }, phase = "resolving", + drawHud = function() end, + }, { __index = Gen2BattleState }) + unsub = wrap("battle.bottom_ui_visible", function() return false end) + panel:drawPanel() + check(boxes == 0, "Gold skips its in-state battle box when a mod owns it") + unsub() + panel:drawPanel() + check(boxes == 1, "Gold draws its battle box again without the hook") + Chrome.clear, Chrome.box, Chrome.print = clear, box, print check(BattleState.statusHUDVisible({}), "battle status HUD is visible without a mod") + check(Gen2BattleState.statusHUDVisible({}), + "Gold battle status HUD is visible without a mod") unsub = wrap("battle.status_hud_visible", function() return false end) check(not BattleState.statusHUDVisible({}), "a mod can hide the battle status HUD") + check(not Gen2BattleState.statusHUDVisible({}), + "the same hook hides Gold's battle status HUD") unsub() check(BattleState.statusHUDVisible({}), "battle status HUD returns when the hook is removed") + check(Gen2BattleState.statusHUDVisible({}), + "Gold battle status HUD returns when the hook is removed") end -- ------- battle.caught_marker_visible (caught wild marker) From 09204daa2807a3d84c009c263de4d314a9e94b50 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:04:46 +0200 Subject: [PATCH 06/13] feat(mods): expose opt-in final-frame output hooks --- docs/mod-api-gen2-compat.md | 6 ++- docs/modding.md | 14 +++++++ src/core/Game2.lua | 39 +++++++++++++------- src/render/Renderer.lua | 38 ++++++++++++------- tests/engine/gate_gen2_mod_api.lua | 3 +- tests/engine/gen2_render_output_seam.lua | 47 ++++++++++++++++++++++++ tests/mod_graphics_tests.lua | 27 ++++++++++++++ 7 files changed, 145 insertions(+), 29 deletions(-) create mode 100644 tests/engine/gen2_render_output_seam.lua diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index fbb61d6e..6e8b0293 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -546,11 +546,13 @@ gains a field instead of the name gaining a prefix. each row's decision in `evolution.check`. The hook passes `data` where Gen 1 passes `game`; positions 2-4 (mon, row, trigger) match. - *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`, - `render.zones`, `render.compose`, `render.letterbox`, `render.hud`. Each sits + `render.zones`, `render.compose`, `render.output_enabled`, `render.output`, + `render.letterbox`, `render.hud`. Each sits at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it -- the logic tick before the pad is read, a pointer the touch overlay gets first refusal on, the palette zone list handed to the present pass, the - letterbox, and the finished playfield rect -- and carries the same payload. + composed frame before GBCFX, the letterbox, and the finished playfield rect + -- and carries the same payload. `render.hud`'s `gameX` / `gameY` really is where Gold's dialogue boxes and menus land, because `Chrome.fitScale` / `fitOrigin` and `World:fitScale` compute the same number. `render.zones` is handed `nil` in GBC mode (Gold diff --git a/docs/modding.md b/docs/modding.md index e3a45fa2..f03517b0 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -440,6 +440,20 @@ oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`. This is what lets a mod lay the two passes out as two stacked Game Boy screens, or push one onto a second screen, without the engine knowing the layout. +`render.output_enabled` and `render.output` are the later, whole-window seam +for mods that need the engine's normal composite rather than its separate +layers. It runs after registered present pipelines and before GBCFX, +`render.hud`, and touch controls. A mod wraps both hooks: the first returns +`true` only while output ownership is needed, and the second receives +`(next, ctx)` with `canvas`, `width`, `height`, `gameX`, `gameY`, `gameWidth`, +`gameHeight`, `scale`, `dpiX`, +`dpiY`, and `generation`. Returning `true` from `render.output` takes over the +window; calling `next(ctx)` keeps the normal presentation. Both hooks default +to `false`. Enabling the seam requires a full-window canvas for that frame. +With no `render.output` subscriber, or while `render.output_enabled` is false, +the existing presentation path is unchanged. `render.compose` takes precedence +when it owns the frame. + `screen.render_visible` receives `(next, state)` while the main screen is being composed. Return `false` to omit that state from drawing, opacity selection and palette-zone ownership. The state remains on the stack and keeps its normal diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 8e02f801..7532437d 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -73,7 +73,7 @@ end -- -- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad -- (the FixedStep callback in Game2:load), so none of it goes through --- src/render/Renderer.lua or src/core/Game.lua. That explains why the six +-- src/render/Renderer.lua or src/core/Game.lua. That explains why the eight -- hooks below never used to fire here; it is not a reason they should not. A -- hook is a contract about a MOMENT in the frame, and Gold has every one of -- these moments -- so each is raised under the Gen 1 NAME with the Gen 1 @@ -83,6 +83,7 @@ end -- input.pointer uncaptured pointer events (src/core/Game.lua:887) -- render.zones the palette pass, pre-blit (src/core/Game.lua:505) -- render.compose the whole-window composite (Renderer.lua:759) +-- render.output* the normal composed frame (Renderer.lua:1063) -- render.letterbox the void around the 160x144 blit (Renderer.lua:840) -- render.hud screen-space UI over the frame (src/core/Game.lua:521) -- @@ -1382,12 +1383,14 @@ function Game2:draw() local zoned = type(zones) == "table" and zones[1] ~= nil -- A present canvas is paid for only when something reads it: the zone pass, - -- GBC FX, a mod post-process, or a render.compose subscriber about to be - -- handed the finished frame. With none of them the frame draws straight to - -- the screen exactly as it always did. + -- GBC FX, a mod post-process, render.compose, or an enabled render.output + -- subscriber. With none of them the frame draws straight to the screen + -- exactly as it always did. local composing = ModRuntime.wantsHook("render.compose") + local hasOutputHook = ModRuntime.wantsHook("render.output") + and ModRuntime.call("render.output_enabled", function() return false end) == true local scene = nil - if zoned or fx or composing or Pipelines.wantsPresent() then + if zoned or fx or composing or Pipelines.wantsPresent() or hasOutputHook then scene = self:presentCanvas(1, w, h) end if not scene then @@ -1419,7 +1422,7 @@ function Game2:draw() -- untinted one. On its own the tint rides the final blit and no second -- canvas is paid for. local source = scene - local reread = fx or Pipelines.wantsPresent() + local reread = fx or Pipelines.wantsPresent() or hasOutputHook if zoned and reread then local tinted = self:presentCanvas(2, w, h) if tinted then @@ -1439,15 +1442,25 @@ function Game2:draw() -- Post-process pipelines run over the finished composite and before GBC -- FX. Each hands back a canvas; with none registered this returns `source` -- unchanged and the frame is byte-identical (Renderer.lua:1058). - local scale, _, _, dpi = self:frameFit(w, h) + local scale, ox, oy, dpi = self:frameFit(w, h) source = Pipelines.present(source, { width = w, height = h, scale = scale, dpi = dpi, dpiX = dpi, dpiY = dpi }) or source - if fx then - GBCFX.present(source, self:pixelScale(w, h)) - else - G.setColor(1, 1, 1, 1) - G.draw(source, 0, 0) - G.setShader() + local outputHandled = hasOutputHook + and ModRuntime.call("render.output", function() return false end, { + canvas = source, width = w, height = h, + gameX = ox, gameY = oy, + gameWidth = 160 * scale, gameHeight = 144 * scale, + scale = scale, dpiX = dpi, dpiY = dpi, + generation = 2, + }) == true + if not outputHandled then + if fx then + GBCFX.present(source, self:pixelScale(w, h)) + else + G.setColor(1, 1, 1, 1) + G.draw(source, 0, 0) + G.setShader() + end end end G.pop() diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 3b69e2d0..54b5518c 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -768,11 +768,12 @@ function Renderer:endFrame(zones, worldZones) end end - -- A post-process pipeline needs the whole composite in a canvas for the - -- same reason GBC FX does, so either one alone is enough to take the - -- present path; with neither, the frame draws straight to the screen - -- exactly as it always did. - local needPresent = GBCFX.active() or Pipelines.wantsPresent() + -- Post-process pipelines, GBC FX and an enabled final-output owner need the + -- whole composite in a canvas. With none of them, the frame draws straight + -- to the screen exactly as it always did. + local hasOutputHook = Runtime.wantsHook("render.output") + and Runtime.call("render.output_enabled", function() return false end) == true + local needPresent = GBCFX.active() or Pipelines.wantsPresent() or hasOutputHook local present = nil if needPresent then if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww @@ -1057,14 +1058,25 @@ function Renderer:endFrame(zones, worldZones) -- this returns `present` unchanged and the frame is byte-identical. local composed = Pipelines.present(present, { width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present - if GBCFX.active() then - -- shader grid/shadow math is in framebuffer pixels - GBCFX.present(composed, Sp) - else - -- the present canvas only existed for the post-process, so put the - -- result on the screen at the same 1:1 unit mapping it was built at - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(composed, 0, 0) + local outputHandled = hasOutputHook + and Runtime.call("render.output", function() return false end, { + canvas = composed, + width = ww, height = wh, + gameX = ox, gameY = oy, + gameWidth = vpw, gameHeight = vph, + scale = Sp, dpiX = dpiX, dpiY = dpiY, + generation = 1, + }) == true + if not outputHandled then + if GBCFX.active() then + -- shader grid/shadow math is in framebuffer pixels + GBCFX.present(composed, Sp) + else + -- the present canvas only existed for the post-process, so put the + -- result on the screen at the same 1:1 unit mapping it was built at + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(composed, 0, 0) + end end end self.worldActive = false diff --git a/tests/engine/gate_gen2_mod_api.lua b/tests/engine/gate_gen2_mod_api.lua index 0cbcf0d7..8079b749 100644 --- a/tests/engine/gate_gen2_mod_api.lua +++ b/tests/engine/gate_gen2_mod_api.lua @@ -434,7 +434,8 @@ local GEN2_HOOKS = { -- pointer with the touch overlay given first refusal, the palette zone list -- handed to the present pass, the letterbox and the HUD rect. "input.step", "input.pointer", - "render.zones", "render.compose", "render.letterbox", "render.hud", + "render.zones", "render.compose", "render.output_enabled", "render.output", + "render.letterbox", "render.hud", } local function assertShared(name, sites, kind) diff --git a/tests/engine/gen2_render_output_seam.lua b/tests/engine/gen2_render_output_seam.lua new file mode 100644 index 00000000..3387e17e --- /dev/null +++ b/tests/engine/gen2_render_output_seam.lua @@ -0,0 +1,47 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Game2 = require("src.core.Game2") +local Runtime = require("src.mods.Runtime") +local Hooks = require("src.mods.Hooks") + +local savedHooks = Runtime.hooks +local hooks = Hooks.new() +Runtime.hooks = hooks + +local received +hooks:wrap("render.output", function(_, context) + received = context + return true +end, 0, "test") +hooks:wrap("render.output_enabled", function() return false end, 0, "test") + +local canvas = love.graphics.newCanvas(640, 480) +local presentCalls = 0 +local game = setmetatable({ + frameFit = function() return 3, 80, 24, 1, 480, 432 end, + presentCanvas = function() presentCalls = presentCalls + 1 return canvas end, + drawScene = function() end, + drawHud = function() end, +}, Game2) + +Game2.draw(game) +T.eq(received, nil, "a disabled Gold output hook does not run") +T.eq(presentCalls, 0, "a disabled Gold output hook keeps the direct path") + +hooks:wrap("render.output_enabled", function() return true end, 10, "test") +Game2.draw(game) +T.check(received ~= nil, "Gold raises render.output for an enabled subscriber") +T.eq(received and received.canvas, canvas, + "Gold hands render.output the finished present canvas") +T.eq(received and received.generation, 2, + "Gold identifies the output context without changing Gen 1") +T.eq(received and received.gameX, 80, "Gold output carries the fitted game X") +T.eq(received and received.gameY, 24, "Gold output carries the fitted game Y") +T.eq(received and received.gameWidth, 480, + "Gold output carries the fitted game width") +T.eq(received and received.gameHeight, 432, + "Gold output carries the fitted game height") + +Runtime.hooks = savedHooks +T.finish("gen2 render output seam") diff --git a/tests/mod_graphics_tests.lua b/tests/mod_graphics_tests.lua index 8e310e63..df7fcbb5 100644 --- a/tests/mod_graphics_tests.lua +++ b/tests/mod_graphics_tests.lua @@ -987,6 +987,33 @@ check(fallback.style == "doublecircle", "a hook naming an unregistered style falls back to the vanilla bits") Runtime.install(Events.new(), Hooks.new(), {}) +-- ------- gated final-output ownership + +local outputHooks = Hooks.new() +Runtime.install(Events.new(), outputHooks, {}) +local outputCalls, outputContext = 0, nil +outputHooks:wrap("render.output", function(nextLink, context) + outputCalls, outputContext = outputCalls + 1, context + return true +end, 0, "test") +outputHooks:wrap("render.output_enabled", function() return false end, 0, "test") +Renderer:init() +Renderer.presentCanvas = nil +Renderer:beginFrame(false) +Renderer:endFrame(nil, nil) +check(outputCalls == 0 and Renderer.presentCanvas == nil, + "a disabled output hook leaves the direct render path untouched") + +outputHooks:wrap("render.output_enabled", function() return true end, 10, "test") +Renderer:init() +Renderer.presentCanvas = nil +Renderer:beginFrame(false) +Renderer:endFrame(nil, nil) +check(outputCalls == 1 and outputContext and outputContext.canvas, + "an enabled output hook receives the finished frame") +check(outputContext and outputContext.generation == 1, + "the output context identifies the active generation") + -- ------- asset transforms local function seedTransform(id, source) From 500d8c2c078e8512c94148a89627dfb52a8d1a1a Mon Sep 17 00:00:00 2001 From: 1jamie Date: Thu, 13 Aug 2026 17:33:29 -0500 Subject: [PATCH 07/13] feat(mods): enhance mod management with profile controls and dependency checks - Added dedicated profile control in the MODS panel for easier profile management. - Implemented dependency checking during mod installation and updates to ensure compatibility. - Improved manifest parsing to support GitHub repository hints for dependencies. - Enhanced UI interactions for saving and renaming profiles. - Updated tests to cover new functionality and ensure stability. --- src/import/LauncherView.lua | 725 ++++++++++++++++++++++++++++++++--- src/import/RomImporter.lua | 128 ++++++- src/mods/LauncherMods.lua | 319 ++++++++++++++- src/mods/Manifest.lua | 65 ++-- src/mods/ModIndex.lua | 12 +- tests/mod_manifest_tests.lua | 42 +- tests/mod_ui_tests.lua | 12 + 7 files changed, 1218 insertions(+), 85 deletions(-) diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index c7dedf52..1257f16c 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -568,10 +568,10 @@ local function modStatusColor(status) return Strings("Incompatible"), PAL.yellow end --- MODS panel scope row: which game the list is answering for. Drawn from --- GameVersion.ORDER so a new game needs nothing here. +-- MODS panel scope row: which game the list is answering for, plus dedicated Profile control (cycle + gear). local function buildModScopeRow(imp, x, y, w, m) local GameVersion = require("src.core.GameVersion") + local LauncherMods = require("src.mods.LauncherMods") local h = math.max(Kit.tapMin(), math.floor(26 * m.s)) local gap = math.floor(6 * m.s) local label = Strings("Show for:") @@ -584,16 +584,63 @@ local function buildModScopeRow(imp, x, y, w, m) { id = version, label = GameVersion.info(version).label } end end - if #options < 2 then return 0 end - for _, opt in ipairs(options) do - local cw = Kit.textWidth("micro", opt.label) + math.floor(18 * m.s) - if Kit.chip(cx, y, cw, h, opt.label, imp.modScope == opt.id, PAL.lineStrong, - "mod-scope-" .. tostring(opt.id or "all")) then - local want = opt.id - queueAction(imp, "mod-scope-" .. tostring(want or "all"), - function() imp:_setModScope(want) end) + + -- Dedicated Profile control section (cycle button + gear icon button) on right side of Scope Bar + local profiles, activeProf = LauncherMods.getProfiles() + local isCompact = (w < math.floor(500 * m.s)) + local nameText = tostring(activeProf or "Default") + local profLabel = isCompact and nameText or Strings("Profile: %s", nameText) + local profW = Kit.textWidth("micro", profLabel) + math.floor(20 * m.s) + local gearW = h + local gearX = x + w - gearW + local profX = gearX - profW - math.floor(4 * m.s) + + -- Tapping main profile button cycles to the next profile (styles match iconButton) + Kit._audit("control", profX, y, profW, h, "mod-scope-profile") + local focused = Kit.focusable("mod-scope-profile", profX, y, profW, h) + local hot = focused or Kit.hover(profX, y, profW, h) + Theme.fillRounded(profX, y, profW, h, hot and PAL.ink or PAL.surface, 1) + Theme.strokeRounded(profX, y, profW, h, PAL.line, + hot and Theme.A.focus or Theme.A.hairline, 1) + Kit.textCenterBold("micro", profLabel, profX, + y + (h - Kit.textHeight("micro")) / 2, profW, + hot and PAL.inverse or PAL.heading) + if Kit.press(profX, y, profW, h) or Kit._activateId == "mod-scope-profile" then + local nextIdx = 1 + for i, p in ipairs(profiles) do + if p.name == activeProf then + nextIdx = (i % #profiles) + 1 + break + end + end + local nextProf = profiles[nextIdx] and profiles[nextIdx].name + if nextProf then + queueAction(imp, "mod-scope-profile", function() + LauncherMods.applyProfile(nextProf) + if imp._refreshMods then imp:_refreshMods() end + end) + end + end + + -- Tapping gear button opens the Profile Manager modal + imp._gearIcon = imp._gearIcon or (love and love.graphics and love.graphics.newImage and love.graphics.newImage("assets/launcher/gear.png")) + iconButton(imp, "mod-profile-gear", gearX, y, gearW, imp._gearIcon, function() + imp._profilesPopup = true + end) + + if #options >= 2 then + for _, opt in ipairs(options) do + local cw = Kit.textWidth("micro", opt.label) + math.floor(18 * m.s) + if cx + cw <= profX - gap then + if Kit.chip(cx, y, cw, h, opt.label, imp.modScope == opt.id, PAL.lineStrong, + "mod-scope-" .. tostring(opt.id or "all")) then + local want = opt.id + queueAction(imp, "mod-scope-" .. tostring(want or "all"), + function() imp:_setModScope(want) end) + end + cx = cx + cw + gap + end end - cx = cx + cw + gap end return h + math.floor(8 * m.s) end @@ -1380,33 +1427,31 @@ end local function drawCheck(x, y, size, color) love.graphics.push("all") love.graphics.setColor(color) - love.graphics.setLineWidth(math.max(2, size * 0.16)) + love.graphics.setLineWidth(math.max(2.2, size * 0.17)) love.graphics.setLineJoin("bevel") love.graphics.line( - x, y + size * 0.55, - x + size * 0.35, y + size * 0.85, - x + size * 0.95, y + size * 0.15) + x + size * 0.02, y + size * 0.52, + x + size * 0.38, y + size * 0.80, + x + size * 1.015, y + size * 0.18) love.graphics.pop() end -- One compact coloured checkbox for each game. The cartridge colour carries --- the game identity even when the row is narrow; the letter keeps an unchecked --- box legible without relying on colour alone. +-- the game identity even when the row is narrow. local function modGameCheckbox(x, y, size, checked, game, id) local color = cartColor(game) local focused = Kit.focusable(id, x, y, size, size) local hot = focused or Kit.hover(x, y, size, size) if love.graphics then + Theme.fillRounded(x, y, size, size, PAL.bg, 1) if checked then - Theme.fillRounded(x, y, size, size, color, 1) - drawCheck(x, y, size, PAL.inverse) + Theme.strokeRounded(x, y, size, size, color, + hot and Theme.A.focus or 0.9, 1.5) + drawCheck(x, y, size, color) else - Theme.fillRounded(x, y, size, size, PAL.bg, 1) - Kit.textCenterBold("micro", game:sub(1, 1):upper(), x, - y + (size - Kit.textHeight("micro")) / 2, size, color) + Theme.strokeRounded(x, y, size, size, color, + hot and Theme.A.focus or Theme.A.hairline, 1) end - Theme.strokeRounded(x, y, size, size, color, - hot and Theme.A.focus or Theme.A.hover, 1) end return Kit.press(x, y, size, size) or Kit._activateId == id end @@ -1418,28 +1463,79 @@ local function buildModsPanel(imp, x, y, w, availH, m) local gap = m.gap local cy = y - -- header: just the action cluster, right-aligned. No "Mods" headline (the - -- active tab already says it) and no enabled count (the toggles show it). - local place = Layout.rightCluster(x, w, math.floor(6 * m.s)) + -- header: progressive action cluster. Surfaces primary/frequent actions + -- (Import, Updates, Sort) directly on the bar across screen sizes, placing + -- bulk actions (Enable all / Disable all) into More... on compact viewports. local bh = m.btnH local importLabel = imp:_modsImportButtonLabel() - local iw2 = Kit.textWidth("small", importLabel) + math.floor(24 * m.s) - btn(imp, place(iw2), cy, iw2, bh, "mods-import", importLabel, { - kind = "accent", font = "small", - action = function() imp:chooseMod() end }) + local importW = Kit.textWidth("small", importLabel) + math.floor(24 * m.s) + if #mods > 0 then - local dw = Kit.textWidth("small", Strings("Disable all")) + math.floor(20 * m.s) - btn(imp, place(dw), cy, dw, bh, "mods-disable-all", Strings("Disable all"), { - kind = "warn", font = "small", - action = function() imp:_setAllMods(false) end }) - local ew = Kit.textWidth("small", Strings("Enable all")) + math.floor(20 * m.s) - btn(imp, place(ew), cy, ew, bh, "mods-enable-all", Strings("Enable all"), { - kind = "good", font = "small", - action = function() imp:_setAllMods(true) end }) - local sw = Kit.textWidth("small", Strings("Sort")) + math.floor(24 * m.s) - btn(imp, place(sw), cy, sw, bh, "mods-sort", Strings("Sort"), { - font = "small", - action = function() imp._sortPopup = true end }) + local disableW = Kit.textWidth("small", Strings("Disable all")) + math.floor(20 * m.s) + local enableW = Kit.textWidth("small", Strings("Enable all")) + math.floor(20 * m.s) + local checkFullW = Kit.textWidth("small", Strings("Check for updates")) + math.floor(20 * m.s) + local checkShortW = Kit.textWidth("small", Strings("Updates")) + math.floor(20 * m.s) + local sortW = Kit.textWidth("small", Strings("Sort")) + math.floor(24 * m.s) + local moreW = Kit.textWidth("small", Strings("More...")) + math.floor(20 * m.s) + + local fullReq = importW + disableW + enableW + checkFullW + sortW + math.floor(30 * m.s) + local medReq = importW + checkShortW + sortW + moreW + math.floor(24 * m.s) + + local place = Layout.rightCluster(x, w, math.floor(6 * m.s)) + + if fullReq <= w then + -- Tier 1 (Desktop / Wide): Show all 5 full-text buttons + btn(imp, place(importW), cy, importW, bh, "mods-import", importLabel, { + kind = "accent", font = "small", + action = function() imp:chooseMod() end }) + btn(imp, place(disableW), cy, disableW, bh, "mods-disable-all", Strings("Disable all"), { + kind = "warn", font = "small", + action = function() imp:_setAllMods(false) end }) + btn(imp, place(enableW), cy, enableW, bh, "mods-enable-all", Strings("Enable all"), { + kind = "good", font = "small", + action = function() imp:_setAllMods(true) end }) + btn(imp, place(checkFullW), cy, checkFullW, bh, "mods-check-updates", Strings("Check for updates"), { + font = "small", + action = function() imp:_syncModUpdateInfo(true) end }) + btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), { + font = "small", + action = function() imp._sortPopup = true end }) + elseif medReq <= w then + -- Tier 2 (Medium / Compact): Surface Import, Updates, and Sort directly + btn(imp, place(importW), cy, importW, bh, "mods-import", importLabel, { + kind = "accent", font = "small", + action = function() imp:chooseMod() end }) + btn(imp, place(checkShortW), cy, checkShortW, bh, "mods-check-updates", Strings("Updates"), { + font = "small", + action = function() imp:_syncModUpdateInfo(true) end }) + btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), { + font = "small", + action = function() imp._sortPopup = true end }) + btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), { + font = "small", + action = function() imp._modHeaderActionsPopup = true end }) + else + -- Tier 3 (Ultra-Compact Mobile): Surface Import, Sort + More... + local importShortLabel = Strings("Import") + local importShortW = Kit.textWidth("small", importShortLabel) + math.floor(20 * m.s) + local miniReq = importShortW + sortW + moreW + math.floor(18 * m.s) + local useImportW = (miniReq <= w) and importShortW or importW + + btn(imp, place(useImportW), cy, useImportW, bh, "mods-import", (miniReq <= w) and importShortLabel or importLabel, { + kind = "accent", font = "small", + action = function() imp:chooseMod() end }) + btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), { + font = "small", + action = function() imp._sortPopup = true end }) + btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), { + font = "small", + action = function() imp._modHeaderActionsPopup = true end }) + end + else + local place = Layout.rightCluster(x, w, math.floor(6 * m.s)) + btn(imp, place(importW), cy, importW, bh, "mods-import", importLabel, { + kind = "accent", font = "small", + action = function() imp:chooseMod() end }) end cy = cy + bh + math.floor(8 * m.s) @@ -1534,17 +1630,28 @@ local function buildModsPanel(imp, x, y, w, availH, m) local mod = mods[i] local ry = listTop + (i - first) * (rowH + gap) - scroll local rowKey = "mod-row-" .. mod.id - -- The whole row is the control: it opens the per-mod actions popup - -- (update / versions / delete moved there). Only the enable toggle - -- stays inline, because flipping a mod on and off is the everyday act. + local isFullyDisabled = true + if mod.enabledByVersion then + for _, on in pairs(mod.enabledByVersion) do + if on then isFullyDisabled = false; break end + end + else + isFullyDisabled = not mod.enabled + end + local focused = Kit.focusable(rowKey, x, ry, w, rowH) local hot = focused or Kit.hover(x, ry, w, rowH) - Kit.card(x, ry, w, rowH, hot) + if isFullyDisabled then + Theme.fillRounded(x, ry, w, rowH, PAL.bg, 0.8, Theme.cardRadius()) + Theme.strokeRounded(x, ry, w, rowH, PAL.muted, hot and Theme.A.hover or 0.25, 1, Theme.cardRadius()) + else + Kit.card(x, ry, w, rowH, hot) + end local pad = math.floor(12 * m.s) local px, inner = x + pad, w - 2 * pad local ly = ry + math.floor(10 * m.s) - local togGap = math.floor(4 * m.s) + local togGap = math.floor(5 * m.s) + 1 local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id) -- These answer separate games, not a single shared install flag. The @@ -1582,7 +1689,8 @@ local function buildModsPanel(imp, x, y, w, availH, m) and Kit.textWidth("micro", mod.targets) + math.floor(12 * m.s) or 0 local nameShown = Kit.ellipsize("button", mod.name, textW - badgeW - gamesW - math.floor(12 * m.s)) - Kit.text("button", nameShown, px, ly, PAL.heading) + local headingCol = isFullyDisabled and PAL.muted or PAL.heading + Kit.text("button", nameShown, px, ly, headingCol) local tagX = px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s) Kit.tag(tagX, ly, badgeW, Kit.textHeight("button"), mod.badge, mod.experimental and PAL.yellow or PAL.muted) @@ -2164,6 +2272,219 @@ local function buildVersionsModal(imp, m) action = function() imp._modVersions = nil end }) end +-- Modal for per-profile actions (Duplicate, Rename, Delete) for compact / mobile / RG device compatibility +local function buildSingleProfileActionsModal(imp, m) + local pName = imp._singleProfileActions and imp._singleProfileActions.name + if not pName then imp._singleProfileActions = nil return end + + local LauncherMods = require("src.mods.LauncherMods") + local SaveData = require("src.core.SaveData") + local options = SaveData.loadOptions() + local profiles, active = LauncherMods.getProfiles(options) + + local pad = math.floor(18 * m.s) + local w = math.min(math.floor(380 * m.s), m.w - 2 * m.pad) + local gap = math.floor(8 * m.s) + local canDelete = (#profiles > 1) + local armed = deleteArmed(imp, "profile", pName, nil) + + local btns = { + { + label = Strings("Duplicate profile"), + kind = "accent", + action = function() + LauncherMods.duplicateProfile(pName, options) + imp._singleProfileActions = nil + end + }, + { + label = Strings("Rename profile"), + font = "small", + action = function() + imp._singleProfileActions = nil + imp._profileRenamePrompt = { oldName = pName, text = pName } + imp:_armTextInput(pName) + end + }, + } + if canDelete then + btns[#btns + 1] = { + label = DELETE_LABEL(armed), + kind = armed and "warn" or "danger", + keepArm = true, + action = function() + imp:pressDelete("profile", pName, nil, function() + LauncherMods.deleteProfile(pName, options) + imp._singleProfileActions = nil + end) + end + } + end + + local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + + #btns * (m.btnH + gap) + m.btnH + pad + local px, py, pw = modalPanel(m, w, h) + local cy = py + pad + + Kit.text("button", Kit.ellipsize("button", pName, pw - 2 * pad), px + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("button") + math.floor(12 * m.s) + + for i, b in ipairs(btns) do + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "profact-" .. i, b.label, { + kind = b.kind, + font = "small", + keepArm = b.keepArm, + action = function() + b.action() + if imp._refreshMods then imp:_refreshMods() end + end + }) + cy = cy + m.btnH + gap + end + + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "profact-close", + Strings("Close"), { + font = "small", + action = function() imp._singleProfileActions = nil end }) +end + +-- Modal for Mod Profiles (#593) - interactive profile manager (switch, edit, duplicate, delete) +local function buildProfilesModal(imp, m) + local LauncherMods = require("src.mods.LauncherMods") + local SaveData = require("src.core.SaveData") + local options = SaveData.loadOptions() + local profiles, active = LauncherMods.getProfiles(options) + + local pad = math.floor(18 * m.s) + local w = math.min(math.floor(460 * m.s), m.w - 2 * m.pad) + local gap = math.floor(8 * m.s) + local rowH = math.max(Kit.tapMin(), math.floor(40 * m.s)) + + local n = #profiles + local maxVisible = 4 + local listH = math.min(maxVisible, math.max(1, n)) * (rowH + gap) - gap + local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + + m.btnH + gap + listH + math.floor(12 * m.s) + m.btnH + pad + + local px, py, pw = modalPanel(m, w, h) + local cy = py + pad + + Kit.text("button", Strings("Mod Profiles"), px + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("button") + math.floor(12 * m.s) + + -- New Profile button + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "prof-new-top", + Strings("+ Create New Profile"), { + kind = "accent", font = "small", + action = function() + imp._profileSavePrompt = { text = "PROFILE " .. tostring(#profiles + 1) } + imp:_armTextInput(imp._profileSavePrompt.text) + end, + }) + cy = cy + m.btnH + gap + + -- Scrollable Profile Rows + local scrollMax = math.max(0, n * (rowH + gap) - gap - listH) + local scroll = clamp(imp._profScrollOffset or 0, 0, scrollMax) + if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and Kit.hit(px + pad, cy, pw - 2 * pad, listH) then + scroll = clamp(scroll - Kit.wheelY * math.floor(36 * m.s), 0, scrollMax) + Kit.wheelY = 0 + end + imp._profScrollOffset = scroll + + Kit.pushClip(px + pad, cy, pw - 2 * pad, listH) + for i, p in ipairs(profiles) do + local ry = cy + (i - 1) * (rowH + gap) - scroll + if ry + rowH >= cy and ry <= cy + listH then + local isCur = (p.name == active) + local rowKey = "prof-row-" .. i + Kit.card(px + pad, ry, pw - 2 * pad, rowH, isCur) + + local rx = px + pad + math.floor(12 * m.s) + local editBtnW = math.floor(64 * m.s) + local swBtnW = isCur and 0 or math.floor(64 * m.s) + local rightClusterW = editBtnW + swBtnW + (isCur and 0 or math.floor(4 * m.s)) + local nameW = math.max(math.floor(80 * m.s), pw - 2 * pad - 2 * math.floor(12 * m.s) - rightClusterW - math.floor(50 * m.s)) + local nameText = Kit.ellipsize("small", p.name, nameW) + Kit.text("small", nameText, rx, ry + (rowH - Kit.textHeight("small")) / 2, isCur and PAL.heading or PAL.muted) + + if isCur then + Kit.tag(rx + Kit.textWidth("small", nameText) + math.floor(6 * m.s), + ry + (rowH - Kit.textHeight("micro")) / 2, + Kit.textWidth("micro", Strings("Active")) + math.floor(8 * m.s), + Kit.textHeight("micro"), Strings("Active"), PAL.green) + end + + -- Right side controls: [Switch] (if not active) + [Edit] + local place = Layout.rightCluster(px + pad, pw - 2 * pad, math.floor(4 * m.s)) + + -- Edit button (opens per-profile action sheet) + btn(imp, place(editBtnW), ry + math.floor(4 * m.s), editBtnW, rowH - math.floor(8 * m.s), "prof-ed-" .. i, + Strings("Edit"), { + font = "micro", + action = function() + imp._singleProfileActions = { name = p.name } + end, + }) + + -- Switch button (if not active) + if not isCur then + btn(imp, place(swBtnW), ry + math.floor(4 * m.s), swBtnW, rowH - math.floor(8 * m.s), "prof-sw-" .. i, + Strings("Switch"), { + kind = "good", font = "micro", + action = function() + LauncherMods.applyProfile(p.name, options) + if imp._refreshMods then imp:_refreshMods() end + end, + }) + end + end + end + Kit.popClip() + + cy = cy + listH + math.floor(12 * m.s) + + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "prof-close", + Strings("Close"), { + font = "small", + action = function() imp._profilesPopup = nil end }) +end + +-- Modal for MODS tab header actions on mobile / compact displays +local function buildModHeaderActionsModal(imp, m) + local pad = math.floor(18 * m.s) + local w = math.floor(380 * m.s) + local gap = math.floor(8 * m.s) + local btns = { + { label = Strings("Mod profiles..."), action = function() imp._profilesPopup = true end }, + { label = Strings("Check for updates"), action = function() imp:_syncModUpdateInfo(true) end }, + { label = Strings("Enable all mods"), kind = "good", action = function() imp:_setAllMods(true) end }, + { label = Strings("Disable all mods"), kind = "warn", action = function() imp:_setAllMods(false) end }, + { label = Strings("Sort mods..."), action = function() imp._sortPopup = true end }, + } + local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + + #btns * (m.btnH + gap) + m.btnH + pad + local px, py, pw = modalPanel(m, w, h) + local cy = py + pad + Kit.text("button", Strings("More Mod Actions"), px + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("button") + math.floor(12 * m.s) + + for i, b in ipairs(btns) do + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modheadact-" .. i, b.label, { + kind = b.kind or "ghost", font = "small", + action = function() + imp._modHeaderActionsPopup = nil + b.action() + end + }) + cy = cy + m.btnH + gap + end + + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modheadact-close", + Strings("Close"), { font = "small", + action = function() imp._modHeaderActionsPopup = nil end }) +end + -- Sort chooser, shared by the MODS and FIND MODS tabs (they share the -- persisted key, so one popup serves both). local function buildSortModal(imp, m) @@ -2299,11 +2620,13 @@ local function buildModActionsModal(imp, m) end if not mod then imp._modActions = nil return end local hasGit = mod.github and mod.github ~= "" + local depSpecs = mod.dependencySpecs or (mod.manifest and mod.manifest.dependencySpecs) + local hasDeps = depSpecs and #depSpecs > 0 local info = hasGit and imp:_modUpdateInfo(mod.id) local pad = math.floor(18 * m.s) local w = math.floor(440 * m.s) local gap = math.floor(8 * m.s) - local nBtns = (hasGit and 2 or 0) + 2 + local nBtns = (hasGit and 2 or 0) + (hasDeps and 1 or 0) + 2 local h = pad + Kit.textHeight("button") + math.floor(4 * m.s) + Kit.textHeight("small") + math.floor(12 * m.s) + nBtns * (m.btnH + gap) - gap + pad @@ -2339,6 +2662,20 @@ local function buildModActionsModal(imp, m) action = function() imp:_modGithubAction(id, "versions") end }) cy = cy + m.btnH + gap end + if hasDeps then + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modact-deps", + Strings("Check dependencies"), { + kind = "accent", font = "small", + action = function() + local LauncherMods = require("src.mods.LauncherMods") + local depCheck = LauncherMods.checkDependencies(mod.manifest or mod) + if depCheck then + imp._modDepResolver = depCheck + end + imp._modActions = nil + end }) + cy = cy + m.btnH + gap + end local armed = deleteArmed(imp, "mod", id, nil) btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modact-del", DELETE_LABEL(armed), { @@ -2655,6 +2992,238 @@ local function buildSettingsModal(imp, m) Kit.pager(px + pad, cy, pw - 2 * pad, cur, n, perPage, "settings")) end +local function buildDepResolverModal(imp, m) + local res = imp._modDepResolver + if not res then return end + + local pad = math.floor(18 * m.s) + local w = math.floor(540 * m.s) + local chipH = math.max(Kit.tapMin(), math.floor(28 * m.s)) + local rowH = math.floor(74 * m.s) + local gap = math.floor(8 * m.s) + local warnH = math.floor(38 * m.s) + + local n = #(res.deps or {}) + local anyUnsatisfied = false + for _, d in ipairs(res.deps or {}) do + if d.status ~= "satisfied" and d.status ~= "disabled" then anyUnsatisfied = true; break end + end + + local totalContentH = n > 0 and (n * rowH + (n - 1) * gap) or 0 + + -- Calculate content height dynamically so modal auto-fits small lists snuggly + local headerH = Kit.textHeight("button") + math.floor(4 * m.s) + + Kit.textHeight("small") + math.floor(10 * m.s) + local warnTotalH = warnH + math.floor(12 * m.s) + local listMaxH = math.floor(240 * m.s) + local itemsH = math.min(totalContentH > 0 and totalContentH or rowH, listMaxH) + local footerH = math.floor(10 * m.s) + m.btnH + local wantedH = pad + headerH + warnTotalH + itemsH + footerH + pad + local h = math.floor(math.min(m.H - 2 * m.pad, math.max(260 * m.s, wantedH))) + + local px, py, pw, ph = modalPanel(m, w, h) + local cy = py + pad + + -- Title + local titleText = Strings("Dependency Resolver: ") .. tostring(res.targetMod.name or res.targetMod.id) + Kit.text("button", Kit.ellipsize("button", titleText, pw - 2 * pad), px + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("button") + math.floor(4 * m.s) + + -- Subtitle / intro + local subText = Strings("This mod requires additional dependencies or has conflicts:") + Kit.text("small", subText, px + pad, cy, PAL.muted) + cy = cy + Kit.textHeight("small") + math.floor(10 * m.s) + + -- Security Disclaimer Banner Callout Card + Theme.fillRounded(px + pad, cy, pw - 2 * pad, warnH, PAL.rowBg, 1, Theme.radius()) + Theme.strokeRounded(px + pad, cy, pw - 2 * pad, warnH, PAL.yellow, Theme.A.hover, 1, Theme.radius()) + local warnMsg = Strings("Caution: Only pull dependencies from sources you trust.\nVerify source repositories before fetching.") + Kit.text("micro", warnMsg, px + pad + math.floor(12 * m.s), cy + math.floor(5 * m.s), PAL.yellow) + cy = cy + warnH + math.floor(12 * m.s) + + -- List area bounds + local listH = (py + ph - pad) - cy - m.btnH - math.floor(10 * m.s) + local scrollMax = math.max(0, totalContentH - listH) + + -- Mouse wheel scroll handling matching upstream pattern + if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and Kit.hit(px + pad, cy, pw - 2 * pad, listH) then + imp._depScrollOffset = clamp((imp._depScrollOffset or 0) - Kit.wheelY * math.floor(48 * m.s), 0, scrollMax) + Kit.wheelY = 0 + elseif scrollMax == 0 then + imp._depScrollOffset = 0 + else + imp._depScrollOffset = clamp(imp._depScrollOffset or 0, 0, scrollMax) + end + + -- Pump active in-flight pulls + if imp._pumpDepPulls then + imp:_pumpDepPulls() + end + + -- Clipped vertical scroll container + Kit.pushClip(px + pad, cy, pw - 2 * pad, listH) + local startY = cy - (imp._depScrollOffset or 0) + + for i = 1, n do + local dep = res.deps[i] + local ry = startY + (i - 1) * (rowH + gap) + + -- Cull rows completely outside the list viewport rectangle + if ry + rowH >= cy and ry <= cy + listH then + -- Item Card Fill & Stroke (matching launcher card interiors & radius) + local hot = Kit.hover(px + pad, ry, pw - 2 * pad, rowH) + Theme.row(px + pad, ry, pw - 2 * pad, rowH, hot and "hover" or "normal") + + local ix = px + pad + math.floor(12 * m.s) + local innerW = pw - 2 * pad - math.floor(24 * m.s) + + -- Dep title & range + local depHeader = tostring(dep.name or dep.id) + if dep.range and dep.range ~= "" then + depHeader = depHeader .. " (" .. dep.range .. ")" + end + Kit.text("small", Kit.ellipsize("small", depHeader, innerW - math.floor(210 * m.s)), + ix, ry + math.floor(8 * m.s), PAL.heading) + + -- Status Badge & Subtext + local statusText, statusCol + if dep.status == "satisfied" then + statusText = Strings("Installed & Compatible (v%s)", tostring(dep.installedVersion or "?")) + statusCol = PAL.green + elseif dep.status == "incompatible" then + statusText = Strings("Incompatible (installed v%s, needs %s)", tostring(dep.installedVersion or "?"), tostring(dep.range or "")) + statusCol = PAL.yellow + elseif dep.status == "conflict" then + statusText = Strings("Incompatible mod enabled (v%s)", tostring(dep.installedVersion or "?")) + statusCol = PAL.red + elseif dep.status == "disabled" then + statusText = Strings("Disabled (conflict resolved)") + statusCol = PAL.green + else + statusText = Strings("Missing") + statusCol = PAL.red + end + Kit.text("micro", statusText, ix, ry + math.floor(8 * m.s) + Kit.textHeight("small") + math.floor(2 * m.s), statusCol) + + -- Repo source line or Conflict reason + local repoLine + if dep.status == "conflict" or dep.kind == "conflict" then + repoLine = Strings("Listed as incompatible with ") .. tostring(res.targetMod.name or res.targetMod.id) + elseif dep.github then + repoLine = Strings("Source: github.com/") .. dep.github + else + repoLine = Strings("Source: Unknown (no repo listed)") + end + Kit.text("micro", repoLine, ix, ry + math.floor(8 * m.s) + Kit.textHeight("small") + Kit.textHeight("micro") + math.floor(4 * m.s), PAL.muted) + + -- Action buttons right cluster (vertically centered inside card) + local ly = ry + math.floor((rowH - chipH) / 2) + local place = Layout.rightCluster(ix, innerW, math.floor(8 * m.s)) + + local pState = imp._depPullState and imp._depPullState[dep.id] + + if pState and pState.stage ~= "done" and pState.stage ~= "error" then + local label = Strings("Pulling...") + if pState.stage == "fetching" then label = Strings("Fetching...") + elseif pState.stage == "downloading" then + if pState.progress and pState.progress > 0 then + label = Strings("Downloading %d%%", math.floor(pState.progress * 100)) + else + label = Strings("Downloading...") + end + elseif pState.stage == "installing" then label = Strings("Installing...") + end + Kit.chip(place(Kit.textWidth("small", label) + math.floor(16 * m.s)), ly, + Kit.textWidth("small", label) + math.floor(16 * m.s), chipH, label, true, PAL.yellow, "dep-pulling-" .. i) + elseif dep.status == "conflict" then + local btnLabel = Strings("Disable mod") + local bw = Kit.textWidth("small", btnLabel) + math.floor(20 * m.s) + btn(imp, place(bw), ly, bw, chipH, "dep-dis-" .. i, btnLabel, { + kind = "warn", font = "small", + action = function() + local LauncherMods = require("src.mods.LauncherMods") + LauncherMods.setEnabled(dep.id, false, imp.modScope) + dep.status = "disabled" + if imp._refreshMods then imp:_refreshMods() end + end, + }) + elseif dep.status == "disabled" then + local chipLabel = Strings("Disabled") + local cw = Kit.textWidth("small", chipLabel) + math.floor(16 * m.s) + Kit.chip(place(cw), ly, cw, chipH, chipLabel, true, PAL.green, "dep-dischip-" .. i) + else + -- Pull / Update button if github repo is known and not satisfied + if dep.github and dep.status ~= "satisfied" then + local btnLabel = dep.status == "incompatible" and Strings("Update") or Strings("Pull from GitHub") + local bw = Kit.textWidth("small", btnLabel) + math.floor(20 * m.s) + btn(imp, place(bw), ly, bw, chipH, "dep-pull-" .. i, btnLabel, { + kind = "accent", font = "small", + action = function() + if imp._startDepPull then + imp:_startDepPull(dep) + end + end, + }) + end + + -- Open Source link button if safeUrl is present + if dep.safeUrl then + local bw = Kit.textWidth("small", Strings("View Source")) + math.floor(20 * m.s) + btn(imp, place(bw), ly, bw, chipH, "dep-view-" .. i, Strings("View Source"), { + font = "small", + action = function() + if love and love.system and love.system.openURL then + love.system.openURL(dep.safeUrl) + end + end, + }) + end + end + end + end + Kit.popClip() + + -- Scrollbar indicator if scrollMax > 0 + if scrollMax > 0 then + local barW = math.floor(4 * m.s) + local barX = px + pw - pad - barW + local thumbH = math.max(math.floor(20 * m.s), math.floor(listH * (listH / totalContentH))) + local thumbY = cy + (listH - thumbH) * ((imp._depScrollOffset or 0) / scrollMax) + Theme.fill(barX, cy, barW, listH, PAL.bg, 0.4) + Theme.fill(barX, thumbY, barW, thumbH, PAL.muted, 0.7) + end + + cy = cy + listH + math.floor(10 * m.s) + + -- Bottom Action Buttons + if anyUnsatisfied then + local btnW = math.floor((pw - 2 * pad - math.floor(10 * m.s)) / 2) + btn(imp, px + pad, cy, btnW, m.btnH, "depresolver-pullall", Strings("Pull All Available"), { + kind = "accent", font = "small", + action = function() + for _, dep in ipairs(res.deps or {}) do + if dep.github and dep.status ~= "satisfied" and dep.status ~= "disabled" and imp._startDepPull then + imp:_startDepPull(dep) + end + end + end, + }) + btn(imp, px + pad + btnW + math.floor(10 * m.s), cy, btnW, m.btnH, "depresolver-close", Strings("Done"), { + font = "small", + action = function() + imp._modDepResolver = nil + end, + }) + else + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "depresolver-close", Strings("Done"), { + kind = "accent", font = "small", + action = function() + imp._modDepResolver = nil + end, + }) + end +end + -- Whether ANY modal will draw this frame. draw() consults this BEFORE the -- panels build: immediate mode hit-tests each control as it draws, so the -- panels underneath a modal must run with Kit.blockClicks already raised or @@ -2663,12 +3232,60 @@ end local function modalUp(imp) return (imp._settingsText or imp._settings or imp._rename or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes - or imp._findDetails or imp._modVersions or imp._sortPopup + or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup or imp._filterPopup or imp._indexManage or imp._modActions - or imp._findEntry or imp._gameManage) ~= nil + or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt + or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil end local function buildModals(imp, m) + if imp._profileRenamePrompt then + buildPrompt(imp, m, { + key = "profren", title = Strings("Rename profile"), + hint = Strings("Enter a new name for this profile:"), + text = imp._profileRenamePrompt.text or "", okLabel = Strings("Save"), + commit = function() + local txt = imp._profileRenamePrompt and imp._profileRenamePrompt.text + local old = imp._profileRenamePrompt and imp._profileRenamePrompt.oldName + if txt and txt ~= "" and old then + local LauncherMods = require("src.mods.LauncherMods") + LauncherMods.renameProfile(old, txt) + imp._profileRenamePrompt = nil + imp:_disarmTextInput() + if imp._refreshMods then imp:_refreshMods() end + end + end, + cancel = function() + imp._profileRenamePrompt = nil + imp:_disarmTextInput() + end, + footnote = Strings("Enter to save - Esc to cancel"), + }) + return true + end + if imp._profileSavePrompt then + buildPrompt(imp, m, { + key = "profsave", title = Strings("Save mod profile"), + hint = Strings("Enter a name for this mod profile:"), + text = imp._profileSavePrompt.text or "", okLabel = Strings("Save"), + commit = function() + local txt = imp._profileSavePrompt and imp._profileSavePrompt.text + if txt and txt ~= "" then + local LauncherMods = require("src.mods.LauncherMods") + LauncherMods.saveProfile(txt) + imp._profileSavePrompt = nil + imp:_disarmTextInput() + if imp._refreshMods then imp:_refreshMods() end + end + end, + cancel = function() + imp._profileSavePrompt = nil + imp:_disarmTextInput() + end, + footnote = Strings("Enter to save - Esc to cancel"), + }) + return true + end if imp._settingsText then local st = imp._settingsText buildPrompt(imp, m, { @@ -2733,10 +3350,14 @@ local function buildModals(imp, m) return true end if imp._modVersions then buildVersionsModal(imp, m) return true end + if imp._modDepResolver then buildDepResolverModal(imp, m) return true end -- The lighter popups come after the deep ones on purpose: opening -- Versions or Details from inside an actions popup draws the deeper modal -- while the popup's own state stays set, so closing the deep one drops -- you back where you were. + if imp._singleProfileActions then buildSingleProfileActionsModal(imp, m) return true end + if imp._profilesPopup then buildProfilesModal(imp, m) return true end + if imp._modHeaderActionsPopup then buildModHeaderActionsModal(imp, m) return true end if imp._sortPopup then buildSortModal(imp, m) return true end if imp._filterPopup then buildFilterModal(imp, m) return true end if imp._indexManage then buildIndexesModal(imp, m) return true end diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 24e894ef..362e0373 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1649,7 +1649,7 @@ end function RomImporter:_installMod(source) if self.workState == "working" then return end self.tab = "mods" - local ok, installed, res = pcall(function() + local ok, installed, res, manifest = pcall(function() local LauncherMods = require("src.mods.LauncherMods") return LauncherMods.installZip(source) end) @@ -1661,6 +1661,17 @@ function RomImporter:_installMod(source) if installed then pcall(self._refreshMods, self) self.modNotice = { ok = true, text = "Installed " .. tostring(res) } + local LauncherMods = require("src.mods.LauncherMods") + local checkTarget = manifest + if not checkTarget and type(res) == "string" then + checkTarget = { id = res } + end + if checkTarget then + local depCheck = LauncherMods.checkDependencies(checkTarget) + if depCheck and depCheck.hasIssues then + self._modDepResolver = depCheck + end + end else self.modNotice = { ok = false, text = tostring(res) } end @@ -2716,6 +2727,43 @@ function RomImporter:fileUrl(path) end function RomImporter:keypressed(key) + if self._profileSavePrompt then + if key == "backspace" then + self._profileSavePrompt.text = utf8Back(self._profileSavePrompt.text or "") + elseif key == "return" or key == "kpenter" then + local txt = self._profileSavePrompt and self._profileSavePrompt.text + if txt and txt ~= "" then + local LauncherMods = require("src.mods.LauncherMods") + LauncherMods.saveProfile(txt) + self._profileSavePrompt = nil + self:_disarmTextInput() + if self._refreshMods then self:_refreshMods() end + end + elseif key == "escape" then + self._profileSavePrompt = nil + self:_disarmTextInput() + end + return + end + if self._profileRenamePrompt then + if key == "backspace" then + self._profileRenamePrompt.text = utf8Back(self._profileRenamePrompt.text or "") + elseif key == "return" or key == "kpenter" then + local txt = self._profileRenamePrompt and self._profileRenamePrompt.text + local old = self._profileRenamePrompt and self._profileRenamePrompt.oldName + if txt and txt ~= "" and old then + local LauncherMods = require("src.mods.LauncherMods") + LauncherMods.renameProfile(old, txt) + self._profileRenamePrompt = nil + self:_disarmTextInput() + if self._refreshMods then self:_refreshMods() end + end + elseif key == "escape" then + self._profileRenamePrompt = nil + self:_disarmTextInput() + end + return + end if self._settingsText then if key == "backspace" then self._settingsText.text = utf8Back(self._settingsText.text) @@ -2885,6 +2933,14 @@ function RomImporter:_commitRename() end function RomImporter:textinput(text) + if self._profileSavePrompt then + self._profileSavePrompt.text = utf8Cap((self._profileSavePrompt.text or "") .. text, MAX_SLOT_LABEL) + return + end + if self._profileRenamePrompt then + self._profileRenamePrompt.text = utf8Cap((self._profileRenamePrompt.text or "") .. text, MAX_SLOT_LABEL) + return + end if self._settingsText then local st = self._settingsText st.text = utf8Cap(st.text .. text, st.maxLen or MAX_SLOT_LABEL) @@ -3371,6 +3427,76 @@ function RomImporter:_pumpModInstall() else self.modNotice = { ok = true, text = text } end + local LauncherMods = require("src.mods.LauncherMods") + local depCheck = LauncherMods.checkDependencies({ id = spec.modId }) + if depCheck and depCheck.hasIssues then + self._modDepResolver = depCheck + end +end + +-- Start an async pull for a single dependency +function RomImporter:_startDepPull(dep) + if not dep or not dep.github then return end + self._depPullState = self._depPullState or {} + local hFetch = require("src.mods.ModUpdate").beginFetchReleases(dep.github, dep.id, { force = true }) + self._depPullState[dep.id] = { + dep = dep, + stage = "fetching", + fetchHandle = hFetch, + } +end + +-- Pump all in-flight dependency pulls +function RomImporter:_pumpDepPulls() + if not self._depPullState then return end + local ModUpdate = require("src.mods.ModUpdate") + local LauncherMods = require("src.mods.LauncherMods") + + for depId, state in pairs(self._depPullState) do + if state.stage == "fetching" then + local done, releases, err = ModUpdate.pumpFetchReleases(state.fetchHandle) + if done then + if err or not releases or #releases == 0 then + state.stage = "error" + state.err = err or "No downloadable releases found on GitHub" + else + local rel = releases[1] + if not rel or not rel.zip or not rel.zip.url then + state.stage = "error" + state.err = "Latest release has no downloadable .zip asset" + else + local tmpName = ("dep_%s_%s.zip"):format(depId, tostring(rel.version or os.time())) + state.dlHandle = ModUpdate.beginDownloadZip(rel.zip.url, tmpName, rel.zip.size) + state.stage = "downloading" + state.targetVersion = rel.version + end + end + end + elseif state.stage == "downloading" then + local done, localPath, err, progress = ModUpdate.pumpDownloadZip(state.dlHandle) + state.progress = progress + if done then + if err or not localPath then + state.stage = "error" + state.err = err or "Download failed" + else + state.stage = "installing" + local okInst, versionRes = LauncherMods.installDownloadedZip(depId, localPath, state.targetVersion) + if okInst then + state.stage = "done" + pcall(self._refreshMods, self) + if self._modDepResolver and self._modDepResolver.targetMod then + local updated = LauncherMods.checkDependencies(self._modDepResolver.targetMod) + self._modDepResolver = updated + end + else + state.stage = "error" + state.err = tostring(versionRes or "Installation failed") + end + end + end + end + end end function RomImporter:_confirmModUpdate(modId, release) diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index c35adaf2..d0d47bc9 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -41,6 +41,8 @@ local CacheFs = require("src.import.CacheFs") local LauncherMods = {} +local discover -- forward declaration for helper functions above line 343 + -- ------- pure status derivation -- A hard-dependency / conflict / version verdict for one manifest. mods is @@ -105,6 +107,166 @@ local function statusFor(mods, id, enabledSet, enabled, version, forcedFor) return "ok", "Ready" end +-- Resolves the best-known GitHub owner/repo string for a dependency spec, if any. +function LauncherMods.resolveDependencyRepo(depId, parentManifest, installedDep) + if not depId or depId == "" then return nil end + -- 1. Check spec hint if parentManifest dependencySpecs carries it + if parentManifest and parentManifest.dependencySpecs then + for _, spec in ipairs(parentManifest.dependencySpecs) do + if spec.id == depId and spec.github then + return spec.github + end + end + end + -- 2. Check parentManifest raw dependency_sources + if parentManifest and parentManifest.raw and type(parentManifest.raw.dependency_sources) == "table" then + local src = parentManifest.raw.dependency_sources[depId] + if src then + local ok, clean = pcall(Manifest.parseGithub, src) + if ok and clean then return clean end + end + end + -- 3. Check installed dependency manifest + if installedDep and installedDep.github then + return installedDep.github + end + -- 4. Check ModIndex entries if available + local okModIndex, ModIndex = pcall(require, "src.mods.ModIndex") + if okModIndex and ModIndex and type(ModIndex.sources) == "function" then + for _, src in ipairs(ModIndex.sources() or {}) do + local cached = ModIndex.readCache and ModIndex.readCache(src.feed) + if cached and type(cached.index) == "table" then + for _, entry in ipairs(cached.index) do + if type(entry) == "table" and entry.id == depId and entry.github then + local ok, clean = pcall(Manifest.parseGithub, entry.github) + if ok and clean then return clean end + end + end + end + end + end + return nil +end + +-- Inspect manifest dependencies and conflicts against installed mods. +-- Returns: { hasIssues = bool, targetMod = {...}, deps = [ { id, name, range, status, kind, installedVersion, github, safeUrl }, ... ] } +function LauncherMods.checkDependencies(manifest, options, version, installedManifests) + if not manifest then + return { hasIssues = false, targetMod = { name = "Unknown" }, deps = {} } + end + + local SaveData = require("src.core.SaveData") + local manifests = installedManifests or discover() + local installedMap = {} + for _, m in ipairs(manifests) do + installedMap[m.id] = m + end + + local depsResult = {} + local hasIssues = false + + -- 1. Hard Dependencies (dependencySpecs) + if type(manifest.dependencySpecs) == "table" then + for _, spec in ipairs(manifest.dependencySpecs) do + local depId = spec.id + local range = spec.range + local installedDep = installedMap[depId] + local status = "satisfied" + local installedVersion = installedDep and installedDep.version or nil + + if not installedDep then + status = "missing" + hasIssues = true + elseif range and not Semver.satisfies(installedDep.version, range) then + status = "incompatible" + hasIssues = true + end + + local ghRepo = LauncherMods.resolveDependencyRepo(depId, manifest, installedDep) + local safeUrl = ghRepo and ("https://github.com/" .. ghRepo) or nil + + depsResult[#depsResult + 1] = { + id = depId, + name = (installedDep and installedDep.name) or depId, + range = range, + status = status, + kind = "dependency", + installedVersion = installedVersion, + github = ghRepo, + safeUrl = safeUrl, + } + end + end + + -- 2. Conflicts / Incompatible Mods (conflictSpecs) + local conflictIdsSeen = {} + local scope = SaveData.modScope and SaveData.modScope(version) or version + + local isEnabled = function(modId) + if not options then return true end + local dec = SaveData.modEnabled(options, modId, scope) + if dec ~= nil then return dec == true end + local m = installedMap[modId] + return m and not m.experimental + 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 + conflictIdsSeen[conflictId] = true + hasIssues = true + depsResult[#depsResult + 1] = { + id = conflictId, + name = installedOther.name or conflictId, + status = "conflict", + kind = "conflict", + installedVersion = installedOther.version or "?", + github = nil, + safeUrl = nil, + } + end + end + end + + -- (b) Reverse conflicts declared by installed mods against target manifest + if manifest.id then + 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 + conflictIdsSeen[other.id] = true + hasIssues = true + depsResult[#depsResult + 1] = { + id = other.id, + name = other.name or other.id, + status = "conflict", + kind = "conflict", + installedVersion = other.version or "?", + github = nil, + safeUrl = nil, + } + break + end + end + end + end + end + + return { + hasIssues = hasIssues, + targetMod = { + id = manifest.id, + name = manifest.name or manifest.id, + version = manifest.version or "?", + }, + deps = depsResult, + } +end + -- deriveList(manifests, options [, version]) -> the panel row list, pure. -- manifests is an array of validated manifests (Manifest.validate output); -- options is the options table (options.mods, options.modsByVersion and @@ -165,6 +327,8 @@ function LauncherMods.deriveList(manifests, options, version) statusDetail = detail, github = m.github, experimental = m.experimental == true, + dependencySpecs = m.dependencySpecs, + manifest = m, -- what game this mod is for, and whether it will run on the one the -- panel is showing (src/mods/ModTargets.lua) targets = ModTargets.chip(m), @@ -252,7 +416,7 @@ end -- Scan "mods/" one level deep for valid manifests (mirrors Loader:_discover, -- but validates only -- no entry chunk is ever loaded). First id wins on a -- duplicate. Returns an array of validated manifests. -local function discover() +discover = function() local fs = love and love.filesystem local out = {} if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end @@ -386,6 +550,7 @@ function LauncherMods.setEnabled(id, enabled, version) local options = SaveData.loadOptions() SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version)) SaveData.saveOptions(options) + LauncherMods.syncActiveProfile(options) return true end @@ -409,6 +574,7 @@ function LauncherMods.setAllEnabled(ids, enabled, version) end end SaveData.saveOptions(options) + LauncherMods.syncActiveProfile(options) return true end @@ -674,9 +840,9 @@ function LauncherMods.adoptStrays() return scanStrays(true) end -- opts.replace = true uninstalls an existing same-id mod first (updates / -- rollbacks). opts.expectId, when set, refuses a zip whose manifest id differs. function LauncherMods.installZip(source, opts) - local ok, result, err = pcall(LauncherMods._installZipInner, source, opts) + local ok, result, res2, res3 = pcall(LauncherMods._installZipInner, source, opts) if not ok then return nil, "import failed: " .. tostring(result) end - return result, err + return result, res2, res3 end function LauncherMods._installZipInner(source, opts) @@ -786,7 +952,7 @@ function LauncherMods._installZipInner(source, opts) return nil, copyErr or "could not copy the mod files" end cleanup() - return true, manifest.id + return true, manifest.id, manifest end -- Install (or replace) a mod from a GitHub release zip URL. @@ -912,4 +1078,149 @@ function LauncherMods.uninstall(id) return true end +-- ----------------------------------------------------------- Mod Profiles (#593) +local ModProfile = require("src.mods.ModProfile") + +function LauncherMods.getProfiles(options) + options = options or SaveData.loadOptions() + local manifests = discover() + ModProfile.ensureFirst(options, manifests, options.modOptions) + return options.modProfiles or {}, options.activeProfile or "PROFILE 1" +end + +function LauncherMods.applyProfile(profileName, options) + options = options or SaveData.loadOptions() + local profiles = options.modProfiles or {} + local targetProfile + for _, p in ipairs(profiles) do + if p.name == profileName then targetProfile = p; break end + end + if not targetProfile then return false end + ModProfile.restoreVersions(targetProfile, options) + options.activeProfile = profileName + SaveData.saveOptions(options) + return true +end + +function LauncherMods.saveProfile(profileName, options) + options = options or SaveData.loadOptions() + local manifests = discover() + options.modProfiles = options.modProfiles or {} + local snap = ModProfile.capture(manifests, options.modOptions, options.modsByVersion) + snap.name = profileName + local existingIdx + for i, p in ipairs(options.modProfiles) do + if p.name == profileName then existingIdx = i; break end + end + if existingIdx then + options.modProfiles[existingIdx] = snap + else + options.modProfiles[#options.modProfiles + 1] = snap + end + options.activeProfile = profileName + SaveData.saveOptions(options) + return snap +end + +local function copyTable(tbl) + if type(tbl) ~= "table" then return tbl end + local copy = {} + for k, v in pairs(tbl) do + copy[k] = type(v) == "table" and copyTable(v) or v + end + return copy +end + +function LauncherMods.syncActiveProfile(options) + options = options or SaveData.loadOptions() + local activeName = options.activeProfile or "PROFILE 1" + local profiles = options.modProfiles or {} + local manifests = discover() + local snap = ModProfile.capture(manifests, options.modOptions, options.modsByVersion) + snap.name = activeName + + local found = false + for i, p in ipairs(profiles) do + if p.name == activeName then + profiles[i] = snap + found = true + break + end + end + if not found then + profiles[#profiles + 1] = snap + end + options.modProfiles = profiles + options.activeProfile = activeName + SaveData.saveOptions(options) + return snap +end + +function LauncherMods.duplicateProfile(sourceName, options) + options = options or SaveData.loadOptions() + local profiles = options.modProfiles or {} + local sourceProfile + for _, p in ipairs(profiles) do + if p.name == sourceName then sourceProfile = p; break end + end + if not sourceProfile then return nil end + + local baseName = sourceName .. " (Copy)" + local newName = baseName + local n = 1 + local taken = {} + for _, p in ipairs(profiles) do taken[p.name] = true end + while taken[newName] do + n = n + 1 + newName = sourceName .. " (" .. n .. ")" + end + + local snap = { + name = newName, + enabled = copyTable(sourceProfile.enabled), + options = copyTable(sourceProfile.options), + slots = copyTable(sourceProfile.slots), + enabledByVersion = copyTable(sourceProfile.enabledByVersion), + } + profiles[#profiles + 1] = snap + options.modProfiles = profiles + options.activeProfile = newName + SaveData.saveOptions(options) + return snap +end + +function LauncherMods.renameProfile(oldName, newName, options) + options = options or SaveData.loadOptions() + if not newName or newName == "" then return false end + local profiles = options.modProfiles or {} + for i, p in ipairs(profiles) do + if p.name == oldName then + p.name = newName + if options.activeProfile == oldName then + options.activeProfile = newName + end + SaveData.saveOptions(options) + return true + end + end + return false +end + +function LauncherMods.deleteProfile(profileName, options) + options = options or SaveData.loadOptions() + options.modProfiles = options.modProfiles or {} + local newProfiles = {} + for _, p in ipairs(options.modProfiles) do + if p.name ~= profileName then newProfiles[#newProfiles + 1] = p end + end + options.modProfiles = newProfiles + if options.activeProfile == profileName then + local fallback = newProfiles[1] and newProfiles[1].name or "PROFILE 1" + LauncherMods.applyProfile(fallback, options) + else + SaveData.saveOptions(options) + end + return true +end + return LauncherMods diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index a6312c41..e142bff2 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -31,26 +31,6 @@ local function violation(strict, id, message) Logger.warn("[%s] %s", tostring(id), message) end --- "id" or "id@"; a malformed id or range fails for every api level --- because there is no sane fallback reading for it -local function parseSpecs(list, field) - local specs = {} - for _, entry in ipairs(list) do - assert(type(entry) == "string" and entry ~= "", - field .. " entries must be non-empty strings") - local id, range = entry:match("^([%w_%-]+)@(.+)$") - if not id then - id = entry:match("^([%w_%-]+)$") - assert(id, ("malformed %s entry %q"):format(field, entry)) - range = nil - end - local ok, err = Semver.validRange(range) - assert(ok, ("malformed %s range in %q: %s"):format(field, entry, tostring(err))) - specs[#specs + 1] = { id = id, range = range } - end - return specs -end - -- Optional GitHub repo for launcher auto-update / other-versions. -- Accepts "owner/repo" or a github.com URL; empty/absent means no updates. function Manifest.parseGithub(value) @@ -73,6 +53,47 @@ function Manifest.parseGithub(value) return owner .. "/" .. repo end +-- "id", "id@", "id@#", "id#", or table entry +local function parseSpecs(list, field, sources) + local specs = {} + sources = type(sources) == "table" and sources or {} + for _, entry in ipairs(list) do + local id, range, ghHint + if type(entry) == "table" then + id = entry.id + range = entry.range or entry.version + ghHint = entry.github or entry.repo + elseif type(entry) == "string" and entry ~= "" then + local main, hashRepo = entry:match("^([^#]+)#(.*)$") + if main then + entry = main + ghHint = hashRepo + end + id, range = entry:match("^([%w_%-]+)@(.+)$") + if not id then + id = entry:match("^([%w_%-]+)$") + assert(id, ("malformed %s entry %q"):format(field, entry)) + range = nil + end + else + error(field .. " entries must be non-empty strings or tables") + end + assert(id, ("malformed %s entry"):format(field)) + local ok, err = Semver.validRange(range) + assert(ok, ("malformed %s range in %q: %s"):format(field, tostring(entry), tostring(err))) + + local parsedGh = nil + local rawGh = ghHint or sources[id] + if rawGh then + local okGh, cleanGh = pcall(Manifest.parseGithub, rawGh) + if okGh and cleanGh then parsedGh = cleanGh end + end + + specs[#specs + 1] = { id = id, range = range, github = parsedGh } + end + return specs +end + -- conflicts + incompatible (alias) merged, first-wins on duplicate ids local function mergeConflictLists(conflicts, incompatible) local seen, out = {}, {} @@ -248,8 +269,8 @@ function Manifest.validate(raw, path) optional_dependencies = array(raw.optional_dependencies), conflicts = conflicts, incompatible = array(raw.incompatible), - dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies"), - optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies"), + dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies", raw.dependency_sources), + optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies", raw.dependency_sources), conflictSpecs = parseSpecs(conflicts, "conflicts"), category = raw.category or "OTHER", game_version = raw.game_version, diff --git a/src/mods/ModIndex.lua b/src/mods/ModIndex.lua index 3988b9a9..f170d920 100644 --- a/src/mods/ModIndex.lua +++ b/src/mods/ModIndex.lua @@ -334,9 +334,15 @@ function ModIndex.compatIssues(entry, ctx) local function eachSpec(spec, fn) if type(spec) ~= "table" then return end for k, v in pairs(spec) do - if type(k) == "number" and type(v) == "string" then - local id, range = v:match("^([^@]+)@(.+)$") - fn(id or v, range) + if type(k) == "number" then + if type(v) == "table" and type(v.id) == "string" then + fn(v.id, v.range or v.version, v.github or v.repo) + elseif type(v) == "string" then + local main, hashRepo = v:match("^([^#]+)#(.*)$") + if main then v = main end + local id, range = v:match("^([^@]+)@(.+)$") + fn(id or v, range, hashRepo) + end elseif type(k) == "string" then fn(k, type(v) == "string" and v or nil) end diff --git a/tests/mod_manifest_tests.lua b/tests/mod_manifest_tests.lua index f5384339..cf2e5283 100644 --- a/tests/mod_manifest_tests.lua +++ b/tests/mod_manifest_tests.lua @@ -138,7 +138,25 @@ check(not pcall(Manifest.parseGithub, "not a repo"), check(not pcall(Manifest.validate, { id = "badgh", name = "Bad", version = "1.0.0", entry = "main.lua", github = "ftp://example.com/x", -}), "a bad github field fails manifest validation") +}), "an unsupported github URL fails validation") + +-- ------- dependency github repo spec hints & dependency resolver +local depGh = Manifest.validate({ + id = "depgh", name = "DepGH", version = "1.0.0", entry = "main.lua", + dependencies = { "colorlib@^1.2.0#Acme/ColorLib", "soundpack#Acme/SoundPack" }, + dependency_sources = { helper = "Acme/Helper" }, +}) +check(depGh.dependencySpecs[1].id == "colorlib" and depGh.dependencySpecs[1].github == "Acme/ColorLib", + "dependency spec hash hint parses github owner/repo") +check(depGh.dependencySpecs[2].id == "soundpack" and depGh.dependencySpecs[2].github == "Acme/SoundPack", + "dependency spec hash hint without range parses github owner/repo") + +local LauncherMods = require("src.mods.LauncherMods") +local depCheck = LauncherMods.checkDependencies(depGh) +check(depCheck.hasIssues == true, "missing dependencies trigger issues verdict") +check(#depCheck.deps == 2, "dependency check lists all specs") +check(depCheck.deps[1].status == "missing", "absent dependency reports missing") +check(depCheck.deps[1].safeUrl == "https://github.com/Acme/ColorLib", "safeUrl built from validated github repo") local v1 = Manifest.validate({ id = "v1", name = "V1", version = "1.0.0", entry = "main.lua", @@ -519,8 +537,26 @@ local emptyLoader = Loader.new({ fs = memfs({}) }) check(emptyLoader:load(pristine) == true, "an empty mods dir still loads clean") check(#emptyLoader:status().errors == 0, "no mods means no diagnostics") check(#emptyLoader.order == 0, "no mods means an empty load order") -check(pristine.pokemon.A.hp == 1 and next(pristine.items) == nil, - "no-mod load leaves data untouched") +-- ------- dependency resolver conflict detection test +local LauncherMods = require("src.mods.LauncherMods") +local testTargetManifest = Manifest.validate({ + id = "new_mod", + name = "New Mod", + version = "1.0.0", + entry = "main.lua", + incompatible = { "colorlib" }, +}, "mods/new_mod") +local installedColorlib = Manifest.validate({ + id = "colorlib", + name = "Color Lib", + version = "1.0.0", + entry = "main.lua", +}, "mods/colorlib") +local depConflictCheck = LauncherMods.checkDependencies(testTargetManifest, nil, nil, { installedColorlib }) +check(depConflictCheck.hasIssues == true, "conflict with colorlib triggers hasIssues") +check(#depConflictCheck.deps == 1 and depConflictCheck.deps[1].status == "conflict", + "incompatible mod is flagged as status conflict") +check(depConflictCheck.deps[1].kind == "conflict", "conflict item carries kind conflict") Runtime.install(savedEvents, savedHooks) diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 5867b59d..4d9ecaf3 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -1162,6 +1162,18 @@ seedOpts.modProfiles = {} ModProfile.ensureFirst(seedOpts, ms.status.available, {}) check(#seedOpts.modProfiles == 0, "seeding never runs twice") +local LauncherMods = require("src.mods.LauncherMods") +local testProfOpts = { activeProfile = "P1", modProfiles = { { name = "P1", enabled = { a = true } } } } +local dupSnap = LauncherMods.duplicateProfile("P1", testProfOpts) +check(dupSnap and dupSnap.name == "P1 (Copy)" and testProfOpts.activeProfile == "P1 (Copy)", + "duplicateProfile creates P1 (Copy) and activates it") +check(LauncherMods.renameProfile("P1 (Copy)", "RenamedP", testProfOpts) == true, + "renameProfile renames active profile") +check(testProfOpts.activeProfile == "RenamedP", "activeProfile updates on rename") +check(LauncherMods.deleteProfile("RenamedP", testProfOpts) == true, "deleteProfile removes profile") +check(#testProfOpts.modProfiles == 1 and testProfOpts.modProfiles[1].name == "P1", "only original profile remains") +check(testProfOpts.activeProfile == "P1", "activeProfile falls back to remaining profile") + -- permissions rows local permy = manifest("permy", { permissions = { "network" } }) local msP = ManagerState.new(managerGame(fakeLoader({ permy }))) From 35d44efb8b5c993f9ffa523637540c71df3c2496 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Thu, 13 Aug 2026 17:44:45 -0500 Subject: [PATCH 08/13] fixes to the failing test systems --- .gitignore | 3 +++ src/import/CacheFs.lua | 4 ++++ src/import/RomImporter.lua | 2 +- tests/integration/title_checkpoint_cold_start.lua | 9 +++++++-- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 084cdfbb..cbf162dd 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,6 @@ mobile/ios/bundle_id.local /dist/native/ /dist/win/ /.bazinga/ + +# Local options / preferences +/options.lua* diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua index 4c99b048..9284e394 100644 --- a/src/import/CacheFs.lua +++ b/src/import/CacheFs.lua @@ -116,6 +116,8 @@ local physfsMountFn = nil local function resolveMount() if physfsMountFn ~= nil then return physfsMountFn end physfsMountFn = false + if Platform.isUWP() then return physfsMountFn end + if love and love.filesystem and love.filesystem._mounts then return physfsMountFn end local ok, ffi = pcall(require, "ffi") if not ok then return physfsMountFn end pcall(ffi.cdef, @@ -159,6 +161,8 @@ local physfsUnmountFn = nil local function resolveUnmount() if physfsUnmountFn ~= nil then return physfsUnmountFn end physfsUnmountFn = false + if Platform.isUWP() then return physfsUnmountFn end + if love and love.filesystem and love.filesystem._mounts then return physfsUnmountFn end local ok, ffi = pcall(require, "ffi") if not ok then return physfsUnmountFn end pcall(ffi.cdef, "int PHYSFS_unmount(const char *oldDir);") diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 362e0373..d37ae295 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1666,7 +1666,7 @@ function RomImporter:_installMod(source) if not checkTarget and type(res) == "string" then checkTarget = { id = res } end - if checkTarget then + if checkTarget and LauncherMods.checkDependencies then local depCheck = LauncherMods.checkDependencies(checkTarget) if depCheck and depCheck.hasIssues then self._modDepResolver = depCheck diff --git a/tests/integration/title_checkpoint_cold_start.lua b/tests/integration/title_checkpoint_cold_start.lua index cfbf9c92..c16347ce 100644 --- a/tests/integration/title_checkpoint_cold_start.lua +++ b/tests/integration/title_checkpoint_cold_start.lua @@ -8,11 +8,16 @@ local function quote(value) return "'" .. tostring(value):gsub("'", "'\\''") .. "'" end +local function execOk(cmd) + local status = os.execute(cmd) + return status == 0 or status == true +end + local function full(path) return root .. "/" .. path end local fs = {} function fs.createDirectory(path) - return os.execute("mkdir -p " .. quote(full(path))) == 0 + return execOk("mkdir -p " .. quote(full(path))) end function fs.write(path, body) local parent = path:match("^(.*)/[^/]+$") @@ -34,7 +39,7 @@ function fs.remove(path) return true end function fs.getInfo(path) - if os.execute("test -d " .. quote(full(path))) == 0 then + if execOk("test -d " .. quote(full(path))) then return { type = "directory" } end local handle = io.open(full(path), "rb") From 1586aec9f6b221cb6efb9dc4da471bd9c61228c0 Mon Sep 17 00:00:00 2001 From: ShaneMcGovernIE Date: Thu, 13 Aug 2026 23:55:31 +0100 Subject: [PATCH 09/13] =?UTF-8?q?fix(gen2):=20sell=20TMs=20at=20Pok=C3=A9?= =?UTF-8?q?=20Marts=20instead=20of=20opening=20the=20teach=20party?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting a TM in the mart's SELL pack (and the item PC's DEPOSIT pack) opened the teach-party screen: PackMenu:useSelected taught any item carrying `teaches`, even when the pack was built as a chooser (DepositSellPack, world = {}). On the cart that chooser's jumptable is four ScrollingMenus and never reaches tmhm.asm, so the row must hand back to the caller instead. Gate the teach and field-NOUSE branches behind world.useFieldItem so only the real field PACK teaches; choosers now hand TMs to onChoose. Selling then prices a TM at half its ItemAttributes price -- exactly half of what the Goldenrod/Celadon TM shelves charge, and half of the hidden price for the rest, the way SelectQuantityToSell -> GetItemPrice -> Sell_HalvePrice does on the cart. Closes #1243 --- src/ui/gen2/MartMenu.lua | 5 +++++ src/ui/gen2/PackMenu.lua | 27 ++++++++++++++------------ tests/gen2_field_items_test.lua | 21 ++++++++++++++++++++ tests/gen2_menus_test.lua | 34 +++++++++++++++++++++++++++++++++ tests/gen2_pc_screens_test.lua | 19 ++++++++++++++++++ 5 files changed, 94 insertions(+), 12 deletions(-) diff --git a/src/ui/gen2/MartMenu.lua b/src/ui/gen2/MartMenu.lua index acc63b6e..2fff4764 100644 --- a/src/ui/gen2/MartMenu.lua +++ b/src/ui/gen2/MartMenu.lua @@ -780,6 +780,11 @@ function MartMenu:offerToSell(itemId, count) end -- ScrollingMenu's .a_button copies the row's own quantity into -- wItemQuantity, so the selector's ceiling is how many you hold. + -- The unit price is the item's own ItemAttributes price: GetItemPrice is + -- what SelectQuantityToSell halves AND what the buy list's + -- GetMartItemPrice charges, so a TM sells for exactly half of what the + -- Goldenrod/Celadon TM shelves ask for it, and half of its hidden price + -- (usually ¥1,500) anywhere else (issue #1243). self.qtyItem = { id = itemId, name = def.name or itemId, price = def.price or 0 } self.qty = 1 diff --git a/src/ui/gen2/PackMenu.lua b/src/ui/gen2/PackMenu.lua index 7c84f3c4..e4c8d3a4 100644 --- a/src/ui/gen2/PackMenu.lua +++ b/src/ui/gen2/PackMenu.lua @@ -354,20 +354,23 @@ function PackMenu:useSelected() end return end - -- engine/items/tmhm.asm:73 - local def = self.items and self.items[row.id] - if def and def.teaches then - self:openTeachParty(row) - return - end - -- UseItem's jumptable runs off ITEMATTR's field-menu nibble, and the first - -- four entries are all .Oak -- an X ATTACK or a POKé DOLL used from the - -- field PACK prints OakThisIsntTheTimeText and goes nowhere. Only the - -- FIELD pack owns that refusal: the battle pack returned above, and the - -- catch tutorial's DUDE pack carries a stub world with no useFieldItem at - -- all -- its POKE BALL is field-NOUSE and must still reach the throw. + -- UseItem's FIELD-pack tail (engine/items/tmhm.asm:73): a TM/HM row opens + -- the party to teach, and a field-NOUSE item -- an X ATTACK or a POKé DOLL + -- used from the field PACK -- prints OakThisIsntTheTimeText and goes + -- nowhere (UseItem's jumptable's first four entries are all .Oak). Both + -- checks live behind `world.useFieldItem` on purpose: DepositSellPack (the + -- mart's SELL, the item PC's DEPOSIT) is a chooser whose jumptable is four + -- ScrollingMenus and never reaches tmhm.asm, so a TM picked there hands + -- its row to onChoose like every other item instead of opening the teach + -- party. The battle pack returned above, and the catch tutorial's DUDE + -- pack carries a stub world with no useFieldItem at all -- its POKE BALL + -- is field-NOUSE and must still reach the throw. if world and world.useFieldItem then local def = self.items and self.items[row.id] + if def and def.teaches then + self:openTeachParty(row) + return + end if def and def.fieldMenu == "ITEMMENU_NOUSE" then self.message = OAK_THIS_ISNT_THE_TIME return diff --git a/tests/gen2_field_items_test.lua b/tests/gen2_field_items_test.lua index 912e61ad..f7589f06 100644 --- a/tests/gen2_field_items_test.lua +++ b/tests/gen2_field_items_test.lua @@ -549,6 +549,27 @@ do dudePack:useSelected() eq(thrown, "POKE_BALL", "the tutorial pack still throws") eq(dudePack.message, nil, "with no Oak line in the way") + + -- DepositSellPack passes the same empty world (the mart's SELL and the + -- item PC's DEPOSIT both do): a TM row must hand its id back rather than + -- open the teach party, which is issue #1243's "Teach which PKMN?" in + -- the middle of a sale. + local sellGame = { input = newInput(), save = { + player = { name = "GOLD" }, + inventory = { TM01 = 1 }, + options = {}, + }, data = DATA, stack = newStack() } + local sold + local sellPack = PackMenu.new(sellGame, { + save = sellGame.save, items = DATA.items, world = {}, + onChoose = function(id) sold = id end, + }) + sellPack.pocketIndex = 4 -- TM_HM + sellPack:rebuild() + sellPack:useSelected() + eq(sold, "TM01", "a chooser pack hands a TM to its caller") + eq(sellPack.message, nil, "with no teach refusal in the way") + eq(#sellGame.stack._items, 0, "and nothing pushed over the pack") end -- ------------------------------------------------ say(): the teardown rule diff --git a/tests/gen2_menus_test.lua b/tests/gen2_menus_test.lua index 00054183..a6bc6507 100644 --- a/tests/gen2_menus_test.lua +++ b/tests/gen2_menus_test.lua @@ -1481,6 +1481,11 @@ local martItems = { price = 100, canToss = true }, NUGGET = { id = "NUGGET", name = "NUGGET", pocket = "ITEM", index = 36, price = 10000, canToss = true }, + -- A TM carries the move it teaches; the SELL pack must hand it to the + -- mart rather than opening the teach party (issue #1243). + TM_HEADBUTT = { id = "TM_HEADBUTT", name = "TM02", pocket = "TM_HM", + index = 234, price = 2000, canToss = true, teaches = "HEADBUTT", + tmNumber = 2 }, -- Every KEY ITEM carries CANT_TOSS, which is the flag SellMenu's -- _CheckTossableItem actually refuses on. BICYCLE = { id = "BICYCLE", name = "BICYCLE", pocket = "KEY_ITEM", index = 7, @@ -1732,6 +1737,35 @@ sellInput:press("a") sell:update(0) -- YES check("the wallet clamps at MAX_MONEY", sellSave.player.money, 999999) check("and the nugget is gone", sellSave.inventory.NUGGET, nil) +-- A TM is not a key item: DepositSellPack's jumptable is four ScrollingMenus +-- and has no teach arm, so picking a TM from the SELL pack must ask "How +-- many?" and price it at half of its ItemAttributes price -- never open the +-- teach party (issue #1243). +local tmSave = Save.newGame() +tmSave.inventory = { TM_HEADBUTT = 1 } +tmSave.player.money = 0 +local tm, tmInput = newMart(tmSave) +tmInput:press("down") tm:update(0) +tmInput:press("a") tm:update(0) -- SELL +check("SELL builds the pack", tm.pack ~= nil, true) +-- The PACK opens on the ITEM pocket; cross to TM/HM the way the player would. +tm.pack.pocketIndex = 4 +tm.pack:rebuild() +check("the TM is the TM pocket's row", tm.pack.rows[1].id, "TM_HEADBUTT") +tmInput:press("a") tm:update(0) +check("picking a TM asks how many", tm.phase, "sellQuantity") +check("with no teach screen opened", #tm.game.stack._items, 0) +check("and no pack refusal printed", tm.pack.message, nil) +check("at the TM's own price", tm.qtyItem.price, 2000) +check("and the ceiling is what you hold", tm.qtyMax, 1) +tmInput:press("a") tm:update(0) +check("the offer prices half of the TM", tm.confirm.pages[1][2], + "\xc2\xa51000.") +tmInput:press("a") tm:update(0) -- page 2 +tmInput:press("a") tm:update(0) -- YES +check("the TM leaves the bag", tmSave.inventory.TM_HEADBUTT, nil) +check("and the money arrives", tmSave.player.money, 1000) + -- ------------------------------------------------- PlayTransactionSound -- -- engine/items/mart.asm rings SFX_TRANSACTION in exactly two places, both of diff --git a/tests/gen2_pc_screens_test.lua b/tests/gen2_pc_screens_test.lua index 44bec0b3..1c6f30dc 100644 --- a/tests/gen2_pc_screens_test.lua +++ b/tests/gen2_pc_screens_test.lua @@ -94,6 +94,8 @@ local ITEMS = { canToss = false }, HM_CUT = { id = "HM_CUT", name = "HM01", pocket = "TM_HM", index = 0xf3, canToss = false }, + TM_HEADBUTT = { id = "TM_HEADBUTT", name = "TM02", pocket = "TM_HM", + index = 0xea, canToss = true, teaches = "HEADBUTT" }, } local function newGame(save, items) @@ -291,6 +293,23 @@ do eq(pc.phase, "menu", "B drops back to the item PC menu") end +-- The same DepositSellPack chooser holds a TM: it must reach the quantity +-- selector like everything tossable, never the teach party (issue #1243). +do + local save = newSave(1) + local game, input = newGame(save) + Bag.add(save, "TM_HEADBUTT", 1, game.data) + local pc = ItemPcMenu.new(game, { save = save, items = ITEMS }) + press(pc, input, "down", "a") -- DEPOSIT ITEM + press(pc, input, "right", "right", "right") -- ITEM -> BALL -> KEY_ITEM -> TM_HM + press(pc, input, "a") -- choose the TM + check(pc.qtyState ~= nil, "a TM asks how many to deposit") + eq(#game.stack._items, 0, "with no teach screen pushed over the pack") + press(pc, input, "a") -- deposit x1 + eq(save.pcItems.TM_HEADBUTT, 1, "the TM lands in the PC") + eq(save.inventory.TM_HEADBUTT, nil, "and leaves the bag") +end + -- .TryDepositItem's .no_toss: a KEY ITEM stays in the bag, silently. do local save = newSave(1) From dfeacc36b06892d116864e9b72f609ce3e54d4f0 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Thu, 13 Aug 2026 18:18:59 -0500 Subject: [PATCH 10/13] - adding ability to specify gen specific deps in manifest system. - fixed issue with wayland users drag and drop causing CTD --- .gitignore | 3 +- CONTRIBUTING-mods.md | 3 +- docs/mod-api-gen2-compat.md | 9 +-- docs/modding.md | 70 +++++++++++++++++++ docs/preparing-your-mod-for-gen2.md | 19 ++++++ scripts/build.sh | 3 + scripts/linux-arm64/build_appimage.sh | 6 ++ scripts/run.sh | 6 ++ src/mods/LauncherMods.lua | 84 ++++++++++++----------- src/mods/Loader.lua | 96 ++++++++++++++++----------- src/mods/Manifest.lua | 26 +++++++- src/mods/ModTargets.lua | 35 ++++++++++ tests/mod_manifest_tests.lua | 45 +++++++++++-- tools/modkit.py | 15 +++-- 14 files changed, 324 insertions(+), 96 deletions(-) diff --git a/.gitignore b/.gitignore index cbf162dd..69581d85 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,9 @@ data/generated/ assets/generated/ -# LÖVE packages +# LÖVE packages & archives *.love +*.zip # Local saves (LÖVE writes to its save dir, but keep the repo clean anyway) save/ diff --git a/CONTRIBUTING-mods.md b/CONTRIBUTING-mods.md index ccd55ea1..86d5a57f 100644 --- a/CONTRIBUTING-mods.md +++ b/CONTRIBUTING-mods.md @@ -245,7 +245,8 @@ real Gold boot. ### 5. `mod.card` The manifest is the *engine's* contract: identity, load order, dependencies, -permissions, profile. The card is the *human-facing* one: who made this, +permissions, profile (see [Manifest specification](docs/modding.md#manifest-specification-manifestjson)). +The card is the *human-facing* one: who made this, what it changes, what it does not do yet. It is never read by the loader's merge — only by tooling and the manager's detail pane — so an absent or malformed card can never break a load. diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index fbb61d6e..ca93ea76 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -118,10 +118,11 @@ Gen 2 games no longer loads on Red, Blue or Yellow. Say `["all"]` or list both generations if you want both. Two riders. **A hard dependency that does not run here takes the dependent down -with it**, as a skip rather than a failure and carrying the dependency's own -wording (`depends on X, which does not run here (For Blue, not Red)`), so the -whole chain has to cover the same games. And **the claim is yours, not the last -word**: it is the manager's `TRY HERE ANYWAY` row that lets a player run a mod +with it** (unless scoped to specific games, e.g. +`dependencies: [{ id = "x", games = ["gen2"] }]`), as a skip rather than a +failure and carrying the dependency's own wording (`depends on X, which does not +run here (For Blue, not Red)`), so the whole chain has to cover the same games. +And **the claim is yours, not the last word**: it is the manager's `TRY HERE ANYWAY` row that lets a player run a mod whose author never opted in, which is the only route for a mod written before the field existed. The override is per game -- `options.modsGen2[id]` is a `{ [version] = true }` table, so forcing a mod onto Red does not force it onto diff --git a/docs/modding.md b/docs/modding.md index e3a45fa2..d7021a64 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -21,6 +21,76 @@ luajit tools/gen_registry_docs.lua luajit tools/gen_registry_docs.lua ../gen1recomp.wiki ``` +## Manifest specification (`manifest.json`) + +Every mod contains a root `manifest.json` defining its metadata, supported games, and dependencies for the engine loader. + +```json +{ + "id": "my_mod", + "name": "My Cool Mod", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "profile": "content", + "category": "GAMEPLAY", + "games": ["gen1", "gen2"], + "game_version": ">=0.0.0-dev <2.0.0", + "priority": 100, + "dependencies": [ + "helper_lib@^1.0.0", + { "id": "pokegear_cards", "games": ["gen2"], "range": "^1.0.0", "github": "1jamie/pokegear_cards" } + ], + "optional_dependencies": [ + "gen1_modern_ui" + ], + "conflicts": [], + "permissions": ["engine_internals"], + "description": "A brief description of the mod.", + "github": "author/my_mod" +} +``` + +### Manifest Fields + +| Field | Type | Description | +| --- | --- | --- | +| `id` | `string` | Unique identifier (lowercase alphanumeric, underscores, hyphens). | +| `name` | `string` | Human-readable title shown in launcher and manager. | +| `version` | `string` | Semantic version string (e.g. `"1.0.0"`). | +| `api` | `integer` | Mod API level (`2` for current standard, `1` for legacy). | +| `entry` | `string` | Entry Lua file path relative to mod root (usually `"main.lua"`). | +| `profile` | `string` | Mod profile: `"content"`, `"overhaul"`, or `"total_conversion"`. | +| `category` | `string` | Categorization chip (e.g. `"GAMEPLAY"`, `"CONTENT"`, `"UI"`, `"AUDIO"`). | +| `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, or `["all"]`. | +| `game_version`| `string` | Semver range of required engine version (e.g. `">=0.0.0-dev <2.0.0"`). | +| `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). | +| `dependencies` | `array` | Hard required dependencies. A mod will not load if a required dependency is missing or disabled for the active game. | +| `optional_dependencies` | `array` | Soft dependencies. Guarantees that if the target mod is present and active, it loads *before* this mod without blocking load if absent. | +| `conflicts` / `incompatible` | `array` | List of mod IDs that cannot run concurrently with this mod. | +| `permissions` | `array` | Requested privileges (e.g. `["engine_internals"]`, `["network"]`, `["filesystem"]`). | +| `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. | + +### Declaring Dependencies & Scoping + +Dependencies in `dependencies` and `optional_dependencies` can be declared in several formats: + +1. **Simple string**: `"mod_id"` +2. **Version-pinned string**: `"mod_id@^1.2.0"` +3. **Repository-hinted string**: `"mod_id#owner/repo"` or `"mod_id@^1.2.0#owner/repo"` +4. **Structured object**: + ```json + { + "id": "mod_id", + "range": "^1.2.0", + "games": ["gen2"], + "github": "owner/repo" + } + ``` + +#### Version-Scoped Dependencies +When a mod supports multiple games (`"games": ["gen1", "gen2"]`), a dependency can specify `"games": ["gen2"]` to indicate it is only required when booting Gen 2. When booting Gen 1, the engine will ignore the dependency, preventing unnecessary boot blocks on games that do not need it. + ## Mods and Gold (Gen 2) The mod API is one API across both generations, but Gold runs its own battle diff --git a/docs/preparing-your-mod-for-gen2.md b/docs/preparing-your-mod-for-gen2.md index c64ae33b..eb6500cf 100644 --- a/docs/preparing-your-mod-for-gen2.md +++ b/docs/preparing-your-mod-for-gen2.md @@ -258,6 +258,25 @@ row on the detail screen. The launcher's dependency verdict asks the same question of your dependencies: a mod whose hard dependency does not run on the selected game reads `Needs (not for Gold)` rather than `Ready`. +### Scoping dependencies per game / generation + +For mods targeting multiple generations (`"games": ["gen1", "gen2"]`), a hard +dependency can be scoped to specific games so that it is only enforced when +booting those games: + +```json +"dependencies": [ + { "id": "pokegear_cards", "games": ["gen2"], "range": "^1.0.0", "github": "1jamie/pokegear_cards" } +] +``` + +When booting a Gen 1 game (Red, Blue, Yellow), the engine loader sees that +`pokegear_cards` is scoped to `"gen2"` and will not skip or block the parent mod +on Gen 1. When booting Gen 2 (Gold), `pokegear_cards` is strictly required. + +For conditional integrations where the dependency is optional across the board, +`optional_dependencies` remains the standard pattern. + ### One limit worth knowing **Enablement is per game.** The overlay diff --git a/scripts/build.sh b/scripts/build.sh index 5c6f8bc5..761fc375 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -400,6 +400,9 @@ EOF grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \ || fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)" + sed -i '' 's|^exec "\$APPDIR/bin/love"|if [ -n "$WAYLAND_DISPLAY" ] \&\& [ -z "$SDL_VIDEODRIVER" ]; then export SDL_VIDEODRIVER=x11; fi\ +exec "$APPDIR/bin/love"|' "$appdir/AppRun" + # Match the upstream image's compression (gzip, 128K blocks) so the # bundled runtime can read it. local sfs_out="$WORK/game.squashfs" diff --git a/scripts/linux-arm64/build_appimage.sh b/scripts/linux-arm64/build_appimage.sh index ac74892e..7a1da335 100755 --- a/scripts/linux-arm64/build_appimage.sh +++ b/scripts/linux-arm64/build_appimage.sh @@ -405,6 +405,12 @@ if [ -z "\$LUA_CPATH" ]; then fi export LUA_CPATH="\$APPDIR/lib/lua/5.1/?.so;\$LUA_CPATH" +# SDL2 on Wayland crashes during desktop drag-and-drop in certain compositors; +# default to X11/XWayland when available to ensure rock-solid drag-drop stability. +if [ -n "\$WAYLAND_DISPLAY" ] && [ -z "\$SDL_VIDEODRIVER" ]; then + export SDL_VIDEODRIVER=x11 +fi + exec "\$APPDIR/bin/love" --fused "\$APPDIR/game.love" "\$@" EOF chmod +x "$APPDIR/AppRun" diff --git a/scripts/run.sh b/scripts/run.sh index f0110d21..49276927 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -35,4 +35,10 @@ find_love() { LOVE_BIN="$(find_love)" \ || fail "LÖVE not found, run scripts/setup.sh (or install from https://love2d.org)" +# SDL2 on Wayland crashes during desktop drag-and-drop in certain compositors; +# default to X11/XWayland when available to ensure rock-solid drag-drop stability. +if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -z "${SDL_VIDEODRIVER:-}" ]; then + export SDL_VIDEODRIVER=x11 +fi + exec "$LOVE_BIN" "$ROOT" "$@" diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index d0d47bc9..4f7f2ba8 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -87,21 +87,23 @@ local function statusFor(mods, id, enabledSet, enabled, version, forcedFor) -- resolveToggle would cascade-enable a merely-disabled dep rather than flag -- it, so the disabled case is judged straight off the manifest here. for _, spec in ipairs(m.dependencySpecs or {}) do - local dep = mods[spec.id] - if not dep then - return "warn", "Needs " .. spec.id .. " (not installed)" - elseif not enabledSet[spec.id] then - return "warn", "Needs " .. spec.id .. " (disabled)" - -- installed and on, but not for THIS game: the loader skips the - -- dependency and the skip is contagious (Loader:_enforceDependencies), - -- so a mod that runs everywhere still does not run here - elseif version - and not ModTargets.runsHere(dep, version, nil, forcedFor(spec.id)) then - return "warn", "Needs " .. spec.id .. " (not for " - .. ModTargets.gameLabel(version) .. ")" - elseif spec.range - and not Semver.satisfies(dep.version, spec.range) then - return "warn", "Needs " .. spec.id .. " " .. spec.range + if ModTargets.specApplies(spec, version) then + local dep = mods[spec.id] + if not dep then + return "warn", "Needs " .. spec.id .. " (not installed)" + elseif not enabledSet[spec.id] then + return "warn", "Needs " .. spec.id .. " (disabled)" + -- installed and on, but not for THIS game: the loader skips the + -- dependency and the skip is contagious (Loader:_enforceDependencies), + -- so a mod that runs everywhere still does not run here + elseif version + and not ModTargets.runsHere(dep, version, nil, forcedFor(spec.id)) then + return "warn", "Needs " .. spec.id .. " (not for " + .. ModTargets.gameLabel(version) .. ")" + elseif spec.range + and not Semver.satisfies(dep.version, spec.range) then + return "warn", "Needs " .. spec.id .. " " .. spec.range + end end end return "ok", "Ready" @@ -168,33 +170,35 @@ function LauncherMods.checkDependencies(manifest, options, version, installedMan -- 1. Hard Dependencies (dependencySpecs) if type(manifest.dependencySpecs) == "table" then for _, spec in ipairs(manifest.dependencySpecs) do - local depId = spec.id - local range = spec.range - local installedDep = installedMap[depId] - local status = "satisfied" - local installedVersion = installedDep and installedDep.version or nil + if not version or ModTargets.specApplies(spec, version) then + local depId = spec.id + local range = spec.range + local installedDep = installedMap[depId] + local status = "satisfied" + local installedVersion = installedDep and installedDep.version or nil - if not installedDep then - status = "missing" - hasIssues = true - elseif range and not Semver.satisfies(installedDep.version, range) then - status = "incompatible" - hasIssues = true + if not installedDep then + status = "missing" + hasIssues = true + elseif range and not Semver.satisfies(installedDep.version, range) then + status = "incompatible" + hasIssues = true + end + + local ghRepo = LauncherMods.resolveDependencyRepo(depId, manifest, installedDep) + local safeUrl = ghRepo and ("https://github.com/" .. ghRepo) or nil + + depsResult[#depsResult + 1] = { + id = depId, + name = (installedDep and installedDep.name) or depId, + range = range, + status = status, + kind = "dependency", + installedVersion = installedVersion, + github = ghRepo, + safeUrl = safeUrl, + } end - - local ghRepo = LauncherMods.resolveDependencyRepo(depId, manifest, installedDep) - local safeUrl = ghRepo and ("https://github.com/" .. ghRepo) or nil - - depsResult[#depsResult + 1] = { - id = depId, - name = (installedDep and installedDep.name) or depId, - range = range, - status = status, - kind = "dependency", - installedVersion = installedVersion, - github = ghRepo, - safeUrl = safeUrl, - } end end diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index f1b1561c..e36d127d 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -561,42 +561,46 @@ end -- hard dependencies must exist, be enabled, have survived, and satisfy their -- range; run to a fixpoint so failures propagate to dependents transitively function Loader:_enforceDependencies() + local targetVersion = self:_targetVersion() + local generation = self.generation local changed = true while changed do changed = false for _, id in ipairs(orderedIds(self.mods, isActive)) do local mod = self.mods[id] for _, spec in ipairs(mod.manifest.dependencySpecs) do - local dep = self.mods[spec.id] - local reason, skip - if not dep then - reason = "missing dependency: " .. spec.id - elseif not dep.enabled then - reason = ("dependency %s is disabled"):format(spec.id) - elseif dep.state == "wrong_generation" then - -- the gate's skip is contagious as a SKIP, not as a failure: the - -- dependency has no bug to report and neither does this mod, so - -- nothing here lands on the boot error list - skip = true - -- carry the dependency's own reason: it names the game or the - -- missing gen2compat, and a guess here would name the wrong one - reason = ("depends on %s, which does not run here (%s)") - :format(spec.id, dep.skipReason or "not made for this game") - elseif dep.failed then - reason = ("dependency %s failed to load"):format(spec.id) - elseif spec.range - and not Semver.satisfies(dep.manifest.version, spec.range) then - reason = ("needs %s@%s, found %s") - :format(spec.id, spec.range, dep.manifest.version) - end - if reason then - if skip then - self:_skip(mod, "wrong_generation", reason) - else - self:_fail(mod, "blocked_dependency", reason) + if ModTargets.specApplies(spec, targetVersion, generation) then + local dep = self.mods[spec.id] + local reason, skip + if not dep then + reason = "missing dependency: " .. spec.id + elseif not dep.enabled then + reason = ("dependency %s is disabled"):format(spec.id) + elseif dep.state == "wrong_generation" then + -- the gate's skip is contagious as a SKIP, not as a failure: the + -- dependency has no bug to report and neither does this mod, so + -- nothing here lands on the boot error list + skip = true + -- carry the dependency's own reason: it names the game or the + -- missing gen2compat, and a guess here would name the wrong one + reason = ("depends on %s, which does not run here (%s)") + :format(spec.id, dep.skipReason or "not made for this game") + elseif dep.failed then + reason = ("dependency %s failed to load"):format(spec.id) + elseif spec.range + and not Semver.satisfies(dep.manifest.version, spec.range) then + reason = ("needs %s@%s, found %s") + :format(spec.id, spec.range, dep.manifest.version) + end + if reason then + if skip then + self:_skip(mod, "wrong_generation", reason) + else + self:_fail(mod, "blocked_dependency", reason) + end + changed = true + break end - changed = true - break end end end @@ -606,6 +610,8 @@ end -- Tarjan SCC over the hard-dependency graph: only a cycle's own members -- fail, so an unrelated mod beside a cycle still loads function Loader:_failCycles() + local targetVersion = self:_targetVersion() + local generation = self.generation local mods = self.mods local counter, stack, onStack, index, low = 0, {}, {}, {}, {} local cycles = {} @@ -616,14 +622,16 @@ function Loader:_failCycles() onStack[id] = true local selfEdge = false for _, spec in ipairs(mods[id].manifest.dependencySpecs) do - local dep = mods[spec.id] - if spec.id == id then selfEdge = true end - if dep and isActive(dep) and spec.id ~= id then - if not index[spec.id] then - connect(spec.id) - if low[spec.id] < low[id] then low[id] = low[spec.id] end - elseif onStack[spec.id] and index[spec.id] < low[id] then - low[id] = index[spec.id] + if ModTargets.specApplies(spec, targetVersion, generation) then + local dep = mods[spec.id] + if spec.id == id then selfEdge = true end + if dep and isActive(dep) and spec.id ~= id then + if not index[spec.id] then + connect(spec.id) + if low[spec.id] < low[id] then low[id] = low[spec.id] end + elseif onStack[spec.id] and index[spec.id] < low[id] then + low[id] = index[spec.id] + end end end end @@ -674,6 +682,8 @@ end -- Kahn over the surviving graph with the ready set kept in (priority, id) -- order, so dependencies come first and the rest matches the v1 contract function Loader:_order() + local targetVersion = self:_targetVersion() + local generation = self.generation local pending, indegree, dependents = {}, {}, {} for _, id in ipairs(orderedIds(self.mods, isActive)) do pending[id], indegree[id] = true, 0 @@ -686,9 +696,17 @@ function Loader:_order() dependents[depId][#dependents[depId] + 1] = id indegree[id] = indegree[id] + 1 end - for _, spec in ipairs(manifest.dependencySpecs) do edge(spec.id) end + for _, spec in ipairs(manifest.dependencySpecs) do + if ModTargets.specApplies(spec, targetVersion, generation) then + edge(spec.id) + end + end -- optional dependencies order without requiring anything - for _, spec in ipairs(manifest.optionalSpecs) do edge(spec.id) end + for _, spec in ipairs(manifest.optionalSpecs) do + if ModTargets.specApplies(spec, targetVersion, generation) then + edge(spec.id) + end + end end local ordered = {} local function nextId() diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index e142bff2..3e601c5d 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -58,11 +58,13 @@ local function parseSpecs(list, field, sources) local specs = {} sources = type(sources) == "table" and sources or {} for _, entry in ipairs(list) do - local id, range, ghHint + local id, range, ghHint, gamesRaw, gameVersion if type(entry) == "table" then id = entry.id range = entry.range or entry.version ghHint = entry.github or entry.repo + gamesRaw = entry.games or entry.game + gameVersion = entry.game_version elseif type(entry) == "string" and entry ~= "" then local main, hashRepo = entry:match("^([^#]+)#(.*)$") if main then @@ -82,6 +84,20 @@ local function parseSpecs(list, field, sources) local ok, err = Semver.validRange(range) assert(ok, ("malformed %s range in %q: %s"):format(field, tostring(entry), tostring(err))) + if gameVersion then + local okV, errV = Semver.validRange(gameVersion) + assert(okV, ("malformed %s game_version range in %q: %s"):format(field, tostring(entry), tostring(errV))) + end + + local parsedGames = nil + if gamesRaw ~= nil then + if type(gamesRaw) == "string" then gamesRaw = { gamesRaw } end + assert(type(gamesRaw) == "table", "dependency games must be a string or table") + local normalized, unknown = ModTargets.normalize(gamesRaw) + assert(#unknown == 0, ("unknown game in %s: %s"):format(field, table.concat(unknown, ", "))) + parsedGames = normalized + end + local parsedGh = nil local rawGh = ghHint or sources[id] if rawGh then @@ -89,7 +105,13 @@ local function parseSpecs(list, field, sources) if okGh and cleanGh then parsedGh = cleanGh end end - specs[#specs + 1] = { id = id, range = range, github = parsedGh } + specs[#specs + 1] = { + id = id, + range = range, + github = parsedGh, + games = parsedGames, + game_version = gameVersion, + } end return specs end diff --git a/src/mods/ModTargets.lua b/src/mods/ModTargets.lua index 58c7d05e..b367c0cb 100644 --- a/src/mods/ModTargets.lua +++ b/src/mods/ModTargets.lua @@ -168,4 +168,39 @@ function ModTargets.detail(manifest, version) ModTargets.gameLabel(version)) end +-- Does a dependency spec apply to this game / version? +-- If spec.games is provided, it must match version / generation. +-- If spec.game_version is provided, the engine version must satisfy it. +function ModTargets.specApplies(spec, version, generation) + if not spec then return false end + if type(spec.games) == "table" and #spec.games > 0 then + if version or generation then + local match = false + for _, id in ipairs(spec.games) do + if version and id == version then + match = true + break + end + if generation and GameVersion.generation(id) == generation then + match = true + break + end + end + if not match then return false end + end + end + if spec.game_version then + local ok = pcall(function() + local Semver = require("src.mods.Semver") + local Version = require("src.core.Version") + if Version and Version.engine and Version.engine:match("^0%.0%.0%-") == nil then + return Semver.satisfies(Version.engine, spec.game_version) + end + return true + end) + if not ok then return false end + end + return true +end + return ModTargets diff --git a/tests/mod_manifest_tests.lua b/tests/mod_manifest_tests.lua index cf2e5283..9b0d9948 100644 --- a/tests/mod_manifest_tests.lua +++ b/tests/mod_manifest_tests.lua @@ -552,11 +552,46 @@ local installedColorlib = Manifest.validate({ version = "1.0.0", entry = "main.lua", }, "mods/colorlib") -local depConflictCheck = LauncherMods.checkDependencies(testTargetManifest, nil, nil, { installedColorlib }) -check(depConflictCheck.hasIssues == true, "conflict with colorlib triggers hasIssues") -check(#depConflictCheck.deps == 1 and depConflictCheck.deps[1].status == "conflict", - "incompatible mod is flagged as status conflict") -check(depConflictCheck.deps[1].kind == "conflict", "conflict item carries kind conflict") +-- ------- scoped dependency tests +local Json = require("src.link.Json") +local scopedDepManifest = Manifest.validate({ + id = "dual_gen_mod", + name = "Dual Gen Mod", + version = "1.0.0", + entry = "main.lua", + games = { "gen1", "gen2" }, + dependencies = { + { id = "gen2_only_dep", games = { "gen2" }, version = "^1.0.0" } + }, +}, "mods/dual_gen_mod") +check(#scopedDepManifest.dependencySpecs == 1, "scoped dependency parsed") +check(scopedDepManifest.dependencySpecs[1].games ~= nil, "dependency carries games list") + +local dualGenFiles = { + ["mods/dual_gen_mod/manifest.json"] = Json.encode({ + id = "dual_gen_mod", + name = "Dual Gen Mod", + version = "1.0.0", + entry = "main.lua", + games = { "gen1", "gen2" }, + dependencies = { + { id = "gen2_only_dep", games = { "gen2" } } + }, + }), + ["mods/dual_gen_mod/main.lua"] = [[ +return function(mod) + mod.content.pokemon:register("DUAL_MON", { hp = 100 }) +end +]], +} +local gen1Loader = Loader.new({ fs = memfs(dualGenFiles), generation = 1 }) +check(gen1Loader:load({}) == true, "dual gen mod loads on Gen 1 when Gen 2 dep is absent") +check(gen1Loader.content.pokemon:get("DUAL_MON") ~= nil, "dual gen mod executed on Gen 1") + +local gen2Loader = Loader.new({ fs = memfs(dualGenFiles), generation = 2 }) +check(gen2Loader:load({}) == false, "loader returns false on Gen 2 when missing required Gen 2 dep") +check(gen2Loader.content.pokemon:get("DUAL_MON") == nil, "dual gen mod is blocked on Gen 2 when missing required Gen 2 dep") +check(#gen2Loader:status().errors > 0, "missing dependency error logged on Gen 2") Runtime.install(savedEvents, savedHooks) diff --git a/tools/modkit.py b/tools/modkit.py index b52138e2..42fc5acd 100644 --- a/tools/modkit.py +++ b/tools/modkit.py @@ -2989,16 +2989,23 @@ def check_gen2_manifest(repo, mod_dir, manifest, named): "manifest.json")) deps = manifest.get("dependencies") or [] for dep in deps if isinstance(deps, list) else []: - if not isinstance(dep, str): + dep_id = dep if isinstance(dep, str) else dep.get("id") if isinstance(dep, dict) else None + if not dep_id: continue - found = named.get(dep) or find_mod_by_id(repo, mod_dir, dep) + if isinstance(dep, dict) and "games" in dep: + g_list = dep.get("games") + if isinstance(g_list, str): + g_list = [g_list] + if isinstance(g_list, list) and not any(g in ["gen2", "gold", "silver", "crystal", "all"] for g in g_list): + continue + found = named.get(dep_id) or find_mod_by_id(repo, mod_dir, dep_id) if found is None: notes.append("unresolved: dependency %s is not installed beside " - "this mod, so its games list could not be read" % dep) + "this mod, so its games list could not be read" % dep_id) elif not declares_gen2(repo, found): findings.append(Finding( "MK401", "error", - f"depends on {dep}, which claims no Gen 2 game; the " + f"depends on {dep_id}, which claims no Gen 2 game; the " f"loader disables a mod whose dependency a Gen 2 boot skipped", "manifest.json")) return findings, notes From 7e57e3174e1f737185c59d1a2b0da088ae0cf016 Mon Sep 17 00:00:00 2001 From: Solidus Snake <63137482+TheRealSolidusSnake@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:38:04 -0400 Subject: [PATCH 11/13] Ship gen1tls.dll in Windows release zips. TLS source landed earlier, but release CI never packaged the dialer, so hosted archipelago.gg rooms (wss://) failed on stock Windows builds. Build the Native AOT DLL on windows-2022 and bundle it from build.sh. Co-authored-by: Cursor --- .github/workflows/release.yml | 48 ++++++++++++++++++++++++++++++++++- native/tls_dial/README.md | 4 +++ scripts/build.sh | 15 +++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7116a65f..30ba4d89 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -252,8 +252,40 @@ jobs: Remove-Item $env:UWP_PFX -Force -ErrorAction SilentlyContinue } + # Windows Native AOT TLS dialer. The Mac release runner fuses the win64 zip + # from LÖVE's prebuilt binaries and cannot cross-compile this DLL, so build + # it here and inject it in the release job before scripts/build.sh win. + native-tls-win: + name: build Windows gen1tls.dll + needs: version + runs-on: windows-2022 + steps: + - uses: actions/checkout@v7 + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + - name: Publish gen1tls (win-x64 Native AOT) + shell: pwsh + run: | + $out = "dist/native/win-x64" + New-Item -ItemType Directory -Force -Path $out | Out-Null + dotnet publish native/tls_dial/Gen1Tls.csproj ` + -c Release -r win-x64 -o $out + if (-not (Test-Path "$out/gen1tls.dll")) { + throw "gen1tls.dll missing after publish" + } + Get-Item "$out/gen1tls.dll" | Format-List Name, Length, LastWriteTime + - name: Upload gen1tls.dll + uses: actions/upload-artifact@v7 + with: + name: gen1tls-win-x64 + path: dist/native/win-x64/gen1tls.dll + if-no-files-found: error + retention-days: 1 + release: - needs: [version, xbox-uwp, linux-arm64] + needs: [version, xbox-uwp, linux-arm64, native-tls-win] runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }} steps: @@ -269,6 +301,12 @@ jobs: fetch-depth: 0 fetch-tags: true + - name: Download Windows gen1tls dialer + uses: actions/download-artifact@v8 + with: + name: gen1tls-win-x64 + path: dist/native/win-x64 + - name: Import signing certificate into a temporary keychain if: github.repository == 'bryanthaboi/gen1recomp' run: | @@ -307,13 +345,21 @@ jobs: security find-identity -v -p codesigning "$KEYCHAIN_PATH" - name: Build macOS + Windows + Linux + env: + GEN1TLS_DLL: ${{ github.workspace }}/dist/native/win-x64/gen1tls.dll run: | set -euo pipefail # Sign in-build (identity auto-detected from the temp keychain); # notarize separately below so it uses secret credentials, not a # login-keychain profile. "all" also builds the Linux AppImage, # which needs no signing/notarization. + if [ ! -f "$GEN1TLS_DLL" ]; then + echo "::error::gen1tls.dll missing at $GEN1TLS_DLL (native-tls-win job)" + exit 1 + fi scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize + unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \ + || { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; } - name: Build Android run: | diff --git a/native/tls_dial/README.md b/native/tls_dial/README.md index 13a1540c..141d6505 100644 --- a/native/tls_dial/README.md +++ b/native/tls_dial/README.md @@ -23,6 +23,10 @@ Ship `gen1tls.dll` (or `libgen1tls.so` / `libgen1tls.dylib`) **next to** the fused executable. Mods load it through LuaJIT FFI; no .NET runtime is required on the player's machine. +Official Windows release zips get `gen1tls.dll` from CI: a `windows-2022` +job publishes this project and `scripts/build.sh`'s Windows packaging copies +it beside `gen1recomp.exe` (see `GEN1TLS_DLL` / `dist/native/win-x64`). + ## Android On Android, the matching API is exposed as `love.system.tlsOpen` / diff --git a/scripts/build.sh b/scripts/build.sh index 5c6f8bc5..f7b934fa 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -261,6 +261,21 @@ build_win() { cp "$love_dir"/*.dll "$out_dir"/ cp "$love_dir"/license.txt "$out_dir"/ 2>/dev/null || true + # Native AOT TLS dialer for outbound wss:// (e.g. Archipelago hosted rooms). + # Release CI builds this on windows-2022 (Native AOT can't cross-compile + # win-x64 from the Mac runner) and either exports GEN1TLS_DLL or drops the + # file at dist/native/win-x64/gen1tls.dll before calling build.sh. + local tls_dll="${GEN1TLS_DLL:-}" + if [ -z "$tls_dll" ] && [ -f "$DIST/native/win-x64/gen1tls.dll" ]; then + tls_dll="$DIST/native/win-x64/gen1tls.dll" + fi + if [ -n "$tls_dll" ] && [ -f "$tls_dll" ]; then + cp "$tls_dll" "$out_dir/gen1tls.dll" + say "bundled gen1tls.dll for Windows TLS (wss://)" + else + warn "gen1tls.dll not found — Windows zip will not support wss:// (set GEN1TLS_DLL or build native/tls_dial)" + fi + # The exe's icon lives in love.exe's PE resources, so it must be patched # BEFORE the .love is appended: peresed rewrites the whole file and would # drop the fused bytes. peresed (pipx install pe_tools) has no .ico input, From 2010ba71a6eb5b7f6f1eb821dfaf582d4e0a4e94 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 14 Aug 2026 07:20:48 -0400 Subject: [PATCH 12/13] better hover --- src/import/LauncherView.lua | 109 ++++++++++++++++++++++++++++++++++-- 1 file changed, 105 insertions(+), 4 deletions(-) diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 1257f16c..957305ff 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -370,6 +370,7 @@ local function cartProject(cx, cy, yaw, pitch, x, y, z) end local function cartPolygon(points, color, alpha) + if not love.graphics.polygon then return end local flat = {} for i = 1, #points do flat[#flat + 1], flat[#flat + 2] = points[i][1], points[i][2] @@ -423,6 +424,55 @@ local function cartLabelMesh(imp, version, label, points) return mesh end +local CART_HOVER_SHADER = [[ +extern vec2 mouse_screen_pos; +extern float hovering; +extern float screen_scale; + +#ifdef VERTEX +vec4 position(mat4 transform_projection, vec4 vertex_position) { + if (hovering <= 0.) { + return transform_projection * vertex_position; + } + float mid_dist = length(vertex_position.xy - 0.5 * love_ScreenSize.xy) + / length(love_ScreenSize.xy); + vec2 mouse_offset = (vertex_position.xy - mouse_screen_pos.xy) / screen_scale; + float scale = 0.2 * (-0.03 - 0.3 * max(0., 0.3 - mid_dist)) + * hovering * (length(mouse_offset) * length(mouse_offset)) / (2. - mid_dist); + return transform_projection * vertex_position + vec4(0.0, 0.0, 0.0, scale); +} +#endif + +#ifdef PIXEL +vec4 effect(vec4 color, Image tex, vec2 texture_coords, vec2 screen_coords) { + return Texel(tex, texture_coords) * color; +} +#endif +]] + +local function cartHoverShader(imp) + if imp._cartHoverShader ~= nil then + return imp._cartHoverShader or nil + end + if not (love.graphics and love.graphics.newShader) then + imp._cartHoverShader = false + return nil + end + local ok, sh = pcall(love.graphics.newShader, CART_HOVER_SHADER) + imp._cartHoverShader = ok and sh or false + return imp._cartHoverShader or nil +end + +local function cartSendHover(shader, mx, my, hovering, screenScale) + if not shader or not shader.send then return false end + local ok = pcall(function() + shader:send("mouse_screen_pos", { mx, my }) + shader:send("hovering", hovering) + shader:send("screen_scale", screenScale) + end) + return ok +end + local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action) local state = cartridgeState(imp, version) local focused = Kit.focusable(key, x, y, w, h) @@ -474,13 +524,50 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action) end local pointerX = clamp((Kit.mouseX - cx) / math.max(1, w / 2), -1, 1) local pointerY = clamp((Kit.mouseY - cy) / math.max(1, h / 2), -1, 1) - local hoverTilt = hot and pointerX * 0.12 or math.sin(Kit.time * 0.75) * 0.035 + local hoverFx = hot or focused + if hoverFx and not state.wasHot then + state.juiceStart = Kit.time + state.juiceScaleAmt = 0.02 + state.juiceRAmt = (math.random() > 0.5 and 1 or -1) * 0.012 + state.visScale = 1 - 0.6 * 0.02 + end + state.wasHot = hoverFx + local juiceScale, juiceR = 0, 0 + if state.juiceStart then + local juiceT = Kit.time - state.juiceStart + if juiceT >= 0.4 then + state.juiceStart = nil + else + local remain = (0.4 - juiceT) / 0.4 + juiceScale = state.juiceScaleAmt * math.sin(50.8 * juiceT) * remain ^ 3 + juiceR = state.juiceRAmt * math.sin(40.8 * juiceT) * remain ^ 2 + end + end + state.visScale = state.visScale or 1 + local desScale = (hoverFx and 1.05 or 1) + juiceScale + local ease = math.exp(-60 * dt) + state.visScale = ease * state.visScale + (1 - ease) * desScale + if not state.animId then + local n, s = 0, tostring(version) + for i = 1, #s do n = n + s:byte(i) * i end + state.animId = n + end + local hoverMx, hoverMy + if hot then + hoverMx, hoverMy = Kit.mouseX, Kit.mouseY + elseif focused then + hoverMx, hoverMy = cx, cy + else + local tiltAngle = Kit.time * (1.56 + (state.animId / 1.14212) % 1) + + state.animId / 1.35122 + hoverMx = x + (0.5 + 0.1 * math.cos(tiltAngle)) * w + hoverMy = y + (0.5 + 0.1 * math.sin(tiltAngle)) * h + end local pressX = active and pointerX * w * 0.025 or 0 local pressY = active and pointerY * h * 0.018 or 0 - local yaw = -0.42 + state.spin + hoverTilt + local yaw = -0.42 + state.spin local pitch = 0.14 + (state.pitchDrag or 0) - + (hot and pointerY * 0.08 or math.sin(Kit.time * 0.6) * 0.018) - local pressedScale = active and 0.965 or 1 + local pressedScale = (active and 0.965 or 1) * state.visScale Kit._audit("control", x, y, w, h, key) if focused then @@ -494,6 +581,19 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action) return cartProject(cx + pressX, cy + pressY, yaw, pitch, px * pressedScale, py * pressedScale, pz * pressedScale) end + local shader = cartHoverShader(imp) + local useHover = shader + and cartSendHover(shader, hoverMx, hoverMy, 1, + math.max(1, 0.4 * math.min(w, h))) + if not useHover then + yaw = yaw + (hoverMx - cx) / math.max(1, w / 2) * 0.08 + pitch = pitch + (hoverMy - cy) / math.max(1, h / 2) * 0.05 + end + love.graphics.push("all") + love.graphics.translate(cx, cy) + love.graphics.rotate(juiceR * 2) + love.graphics.translate(-cx, -cy) + if useHover then love.graphics.setShader(shader) end local capH = h * 3 / 65 local mainTop = -halfH + capH @@ -553,6 +653,7 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action) { project(w * 0.07, h * 0.37, faceZ + 1) }, { project(0, h * 0.43, faceZ + 1) }, }, side, 0.70) + love.graphics.pop() if not state.active and (Kit._activateId == key) then queueAction(imp, key, action) From a94fecfec8c8a853cb20cca0d97446d933387e8d Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 14 Aug 2026 10:05:51 -0400 Subject: [PATCH 13/13] Closes #919, closes #982, closes #1003, closes #1012, closes #1022, closes #1028, closes #1033 --- data/scripts/celadon_eevee.lua | 2 +- data/scripts/flavor/game_corner.lua | 11 +- data/scripts/flavor/museum_1f.lua | 9 +- data/scripts/oaks_lab.lua | 12 +- data/scripts/oaks_lab_yellow.lua | 10 +- data/scripts/story.lua | 2 + data/scripts/story2.lua | 8 +- data/scripts/story4.lua | 18 +- data/scripts/story5.lua | 15 +- data/scripts/victories.lua | 21 ++ docs/new-features.md | 2 +- src/battle/BattleState.lua | 188 ++++++++++------ src/battle/WideBattle.lua | 2 +- src/core/BattleCheckpoint.lua | 6 + src/import/LauncherView.lua | 186 +++++----------- src/link/LinkBattle.lua | 131 ++++++++++- src/link/LinkState.lua | 104 +++++++-- src/pokemon/Evolution.lua | 6 +- src/render/HudTiles.lua | 11 +- src/render/PaletteFX.lua | 10 +- src/render/TextBox.lua | 20 ++ src/script/Commands.lua | 17 +- src/ui/BagMenu.lua | 13 +- src/ui/EvolutionState.lua | 30 ++- src/ui/IntroMovie.lua | 19 +- src/ui/PartyMenu.lua | 123 ++++++++--- src/ui/kit/Kit.lua | 206 +++++++++++------- src/ui/kit/Theme.lua | 115 ++++++++-- src/world/NPC.lua | 20 +- src/world/OverworldController.lua | 170 +++++++++------ src/world/PikachuFollower.lua | 11 +- tests/drivers/evolution_black_bug279_test.lua | 6 +- .../drivers/evolution_cancel_bug213_test.lua | 8 +- tests/drivers/evolution_move_bug12_test.lua | 6 +- tests/drivers/trainer_reset_bug1028_test.lua | 188 ++++++++++++++++ tests/engine/party_fieldmove_order_bug792.lua | 11 +- tests/engine/viridian_fisher_pre_bug775.lua | 1 + tests/link_desync_fuzz.lua | 7 + tests/link_tournament16.lua | 7 + tests/mod_battle_tests.lua | 2 +- tests/mod_ui_tests.lua | 4 +- tests/modkit/link.lua | 10 + tests/parity_A.lua | 15 +- tests/parity_I_M.lua | 18 +- tests/parity_gym_tm_bag_full_bug797.lua | 16 +- tests/parity_yellow_bills_pikachu.lua | 1 + tests/parity_yellow_disabled_pikachu.lua | 2 +- tests/run_tests.lua | 4 +- 48 files changed, 1263 insertions(+), 541 deletions(-) create mode 100644 tests/drivers/trainer_reset_bug1028_test.lua diff --git a/data/scripts/celadon_eevee.lua b/data/scripts/celadon_eevee.lua index 55d0627d..4b6a78de 100644 --- a/data/scripts/celadon_eevee.lua +++ b/data/scripts/celadon_eevee.lua @@ -92,7 +92,7 @@ return { { "set_flag", "EVENT_GOT_EEVEE" }, -- 7 { "hide_object", "CELADON_MANSION_ROOF_HOUSE", "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8 - { "play_sound", "Get_Item1" }, -- 9 (GotMonText jingle) + { "text_sound", "Get_Item1" }, -- 9 (GotMonText jingle) { "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10 { "jump", 13 }, -- 11 { "show_text", "_BoxIsFullText" }, -- 12 diff --git a/data/scripts/flavor/game_corner.lua b/data/scripts/flavor/game_corner.lua index 59738245..82ee7002 100644 --- a/data/scripts/flavor/game_corner.lua +++ b/data/scripts/flavor/game_corner.lua @@ -10,10 +10,10 @@ local function coinGiver(opts) return function(game, ow, npc, done) local TextBox = require("src.render.TextBox") - local Sound = require("src.core.Sound") local t = game.data.text - local function push(label, fallback, onDone) - game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done)) + local function push(label, fallback, onDone, popts) + game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done, + popts)) end if game.save.flags[opts.event] then push(opts.alreadyGotLabel, opts.alreadyGotFallback) @@ -30,9 +30,10 @@ local function coinGiver(opts) end game.save.coins = math.min(9999, (game.save.coins or 0) + opts.amount) game.save.flags[opts.event] = true - Sound.play(game.data, "Get_Item1") + -- the ReceivedNCoinsText strings carry sound_get_item_1 push(opts.receivedLabel, - ("{PLAYER} received\n%d coins!"):format(opts.amount)) + ("{PLAYER} received\n%d coins!"):format(opts.amount), nil, + TextBox.soundOpts(game, "Get_Item1")) end) end end diff --git a/data/scripts/flavor/museum_1f.lua b/data/scripts/flavor/museum_1f.lua index cd706e1a..21bbb2a2 100644 --- a/data/scripts/flavor/museum_1f.lua +++ b/data/scripts/flavor/museum_1f.lua @@ -22,8 +22,8 @@ return { local TextBox = require("src.render.TextBox") local Commands = require("src.script.Commands") local t = game.data.text - local function say(label, cb) - game.stack:push(TextBox.new(game, t[label] or label, cb)) + local function say(label, cb, sopts) + game.stack:push(TextBox.new(game, t[label] or label, cb, sopts)) end if game.save.flags.EVENT_GOT_OLD_AMBER then @@ -39,8 +39,9 @@ return { game.save.flags.EVENT_GOT_OLD_AMBER = true Commands.hide_object({ save = game.save, overworld = ow, game = game }, "MUSEUM_1F", "MUSEUM1F_OLD_AMBER") - require("src.core.Sound").play(game.data, "Get_Item1") - say("_Museum1FScientist2ReceivedOldAmberText", done) + -- .ReceivedOldAmberText carries sound_get_item_1 + say("_Museum1FScientist2ReceivedOldAmberText", done, + TextBox.soundOpts(game, "Get_Item1")) end) end, diff --git a/data/scripts/oaks_lab.lua b/data/scripts/oaks_lab.lua index cfce77cb..55cb3316 100644 --- a/data/scripts/oaks_lab.lua +++ b/data/scripts/oaks_lab.lua @@ -38,9 +38,9 @@ local function starterBall(askText, species, choseFlag, ownBall, -- inside give_pokemon). Show the received text first so the -- nickname prompt follows "you got X", matching Gen1. -- The received text carries sound_get_key_item (OaksLab.asm - -- OaksLabReceivedMonText); the jingle plays as the box opens - -- (same beat as the Yellow port's starter, #668). - { "play_sound", "Get_Key_Item" }, -- 8 + -- OaksLabReceivedMonText), so the jingle fires once the box has + -- typed out and holds it (same beat as the Yellow port's starter, #668). + { "text_sound", "Get_Key_Item" }, -- 8 { "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 9 { "give_pokemon", species, 5 }, -- 10 { "set_flag", "EVENT_GOT_STARTER" }, -- 11 @@ -54,7 +54,7 @@ local function starterBall(askText, species, choseFlag, ownBall, { "face_object", 1, "up" }, -- 15 { "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 16 { "hide_object", "OAKS_LAB", rivalBall }, -- 17 - { "play_sound", "Get_Key_Item" }, -- 18 (sound_get_key_item) + { "text_sound", "Get_Key_Item" }, -- 18 (sound_get_key_item) { "show_text", "_OaksLabRivalReceivedMonText", { RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER" or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE" @@ -106,8 +106,8 @@ return { { "check_item", "OAKS_PARCEL" }, { "jump_if_false", "raise_young" }, -- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex + { "text_sound", "Get_Key_Item" }, { "show_text", "_OaksLabOak1DeliverParcelText" }, - { "play_sound", "Get_Key_Item" }, { "show_text", "_OaksLabOak1ParcelThanksText" }, { "take_item", "OAKS_PARCEL", 1 }, { "stop_music" }, @@ -128,8 +128,8 @@ return { { "face_object", 1, "up" }, { "face_object", 5, "down" }, { "show_text", "_OaksLabOakMyInventionPokedexText" }, + { "text_sound", "Get_Key_Item" }, { "show_text", "_OaksLabOakGotPokedexText" }, - { "play_sound", "Get_Key_Item" }, { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" }, { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" }, { "face_object", 1, "up" }, diff --git a/data/scripts/oaks_lab_yellow.lua b/data/scripts/oaks_lab_yellow.lua index e5cacf8a..6c67a80f 100644 --- a/data/scripts/oaks_lab_yellow.lua +++ b/data/scripts/oaks_lab_yellow.lua @@ -38,8 +38,8 @@ return { { "jump_if_false", "raise_young" }, -- .DeliverParcelText: parcel handover, then the Pokédex scene -- (OaksLabRivalArrivesAtOaksRequestScript -> OakGivesPokedexScript) + { "text_sound", "Get_Key_Item" }, { "show_text", "_OaksLabOak1DeliverParcelText" }, - { "play_sound", "Get_Key_Item" }, { "show_text", "_OaksLabOak1ParcelThanksText" }, { "take_item", "OAKS_PARCEL", 1 }, { "stop_music" }, @@ -62,8 +62,8 @@ return { { "face_object", RIVAL, "up" }, { "face_object", OAK1, "down" }, { "show_text", "_OaksLabOakMyInventionPokedexText" }, + { "text_sound", "Get_Key_Item" }, { "show_text", "_OaksLabOakGotPokedexText" }, - { "play_sound", "Get_Key_Item" }, { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" }, { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" }, { "face_object", RIVAL, "up" }, @@ -113,8 +113,8 @@ return { { "jump_if_true", "come_see" }, { "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, { "give_item", "POKE_BALL", 5, false }, + { "text_sound", "Get_Key_Item" }, { "show_text", "_OaksLabOak1ReceivedPokeballsText" }, - { "play_sound", "Get_Key_Item" }, { "show_text", "_OaksLabGivePokeballsExplanationText" }, { "jump", "end" }, @@ -176,7 +176,7 @@ return { -- rival starter baseline (RIVAL_STARTER_JOLTEON) at snatch time rows[#rows + 1] = { "set_field", "rivalStarter", 1 } rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText1" } - rows[#rows + 1] = { "play_sound", "Get_Key_Item" } + rows[#rows + 1] = { "text_sound", "Get_Key_Item" } rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText2" } rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText3" } rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText4" } @@ -196,7 +196,7 @@ return { rows[#rows + 1] = { "face_object", OAK1, "down" } -- OaksLabPlayerReceivedMonText clears wMonDataLocation, so AskName runs (#1013) rows[#rows + 1] = { "show_text", "_OaksLabOakGivesText" } - rows[#rows + 1] = { "play_sound", "Get_Key_Item" } + rows[#rows + 1] = { "text_sound", "Get_Key_Item" } rows[#rows + 1] = { "show_text", "_OaksLabReceivedText", { RAM = "PIKACHU" } } rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5 } -- DisablePikachuOverworldSpriteDrawing keeps it in the ball (#1009) diff --git a/data/scripts/story.lua b/data/scripts/story.lua index adbf2e6d..2941ac58 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -297,6 +297,7 @@ M.BILLS_HOUSE = { M.ROUTE_25 = { onEnter = function(game, ow) + game.save.pikachuMapScriptActive = nil local flags = game.save.flags if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end local Commands = require("src.script.Commands") @@ -329,6 +330,7 @@ M.VERMILION_CITY = { -- only read while EVENT_1ST_LOCK_OPENED is unset (the gym is only -- reachable through this map, so a fresh visit always re-rolls). onEnter = function(game, ow) + game.save.pikachuMapScriptActive = nil local puz = game.save.trashPuzzle or {} game.save.trashPuzzle = puz puz.first = love.math.random(0, 7) * 2 diff --git a/data/scripts/story2.lua b/data/scripts/story2.lua index 887a683e..aa8fb23d 100644 --- a/data/scripts/story2.lua +++ b/data/scripts/story2.lua @@ -648,8 +648,10 @@ local function mtMoonFossil(itemId, otherName, gotFlag) end local idef = game.data.items[itemId] game.stringBuffer = idef and idef.name or itemId - require("src.core.Sound").play(game.data, "Get_Key_Item") local dirs = mtMoonNerdWalk(ow.player.cellX, ow.player.cellY, itemId) + -- MtMoonB2FReceivedFossilText: text_far, sound_get_key_item, + -- text_waitbutton -- the jingle plays after the box has typed and + -- the button wait comes after it game.stack:push(TextBox.new(game, t._MtMoonB2FReceivedFossilText or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"), @@ -662,11 +664,11 @@ local function mtMoonFossil(itemId, otherName, gotFlag) ow.runner:run({ { "walk_npc", 1, dirs }, { "text_opts", { auto = true } }, + { "text_sound", "Get_Key_Item" }, { "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" }, - { "play_sound", "Get_Key_Item" }, { "hide_object", "MT_MOON_B2F", otherName }, }, { onDone = done }) - end)) + end, TextBox.soundOpts(game, "Get_Key_Item"))) end })) end end diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index b8c55163..769309e6 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -8,9 +8,9 @@ local M = {} local function text(game) return game.data.text end -local function push(game, s, done) +local function push(game, s, done, opts) local TextBox = require("src.render.TextBox") - game.stack:push(TextBox.new(game, s, done)) + game.stack:push(TextBox.new(game, s, done, opts)) end -- The question stays on screen under the YES/NO menu. The dojo prize @@ -263,7 +263,7 @@ M.SILPH_CO_7F = { { "jump_if_false", "box_full" }, -- flag ahead of the jingle, like the Celadon EEVEE (#426) { "set_flag", "EVENT_GOT_LAPRAS" }, - { "play_sound", "Get_Item1" }, + { "text_sound", "Get_Item1" }, { "show_text", "_GotMonText", { RAM = "LAPRAS" } }, { "show_text", "_SilphCo7FSilphWorkerM1LaprasDescriptionText" }, { "jump", "end" }, @@ -307,12 +307,11 @@ M.COPYCATS_HOUSE_2F = { return end game.stringBuffer = game.data.items.TM_MIMIC.name - require("src.core.Sound").play(game.data, "Get_Item1") Bag.remove(game.save, "POKE_DOLL", 1) game.save.flags.EVENT_GOT_TM31 = true push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function() push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done) - end) + end, require("src.render.TextBox").soundOpts(game, "Get_Item1")) end) end) end, @@ -478,7 +477,6 @@ M.CELADON_MART_ROOF = { return end game.save.flags[g.flag] = true - require("src.core.Sound").play(game.data, "Get_Item1") local subs = { player = game.save.player.name, ram = game.data.items[g.tm].name } local explain = fill(t[g.explain] or "", subs) @@ -490,7 +488,7 @@ M.CELADON_MART_ROOF = { else done() end - end) + end, require("src.render.TextBox").soundOpts(game, "Get_Item1")) end) end, onCancel = done, @@ -522,7 +520,6 @@ M.ROUTE_24 = { local t = text(game) push(game, t._Route24CooltrainerM1YouBeatOurContestText .. "\f" .. t._Route24CooltrainerM1YouJustEarnedAPrizeText, function() - require("src.core.Sound").play(game.data, "Get_Item1") if not require("src.inventory.Bag").add(game.save, "NUGGET", 1, game.data) then push(game, t._Route24CooltrainerM1NoRoomText, done) @@ -531,11 +528,10 @@ M.ROUTE_24 = { flags.EVENT_GOT_NUGGET = true game.stringBuffer = game.data.items.NUGGET.name push(game, t._Route24CooltrainerM1ReceivedNuggetText, function() - require("src.core.Sound").play(game.data, "Get_Item1") push(game, t._Route24CooltrainerM1JoinTeamRocketText, battleOrDone) - end) - end) + end, require("src.render.TextBox").soundOpts(game, "Get_Item1")) + end, require("src.render.TextBox").soundOpts(game, "Get_Item1")) return end battleOrDone() diff --git a/data/scripts/story5.lua b/data/scripts/story5.lua index 8aefafe3..aaba5178 100644 --- a/data/scripts/story5.lua +++ b/data/scripts/story5.lua @@ -4,9 +4,9 @@ local M = {} local function text(game) return game.data.text end -local function push(game, s, done) +local function push(game, s, done, opts) local TextBox = require("src.render.TextBox") - game.stack:push(TextBox.new(game, s, done)) + game.stack:push(TextBox.new(game, s, done, opts)) end -- fill the extracted text placeholders ({RAM:...}, {PLAYER}) @@ -25,8 +25,8 @@ local function gift(opts) local t = text(game) local itemName = game.data.items[opts.item].name local subs = { ram = itemName, player = game.save.player.name } - local function say(label, fallback, cb) - push(game, fill(t[label] or fallback, subs), cb) + local function say(label, fallback, cb, sopts) + push(game, fill(t[label] or fallback, subs), cb, sopts) end if game.save.flags[opts.flag] then say(opts.already or opts.explain, "It's a useful\nitem, isn't it?", done) @@ -39,15 +39,16 @@ local function gift(opts) end game.save.flags[opts.flag] = true local idef = game.data.items[opts.item] - require("src.core.Sound").play(game.data, - (idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1") + -- the received texts carry sound_get_item_1 / sound_get_key_item, so + -- the jingle only fires once that box has typed out say(opts.received, "{PLAYER} received\n{RAM:}!", function() if opts.explain then say(opts.explain, "", done) else done() end - end) + end, require("src.render.TextBox").soundOpts(game, + (idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1")) end if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end end diff --git a/data/scripts/victories.lua b/data/scripts/victories.lua index dc71e796..32e10c24 100644 --- a/data/scripts/victories.lua +++ b/data/scripts/victories.lua @@ -28,6 +28,14 @@ -- is full; `gotFlag` (pokered's EVENT_GOT_TM*) is set only on a -- successful give, which is what makes the leader's talk script retry -- later (gyms.lua). +-- +-- `badgeSound` / `tmSound` are the text sound command each gym's reward +-- text carries right after its FIRST label -- home/text.asm TextCommand_SOUND +-- plays it once that page has typed out and then blocks on +-- WaitForSoundToFinish, so the jingle sits between the pages rather than +-- under them. macros/scripts/text.asm defines sound_level_up as +-- sound_get_item_1, so Pewter's and Viridian's badge lines are Get_Item1 +-- too. Vermilion, Celadon and Fuchsia carry no sound on the badge text. local function range(prefix, first, last) local t = {} @@ -50,6 +58,8 @@ return { { "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" }, { "ROUTE_22", "ROUTE22_RIVAL1" }, }, + badgeSound = "Get_Item1", -- sound_level_up + tmSound = "Get_Item1", dialogue = { "_PewterGymBrockReceivedBoulderBadgeText", "_PewterGymBrockBoulderBadgeInfoText", @@ -64,6 +74,8 @@ return { gotFlag = "EVENT_GOT_TM11", noRoom = "_CeruleanGymMistyTM11NoRoomText", deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1), + badgeSound = "Get_Key_Item", + tmSound = "Get_Item1", dialogue = { "_CeruleanGymMistyReceivedCascadeBadgeText", }, @@ -76,6 +88,7 @@ return { gotFlag = "EVENT_GOT_TM24", noRoom = "_VermilionGymLTSurgeTM24NoRoomText", deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2), + tmSound = "Get_Key_Item", dialogue = { "_VermilionGymLTSurgeReceivedThunderBadgeText", }, @@ -89,6 +102,7 @@ return { gotFlag = "EVENT_GOT_TM21", noRoom = "_CeladonGymTM21NoRoomText", deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6), + tmSound = "Get_Item1", dialogue = { "_CeladonGymErikaReceivedRainbowBadgeText", }, @@ -102,6 +116,7 @@ return { gotFlag = "EVENT_GOT_TM06", noRoom = "_FuchsiaGymKogaTM06NoRoomText", deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5), + tmSound = "Get_Key_Item", dialogue = { "_FuchsiaGymKogaReceivedSoulBadgeText", }, @@ -115,6 +130,8 @@ return { gotFlag = "EVENT_GOT_TM46", noRoom = "_SaffronGymSabrinaTM46NoRoomText", deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6), + badgeSound = "Get_Key_Item", + tmSound = "Get_Item1", dialogue = { "_SaffronGymSabrinaReceivedMarshBadgeText", }, @@ -128,6 +145,8 @@ return { gotFlag = "EVENT_GOT_TM38", noRoom = "_CinnabarGymBlaineTM38NoRoomText", deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6), + badgeSound = "Get_Key_Item", + tmSound = "Get_Item1", dialogue = { "_CinnabarGymBlaineReceivedVolcanoBadgeText", }, @@ -141,6 +160,8 @@ return { gotFlag = "EVENT_GOT_TM27", noRoom = "_ViridianGymGiovanniTM27NoRoomText", deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7), + badgeSound = "Get_Item1", -- sound_level_up + tmSound = "Get_Item1", dialogue = { "_ViridianGymGiovanniReceivedEarthBadgeText", }, diff --git a/docs/new-features.md b/docs/new-features.md index 54d7fda8..80c47172 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -26,7 +26,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow ## Pokémon Gold (Gen 2) -A fourth game the launcher can import and play, built from pret/pokegold the same way Red/Blue/Yellow are built from pokered. Port extras beyond the cartridge: +A fourth game the launcher can import and play, built from pret/pokegold the same way Red/Blue/Yellow are built from pokered (and pokeyellow). Port extras beyond the cartridge: * **COLOR, zoom, tilt, GBC FX, and quick save/load** * **UI that stays fixed while the overworld zooms** diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 679e2518..2a23d96c 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -501,6 +501,9 @@ local function makeBattler(data, mon, isPlayer, save) badgeBoosts = badgeBoosts, statuses = data.statuses, shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain) + -- the bar's own length in GetHPBarLength pixels; it trails shownHP + -- because UpdateHPBar_AnimateHPBar slides it one pixel at a time + shownPx = Timing.hpBarPixels(mon.hp, math.max(1, mon.stats.hp)), -- HUD status label (DrawHUDsAndHPBars); mon.status can land mid-move -- while the tilemap still shows the prior condition until the next -- post-action HUD refresh (core.asm after Execute*Move) @@ -563,10 +566,6 @@ local function markOwned(game, species) local dex = game.save.pokedex if dex then dex.seen[species] = true - if not dex.owned[species] then - -- new dex page registered (SFX_DEX_PAGE_ADDED) - require("src.core.Sound").play(game.data, "Dex_Page_Added") - end dex.owned[species] = true end end @@ -935,6 +934,17 @@ function BattleState:sayNextWaitSfx(text, sfx) table.insert(self.queue, self.nextInsert, { text = text, waitForLearningSfx = sfx }) end +-- WaitForSoundToFinish for a sound an act() has already started: PlayCry +-- ends in `jp WaitForSoundToFinish` (home/pokemon.asm), so every cry in +-- Gen 1 holds whatever the ROM does next. `src` is the audio source, or a +-- getter the row calls at execution time when the source is only known then. +function BattleState:waitSfxNext(src) + self.nextInsert = (self.nextInsert or 0) + 1 + table.insert(self.queue, self.nextInsert, + { waitSound = type(src) == "function" and src + or function() return src end }) +end + -- sayNext for a page that ends in `text_end` (see sayAuto) (#765) function BattleState:sayNextAuto(text, delay) self.nextInsert = (self.nextInsert or 0) + 1 @@ -1022,22 +1032,39 @@ function BattleState:stepHPDrain() and b.shownHP >= b.drainFloor then goal = b.drainFloor end + local maxHP = math.max(1, b.mon.stats.hp) + local playerSide = (b == self.player) + local targetPx = Timing.hpBarPixels(b.shownHP, maxHP) + if not b.shownPx then b.shownPx = targetPx end if (b.drainHold or 0) > 0 then b.drainHold = b.drainHold - 1 busy = true + elseif b.shownPx ~= targetPx then + -- .barAnimationLoop redraws the bar one pixel at a time, `ld c, 2 / + -- call DelayFrames` apiece (:141-148), so a single HP point that + -- spans several pixels still slides instead of jumping + b.shownPx = b.shownPx + ((b.shownPx > targetPx) and -1 or 1) + b.drainHold = Timing.HP_BAR_PIXEL_STEP - 1 + busy = true elseif b.shownHP ~= goal then - local maxHP = math.max(1, b.mon.stats.hp) - local playerSide = (b == self.player) - local cost = 0 + local spent = 0 -- consume whole HP steps until this frame's budget is spent; on the -- enemy HUD several free steps can land in the same frame - while b.shownHP ~= goal and cost < 1 do - local nextHP = b.shownHP + ((b.shownHP > goal) and -1 or 1) - cost = cost + Timing.hpDrainStepFrames(b.shownHP, nextHP, - maxHP, playerSide) - b.shownHP = nextHP + repeat + b.shownHP = b.shownHP + ((b.shownHP > goal) and -1 or 1) + spent = spent + (playerSide and Timing.HP_BAR_HP_STEP or 0) + targetPx = Timing.hpBarPixels(b.shownHP, maxHP) + until b.shownHP == goal or targetPx ~= b.shownPx or spent >= 1 + if spent > 0 then + b.drainHold = spent - 1 + elseif targetPx ~= b.shownPx then + -- the enemy HUD printed no number, so this frame is already the + -- first of the pixel step the crossing just asked for + b.shownPx = b.shownPx + ((b.shownPx > targetPx) and -1 or 1) + b.drainHold = Timing.HP_BAR_PIXEL_STEP - 1 + else + b.drainHold = 0 end - b.drainHold = math.max(0, cost - 1) b.draining = true busy = true elseif b.draining then @@ -1431,7 +1458,7 @@ end function BattleState:playEntranceCry(battler) local mon = battler and battler.mon if not mon then return end - require("src.core.Sound").playCry(self.data, mon.species, + return require("src.core.Sound").playCry(self.data, mon.species, mon.status == "SLP" and 37 or 11) end @@ -1555,7 +1582,23 @@ function BattleState:enter() -- default stays opaque for every other battle and for older saves. self.isOpaque = self:bgMode() ~= "world" self.introSlide = Timing.BATTLE_SLIDE_IN_FRAMES - self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil + -- GetTrainerInformation .linkBattle (home/trainers2.asm:26-31): the link + -- foe's pic is RedPicFront whatever either save looks like, and + -- InitBattleCommon loads and tilemaps it at hlcoord 12,0 for every + -- wIsInBattle == 2 battle (core.asm:6681-6688), the link one included. + -- wEnemyMonSpecies2 is zeroed under it, so the pic palette is PAL_MEWMON + -- exactly as it is for a trainer. + if self.kind == "link" and not self.trainerPic then + local frontPath, frontTrueColor = + require("src.pokemon.Sprites").playerPath(self.data, "front", + { kind = "battle", battle = self }) + if frontPath and require("src.render.Assets").exists(frontPath) then + self.trainerPic = getImage(frontPath, namedPalette(self.data, "MEWMON"), + frontTrueColor) + end + end + self.showEnemyTrainer = (self.kind == "trainer" or self.kind == "link") + and self.trainerPic ~= nil -- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the -- HUD corner/underline tiles under them (PlacePlayerHUDTiles / -- PlaceEnemyHUDTiles, draw_hud_pokeball_gfx.asm:119-165) on screen with @@ -1587,13 +1630,13 @@ function BattleState:enter() -- a different point in each battle kind, so queue it per branch local function queueEnemyCry() self:act(function() - self:playEntranceCry(self.enemy) + self:waitSfxNext(self:playEntranceCry(self.enemy)) end) end -- PrintBeginningBattleText (engine/battle/common_text.asm:10-19): a wild - -- battle calls PlayCry BEFORE PrintText WildMonAppearedText, so the cry - -- sounds with the "Wild X appeared!" box instead of waiting on the A - -- press that clears its `prompt` (#303). The Silph-Scope-less tower + -- battle calls PlayCry BEFORE PrintText WildMonAppearedText, and PlayCry + -- ends in WaitForSoundToFinish, so the cry runs to its end and only then + -- does the "Wild X appeared!" box open (#303). The Silph-Scope-less tower -- ghost gets no cry at all (common_text.asm:43-48), and neither does the -- unveiled MAROWAK: .isMarowak never reaches PlayCry (#492). if self.kind ~= "trainer" and self.kind ~= "link" @@ -1611,7 +1654,10 @@ function BattleState:enter() -- The sfx is extracted as "Trainer_Appeared" (tools/rom_manifest.json -- sfxHeaders, bank 8 / $42bb -- the same header pokered names -- SFX_Silph_Scope); nothing had ever played it. - if self.kind == "trainer" then + -- + -- A link battle is wIsInBattle == 2, so PrintBeginningBattleText takes the + -- same .trainerBattle arm and owes the sfx too (common_text.asm:20-23). + if self.kind == "trainer" or self.kind == "link" then self:act(function() self.introSfx = require("src.core.Sound").play(self.data, "Trainer_Appeared") @@ -1629,13 +1675,17 @@ function BattleState:enter() -- battle -- not on a switch, and not when the beaten trainer's pic -- scrolls back in (#317, #282) self:act(function() self.introBalls = nil end) - if self.kind == "trainer" then - -- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen - -- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles, - -- one tile every 2 frames) BEFORE TrainerSentOutText -- the pic does - -- not blink out under the text (#317) - self:act(function() self:slidePic("foe", 0, 64, 4) end) - table.insert(self.queue, { wait = 16 }) + if self.kind == "trainer" or self.kind == "link" then + local foeName = self.trainer and self.trainer.name + or self.opponentName or Strings("FOE") + if self.showEnemyTrainer then + -- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen + -- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles, + -- one tile every 2 frames) BEFORE TrainerSentOutText -- the pic does + -- not blink out under the text (#317) + self:act(function() self:slidePic("foe", 0, 64, 4) end) + table.insert(self.queue, { wait = 16 }) + end self:act(function() self.showEnemyTrainer = false -- the slot is EMPTY from here until AnimateSendingOutMon runs below: @@ -1648,7 +1698,7 @@ function BattleState:enter() self.enemySendingOut = true self:slidePic("foe") end) - self:say(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name)) + self:say(Strings("%s sent\nout %s!", foeName, self.enemy.name)) self:act(function() -- EnemySendOutFirstMon (core.asm:1421-1434): after the text the -- pic grows out of the ball (AnimateSendingOutMon), then the cry @@ -1656,18 +1706,6 @@ function BattleState:enter() self:startGrowIn(self.enemy) end) queueEnemyCry() - elseif self.kind == "link" then - -- Colosseum has no foe trainer pic, but the enemy mon still grows - -- out of the ball after "X sent out Y!" (not the wild "already there" - -- intro that LinkBattle previously inherited from newWild). - self.enemySendingOut = true - self:say(Strings("%s sent\nout %s!", self.opponentName or Strings("FOE"), - self.enemy.name)) - self:act(function() - self.enemySendingOut = false - self:startGrowIn(self.enemy) - end) - queueEnemyCry() end -- StartBattle .foundFirstAliveEnemyMon (core.asm:152-156): the `call nz` -- gates only EnemySendOutFirstMon -- the `ld c, 40 / call DelayFrames` @@ -1698,7 +1736,7 @@ function BattleState:enter() -- SendOutMon (core.asm:1757-1762): after the poof the mon grows -- out of the ball (AnimateSendingOutMon at hlcoord 4,11) self:startGrowIn(self.player) - self:playEntranceCry(self.player) + self:waitSfxNext(self:playEntranceCry(self.player)) end) self:markParticipant() end @@ -1876,7 +1914,10 @@ function BattleState:update(dt) if self.phase == "menu" then for _, b in ipairs({ self.player, self.enemy }) do if b then - if b.shownHP then b.shownHP = b.mon.hp end + if b.shownHP then + b.shownHP = b.mon.hp + b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp)) + end b.drainFloor = nil b.shownStatus = b.mon.status end @@ -2294,10 +2335,10 @@ function BattleState:oldManThrow() return end self:ballChain("TOSS_ANIM", true, 3, "POKE_BALL") - self:actNext(function() - require("src.core.Sound").play(self.data, "Caught_Mon") - end) - self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name)) + -- ItemUseBallText05: text_far, sound_caught_mon, text_promptbutton -- + -- the fanfare follows the caught text and holds the prompt + self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name), + function() return require("src.core.Sound").play(self.data, "Caught_Mon") end) end) end @@ -2481,7 +2522,7 @@ function BattleState:resolveSwitch(newMon) self.sendingOut = false -- SendOutMon (core.asm:1757-1762): poof, then the grow-in self:startGrowIn(self.player) - self:playEntranceCry(self.player) + self:waitSfxNext(self:playEntranceCry(self.player)) end) end) self:act(function() @@ -3793,7 +3834,7 @@ function BattleState:onFaint(battler) if battler.isPlayer then -- RemoveFaintedPlayerMon (core.asm:1040-1042): the player mon's -- faint plays its ordinary species cry -- no Faint_Fall - Sound.playCry(self.data, battler.mon.species) + self.faintCry = Sound.playCry(self.data, battler.mon.species) elseif self.kind ~= "wild" then -- FaintEnemyPokemon (core.asm:732-771): the enemy faint plays no -- species cry; trainer battles get SFX_FAINT_FALL, then SFX_FAINT_THUD @@ -3808,6 +3849,11 @@ function BattleState:onFaint(battler) end) self.nextInsert = (self.nextInsert or 0) + 1 table.insert(self.queue, self.nextInsert, { wait = Timing.FAINT_SLIDE }) + if battler.isPlayer then + -- RemoveFaintedPlayerMon ends `call PlayCry / jp PrintText`, and PlayCry + -- is `jp WaitForSoundToFinish`, so "X fainted!" waits out the cry + self:waitSfxNext(function() return self.faintCry end) + end if not battler.isPlayer and self.kind ~= "wild" then -- FaintEnemyPokemon's SFX_FAINT_THUD lands as the slide does (after -- Faint_Fall, before EnemyMonFaintedText) @@ -3895,9 +3941,11 @@ function BattleState:awardExp() -- experience.asm:248 fires per grew-level text require("src.world.PikachuFollower") .modifyHappiness(game.save, "LEVELUP", mon) - self:sayNext(Strings("%s grew\nto level %d!", name, lv)) + -- GrewLevelText: text_far, sound_level_up, text_end (experience.asm: + -- 369-372); PrintStatsBox only runs once PrintText has returned + self:sayNextWaitSfx(Strings("%s grew\nto level %d!", name, lv), + function() return require("src.core.Sound").play(game.data, "Level_Up") end) self:uiNext(function() - require("src.core.Sound").play(game.data, "Level_Up") return StatBox.new(game, mon) end) -- After PrintStatsBox, experience.asm reloads the active battler's @@ -4042,7 +4090,7 @@ function BattleState:enemyMonFainted() self.enemySendingOut = false self:startGrowIn(self.enemy) self:actNext(function() - self:playEntranceCry(self.enemy) + self:waitSfxNext(self:playEntranceCry(self.enemy)) end) end) end) @@ -4081,7 +4129,7 @@ function BattleState:enemyMonFainted() self:actNext(function() self.sendingOut = false self:startGrowIn(self.player) - self:playEntranceCry(self.player) + self:waitSfxNext(self:playEntranceCry(self.player)) end) end) return @@ -4277,7 +4325,7 @@ function BattleState:openReplacementMenu() self.sendingOut = false -- SendOutMon (core.asm:1757-1762): poof, then the grow-in self:startGrowIn(self.player) - self:playEntranceCry(self.player) + self:waitSfxNext(self:playEntranceCry(self.player)) end) end, }) @@ -4321,11 +4369,10 @@ function BattleState:safariAction(choice) -- above DoBallTossSpecialEffects's <= ULTRA_BALL check) self:ballChain(self:tossAnimFor("SAFARI_BALL"), caught, shakes, "SAFARI_BALL") if caught then - -- ItemUseBallText05's sound_caught_mon: fanfare with the text - self:actNext(function() - require("src.core.Sound").play(self.data, "Caught_Mon") - end) - self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name)) + -- ItemUseBallText05: text_far, sound_caught_mon, text_promptbutton -- + -- the fanfare follows the caught text and holds the prompt + self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name), + function() return require("src.core.Sound").play(self.data, "Caught_Mon") end) -- same ItemUseBall .captured flow as a regular ball self:act(function() self:storeCaughtMon() end) else @@ -4553,8 +4600,12 @@ function BattleState:storeCaughtMon() markOwned(game, species) stampOT(game.save, self.enemy.mon) if isNew then - -- _ItemUseBallText06 + ShowPokedexData - self:sayNext(Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name)) + -- _ItemUseBallText06 + ShowPokedexData: text_far, sound_dex_page_added, + -- text_promptbutton (item_effects.asm:624-629), so the fanfare follows + -- the box rather than firing when the dex bit is set + self:sayNextWaitSfx( + Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name), + function() return require("src.core.Sound").play(self.data, "Dex_Page_Added") end) self:uiNext(function() return self:buildScreen("DexEntryMenu", species) end) @@ -4704,12 +4755,10 @@ function BattleState:throwBall(ball) self:ballChain(self:tossAnimFor(ball), caught, shakes, ball) if caught then -- ItemUseBallText05 carries sound_caught_mon (item_effects.asm: - -- 608-614): the fanfare sounds with the caught message, before - -- the prompt, not after the text is dismissed - self:actNext(function() - require("src.core.Sound").play(self.data, "Caught_Mon") - end) - self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name)) + -- 608-614): text_far, sound_caught_mon, text_promptbutton -- the + -- fanfare follows the caught message and holds the prompt + self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name), + function() return require("src.core.Sound").play(self.data, "Caught_Mon") end) self:act(function() self:storeCaughtMon() end) else self:sayNext(self:ballMissMessage(shakes)) @@ -5196,7 +5245,8 @@ function BattleState:sgbBattlePals() local function bar(b) if not b then return pals.GREENBAR end local hp = b.shownHP or b.mon.hp - return pals[PaletteFX.barPalName(hp, b.mon.stats.hp)] or pals.GREENBAR + return pals[PaletteFX.barPalName(hp, b.mon.stats.hp, b.shownPx)] + or pals.GREENBAR end local function mon(b, placeholder) if placeholder or not b then return pals.MEWMON or pals.GREENBAR end @@ -5594,7 +5644,7 @@ function BattleState:drawHUDs(slide) hudTile(0x73, 8, 16) drawHPBar(barData, 2, 2, { hp = shownHP(self.enemy), stats = self.enemy.mon.stats }, - nil, grayFill) + nil, grayFill, nil, self.enemy.shownPx) hudTile(0x74, 8, 24) for i = 2, 9 do hudTile(0x76, i * 8, 24) end hudTile(0x78, 80, 24) @@ -5675,7 +5725,7 @@ function BattleState:drawHUDs(slide) end drawHPBar(barData, 10, 9, { hp = shownHP(self.player), stats = self.player.mon.stats }, - 1, grayFill) -- wHPBarType 1: the $6D cap + 1, grayFill, nil, self.player.shownPx) -- wHPBarType 1: the $6D cap Font.draw(("%3d/%3d"):format(shownHP(self.player), self.player.mon.stats.hp), 88, 80) hudTile(0x73, 144, 80) hudTile(0x77, 144, 88) diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index 87e43bd2..abe3ef7a 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -108,7 +108,7 @@ local function drawStatusPanel(battle, battler, x, y, player) HudTiles.drawHPBar(battle.data, tx + 1, ty + 2, { hp = shownHP(battler), stats = battler.mon.stats, - }, nil, monoMode(), tw - 5) + }, nil, monoMode(), tw - 5, battler.shownPx) if player then Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp), diff --git a/src/core/BattleCheckpoint.lua b/src/core/BattleCheckpoint.lua index 67698662..c3319ab2 100644 --- a/src/core/BattleCheckpoint.lua +++ b/src/core/BattleCheckpoint.lua @@ -334,6 +334,12 @@ function BattleCheckpoint.restore(game, checkpoint, copy) battle.player.mon.hp, battle.player.mon.status battle.enemy.shownHP, battle.enemy.shownStatus = battle.enemy.mon.hp, battle.enemy.mon.status + -- the bar's own pixel length is derived HUD state, not captured: it + -- settles with shownHP above (UpdateHPBar_AnimateHPBar's end position) + local Timing = require("src.core.Timing") + for _, b in ipairs({ battle.player, battle.enemy }) do + b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp)) + end local ow = game.overworld if not ow or type(ow.restoreBattleContinuation) ~= "function" diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 957305ff..725c013c 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -289,38 +289,6 @@ local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, ac end end --- A square icon control: the header's gear and quit, and the game panel's --- manage button. Inverts to a solid white fill when hot, the same signal --- every other control here uses, and rounds to the shared control radius. --- `image` draws a texture; `drawFn(x, y, size, hot)` draws a hand-rolled --- glyph (the quit X, which ships no asset). -local function iconButton(imp, key, x, y, size, image, action, drawFn) - Kit._audit("control", x, y, size, size, key) - local focused = Kit.focusable(key, x, y, size, size) - local hot = focused or Kit.hover(x, y, size, size) - Theme.fillRounded(x, y, size, size, hot and PAL.ink or PAL.surface, 1) - Theme.strokeRounded(x, y, size, size, PAL.line, - hot and Theme.A.focus or Theme.A.hairline, 1) - if image then - local iw, ih = image:getDimensions() - local pad = math.floor(size * 0.24) - local s = math.min((size - 2 * pad) / iw, (size - 2 * pad) / ih) - if hot then love.graphics.setColor(0, 0, 0, 1) - else love.graphics.setColor(1, 1, 1, 0.85) end - love.graphics.draw(image, Theme.snap(x + (size - iw * s) / 2), - Theme.snap(y + (size - ih * s) / 2), 0, s, s) - love.graphics.setColor(1, 1, 1, 1) - elseif drawFn then - drawFn(x, y, size, hot) - end - if action and (Kit.press(x, y, size, size) or Kit._activateId == key) then - queueAction(imp, key, action) - end -end - --- The cartridge colour for a game, matching its tab in the header. Play --- wears it, so "which game is this button going to boot" is answered before --- the label is read. Unknown versions fall back to the commit green. local CART_COLOR = { red = PAL.railRed, blue = PAL.railBlue, yellow = PAL.railGold, gold = PAL.railAmber, @@ -687,7 +655,7 @@ local function buildModScopeRow(imp, x, y, w, m) end -- Dedicated Profile control section (cycle button + gear icon button) on right side of Scope Bar - local profiles, activeProf = LauncherMods.getProfiles() + local _, activeProf = LauncherMods.getProfiles() local isCompact = (w < math.floor(500 * m.s)) local nameText = tostring(activeProf or "Default") local profLabel = isCompact and nameText or Strings("Profile: %s", nameText) @@ -696,38 +664,30 @@ local function buildModScopeRow(imp, x, y, w, m) local gearX = x + w - gearW local profX = gearX - profW - math.floor(4 * m.s) - -- Tapping main profile button cycles to the next profile (styles match iconButton) - Kit._audit("control", profX, y, profW, h, "mod-scope-profile") - local focused = Kit.focusable("mod-scope-profile", profX, y, profW, h) - local hot = focused or Kit.hover(profX, y, profW, h) - Theme.fillRounded(profX, y, profW, h, hot and PAL.ink or PAL.surface, 1) - Theme.strokeRounded(profX, y, profW, h, PAL.line, - hot and Theme.A.focus or Theme.A.hairline, 1) - Kit.textCenterBold("micro", profLabel, profX, - y + (h - Kit.textHeight("micro")) / 2, profW, - hot and PAL.inverse or PAL.heading) - if Kit.press(profX, y, profW, h) or Kit._activateId == "mod-scope-profile" then - local nextIdx = 1 - for i, p in ipairs(profiles) do - if p.name == activeProf then - nextIdx = (i % #profiles) + 1 - break + btn(imp, profX, y, profW, h, "mod-scope-profile", profLabel, { + face = "invert", font = "micro", + action = function() + local list, cur = LauncherMods.getProfiles() + local nextIdx = 1 + for i, p in ipairs(list) do + if p.name == cur then + nextIdx = (i % #list) + 1 + break + end end - end - local nextProf = profiles[nextIdx] and profiles[nextIdx].name - if nextProf then - queueAction(imp, "mod-scope-profile", function() + local nextProf = list[nextIdx] and list[nextIdx].name + if nextProf then LauncherMods.applyProfile(nextProf) if imp._refreshMods then imp:_refreshMods() end - end) - end - end + end + end, + }) - -- Tapping gear button opens the Profile Manager modal imp._gearIcon = imp._gearIcon or (love and love.graphics and love.graphics.newImage and love.graphics.newImage("assets/launcher/gear.png")) - iconButton(imp, "mod-profile-gear", gearX, y, gearW, imp._gearIcon, function() - imp._profilesPopup = true - end) + btn(imp, gearX, y, gearW, gearW, "mod-profile-gear", "", { + face = "invert", image = imp._gearIcon, + action = function() imp._profilesPopup = true end, + }) if #options >= 2 then for _, opt in ipairs(options) do @@ -836,8 +796,7 @@ local function buildHeader(imp, m) local padX = math.floor(12 * m.s) local chipW = math.max(tw + 2 * padX, gear) local lx = m.x + m.pad - Theme.fill(lx, by, chipW, gear, PAL.bg, 1) - Theme.stroke(lx, by, chipW, gear, PAL.yellow, Theme.A.hover, 1) + Kit.card(lx, by, chipW, gear, "badge") local th = Kit.textHeight("small") Kit.text("small", label, lx + math.floor((chipW - tw) / 2), by + math.floor((gear - th) / 2), PAL.yellow) @@ -856,17 +815,20 @@ local function buildHeader(imp, m) imp._gearIcon = imp._gearIcon or love.graphics.newImage("assets/launcher/gear.png") rx = rx - gear - iconButton(imp, "gear", rx, by, gear, imp._gearIcon, - function() imp:_openSettings() end) + btn(imp, rx, by, gear, gear, "gear", "", { + face = "invert", image = imp._gearIcon, + action = function() imp:_openSettings() end, + }) - -- Quit, top-right corner. - iconButton(imp, "quit", quitX, by, gear, nil, - function() imp:_quitApp() end, - function(x, y, size, hot) - local pad = math.floor(size * 0.32) - drawCross(x + pad, y + pad, size - 2 * pad, + btn(imp, quitX, by, gear, gear, "quit", "", { + face = "invert", + action = function() imp:_quitApp() end, + drawFn = function(x, y, w, h, hot) + local pad = math.floor(w * 0.32) + drawCross(x + pad, y + pad, w - 2 * pad, hot and { 0, 0, 0, 1 } or { 1, 1, 1, 0.85 }) - end) + end, + }) -- The self-update control lives in the FOOTER next to the BCG mark (small, -- out of the wordmark's way -- it used to overlap the logo on a phone). It @@ -885,22 +847,16 @@ local function buildHeader(imp, m) -- bright cart gold; Gold (Gen 2) uses the deeper amber so the two do not -- collide. local tabs = { - { id = "red", letter = "R", label = Strings("RED"), color = PAL.railRed }, - { id = "blue", letter = "B", label = Strings("BLUE"), color = PAL.railBlue }, - { id = "yellow", letter = "Y", label = Strings("YELLOW"), color = PAL.railGold }, - { id = "gold", letter = "G", label = Strings("GOLD"), color = PAL.railAmber }, - { id = "mods", icon = imp._modsIcon, label = Strings("MODS") }, - { id = "find", icon = imp._findIcon, label = Strings("FIND MODS") }, + { id = "red", letter = "R", color = PAL.railRed }, + { id = "blue", letter = "B", color = PAL.railBlue }, + { id = "yellow", letter = "Y", color = PAL.railGold }, + { id = "gold", letter = "G", color = PAL.railAmber }, + { id = "mods", icon = imp._modsIcon }, + { id = "find", icon = imp._findIcon }, } local tabH = m.chip local tx = m.x + m.pad local ty = y + math.floor(6 * m.s) - -- Wrap the strip instead of running off the edge. - -- - -- Six tabs used to escape a phone width when an active icon tab spelled its - -- name out (FIND MODS at 412x915). Game tabs (R/B/Y/G) stay glyph-only even - -- when active; only MODS / FIND MODS expand. Still wrap when the next tab - -- would not fit so the divider below moves with the row count. local tabLeft = tx local tabRight = m.x + m.w - m.pad local tabGap = math.floor(6 * m.s) @@ -908,51 +864,16 @@ local function buildHeader(imp, m) for _, t in ipairs(tabs) do local active = imp.tab == t.id local key = "tab-" .. t.id - -- Cartridge tabs stay square (letter only). Icon tabs still expand to - -- show MODS / FIND MODS when selected. - local expand = active and t.icon ~= nil - local labelW = expand and Kit.textWidth("tab", t.label) or 0 - local w = expand and (tabH + math.floor(8 * m.s) + labelW + math.floor(12 * m.s)) - or tabH - -- Never wrap the first tab of a row: if one tab alone is wider than the - -- panel there is nowhere better to put it, and wrapping would loop. + local w = tabH if tx > tabLeft and tx + w > tabRight then tx = tabLeft ty = ty + tabH + tabRowGap end - Kit._audit("control", tx, ty, w, tabH, key) - local focused = Kit.focusable(key, tx, ty, w, tabH) - local hot = focused or Kit.hover(tx, ty, w, tabH) - local invert = active or hot - local tint = t.color or PAL.ink - Theme.fillRounded(tx, ty, w, tabH, invert and tint or PAL.surface, 1) - if not invert then - Theme.strokeRounded(tx, ty, w, tabH, tint, - t.color and Theme.A.hover or Theme.A.hairline, 1) - end - -- Ink on a filled tab must contrast with THAT fill: black on the light - -- red/blue/gold cartridge colours, which are all high-luminance. - local ink = invert and PAL.inverse or (t.color or PAL.text) - if t.icon then - local iw, ih = t.icon:getDimensions() - local pad = math.floor(tabH * 0.24) - local s = math.min((tabH - 2 * pad) / iw, (tabH - 2 * pad) / ih) - if invert then love.graphics.setColor(0, 0, 0, 1) - else love.graphics.setColor(1, 1, 1, 0.9) end - love.graphics.draw(t.icon, Theme.snap(tx + (tabH - iw * s) / 2), - Theme.snap(ty + (tabH - ih * s) / 2), 0, s, s) - love.graphics.setColor(1, 1, 1, 1) - else - Kit.textCenter("tab", t.letter, tx, - ty + (tabH - Kit.textHeight("tab")) / 2, tabH, ink) - end - if expand then - Kit.text("tab", t.label, tx + tabH + math.floor(4 * m.s), - ty + (tabH - Kit.textHeight("tab")) / 2, ink) - end - if Kit.press(tx, ty, w, tabH) or Kit._activateId == key then - queueAction(imp, key, function() imp:_switchTab(t.id) end) - end + btn(imp, tx, ty, w, tabH, key, "", { + face = "tab", font = "tab", color = t.color, active = active, + image = t.icon, letter = t.letter, + action = function() imp:_switchTab(t.id) end, + }) tx = tx + w + tabGap end @@ -1454,8 +1375,10 @@ local function buildGamePanel(imp, x, y, w, availH, m, version) version, gameName, function() imp:play(version, true) end) imp._gearIcon = imp._gearIcon or love.graphics.newImage("assets/launcher/gear.png") - iconButton(imp, "manage-" .. version, lx + lw - mgW, ly, mgW, - imp._gearIcon, function() imp._gameManage = version end) + btn(imp, lx + lw - mgW, ly, mgW, mgW, "manage-" .. version, "", { + face = "invert", image = imp._gearIcon, + action = function() imp._gameManage = version end, + }) ly = ly + playH + gap end @@ -1743,8 +1666,7 @@ local function buildModsPanel(imp, x, y, w, availH, m) local focused = Kit.focusable(rowKey, x, ry, w, rowH) local hot = focused or Kit.hover(x, ry, w, rowH) if isFullyDisabled then - Theme.fillRounded(x, ry, w, rowH, PAL.bg, 0.8, Theme.cardRadius()) - Theme.strokeRounded(x, ry, w, rowH, PAL.muted, hot and Theme.A.hover or 0.25, 1, Theme.cardRadius()) + Kit.card(x, ry, w, rowH, hot and "mutedHot" or "muted") else Kit.card(x, ry, w, rowH, hot) end @@ -3020,8 +2942,7 @@ local function buildSettingsModal(imp, m) else local row = item.row local key = "set-" .. i - Theme.strokeRounded(px + pad, ry, pw - 2 * pad, rowH, PAL.line, - Theme.A.hairline, 1) + Kit.card(px + pad, ry, pw - 2 * pad, rowH, "hairline") local ix = px + pad + math.floor(12 * m.s) -- Where the label prints, and where the control band starts. Stacked: -- label on its own full-width line, controls on the line below it. @@ -3136,8 +3057,7 @@ local function buildDepResolverModal(imp, m) cy = cy + Kit.textHeight("small") + math.floor(10 * m.s) -- Security Disclaimer Banner Callout Card - Theme.fillRounded(px + pad, cy, pw - 2 * pad, warnH, PAL.rowBg, 1, Theme.radius()) - Theme.strokeRounded(px + pad, cy, pw - 2 * pad, warnH, PAL.yellow, Theme.A.hover, 1, Theme.radius()) + Kit.card(px + pad, cy, pw - 2 * pad, warnH, "warn") local warnMsg = Strings("Caution: Only pull dependencies from sources you trust.\nVerify source repositories before fetching.") Kit.text("micro", warnMsg, px + pad + math.floor(12 * m.s), cy + math.floor(5 * m.s), PAL.yellow) cy = cy + warnH + math.floor(12 * m.s) @@ -3173,7 +3093,7 @@ local function buildDepResolverModal(imp, m) if ry + rowH >= cy and ry <= cy + listH then -- Item Card Fill & Stroke (matching launcher card interiors & radius) local hot = Kit.hover(px + pad, ry, pw - 2 * pad, rowH) - Theme.row(px + pad, ry, pw - 2 * pad, rowH, hot and "hover" or "normal") + Kit.card(px + pad, ry, pw - 2 * pad, rowH, hot and "rowHover" or "row") local ix = px + pad + math.floor(12 * m.s) local innerW = pw - 2 * pad - math.floor(24 * m.s) diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index 99e36aef..40c094d7 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -9,15 +9,16 @@ -- cable pull. -- -- Cable rules: no experience, no money, no items; either side may RUN --- (a draw); a fainted mon is auto-replaced by the next healthy party --- member (the original prompts; documented divergence). Badge stat --- boosts don't apply on either side (divergence: Gen 1 famously kept --- them in link battles). +-- (a draw); a fainted mon is replaced from the party menu and the chosen +-- slot rides the wire, the way ChooseNextMon hands it to +-- LinkBattleExchangeData. Badge stat boosts don't apply on either side +-- (divergence: Gen 1 famously kept them in link battles). local Fingerprint = require("src.link.Fingerprint") local Font = require("src.render.Font") local Handshake = require("src.link.Handshake") local Logger = require("src.core.Logger") +local Party = require("src.pokemon.Party") local Protocol = require("src.link.Protocol") local Runtime = require("src.mods.Runtime") local TurnOrder = require("src.battle.TurnOrder") @@ -257,7 +258,9 @@ function LinkBattle.new(game, net, opts) self.enemyParty = theirParty self.playerParty = myParty -- intro ball row uses the clamped copies self.opponentName = theirName - self.introText = Strings("%s wants\nto battle!", theirName) + -- _TrainerWantsToFightText (data/text/text_2.asm:1257): wIsInBattle == 2 + -- takes PrintBeginningBattleText's .trainerBattle arm, link included + self.introText = Strings("%s wants\nto fight!", theirName) self.remoteHashes = {} self.localHashes = {} self.remoteParts = {} @@ -327,6 +330,68 @@ function LinkBattle.new(game, net, opts) end) end + -- ChooseNextMon (engine/battle/core.asm:1086-1103): the replacement after + -- a faint is a free party-menu pick in a link battle too -- DisplayPartyMenu + -- runs first, a fainted pick or a cancel goes back to it + -- (.goBackToPartyMenu), and only the chosen slot rides + -- LinkBattleExchangeData. + local function chooseReplacement(s) + s.linkReplacement = nil + s:uiNext(function() + return s:buildScreen("PartyMenu", { + battle = s, + party = myParty, + forceSwitch = true, + onSwitch = function(mon) + if mon.hp > 0 then s.linkReplacement = mon end + end, + }) + end) + s:actNext(function() + local mon = s.linkReplacement + s.linkReplacement = nil + if s.result then return end + if not mon then + chooseReplacement(s) + return + end + for i, m in ipairs(myParty) do + if m == mon then send({ type = "replace", index = i }) break end + end + sendOutPlayer(s, mon) + end) + end + + -- ReplaceFaintedEnemyMon (core.asm:892-905) reads the peer's slot back out + -- of the exchange, and EnemySendOutFirstMon (core.asm:1315-1320) decodes it + -- as wSerialExchangeNybbleReceiveData - 4. HandlePlayerMonFainted + -- (core.asm:989-996) always runs ChooseNextMon BEFORE that read, so on a + -- double faint both machines commit their own slot before they block on the + -- peer's; our queue can reach the enemy's handler first, so the wait + -- pre-empts itself with the local pick rather than deadlocking on a message + -- neither side is going to send. + local function awaitReplacement(s) + s:actNext(function() + if s.result then return end + if s.player.mon.hp <= 0 and not s.linkChoiceQueued + and Party.firstHealthy(myParty) then + s.linkChoiceQueued = true + chooseReplacement(s) + awaitReplacement(s) + return + end + local idx = s.remoteReplace + if not idx then + awaitReplacement(s) + return + end + s.remoteReplace = nil + local mon = theirParty[idx] + if not mon or mon.hp <= 0 then mon = Party.firstHealthy(theirParty) end + sendOutEnemy(s, mon) + end) + end + -- decode a remote action message against the enemy battler local function decodeTheirAction(s, msg) return decodeWireAction(s, msg, s.enemy) @@ -540,12 +605,17 @@ function LinkBattle.new(game, net, opts) submit(s, { type = "action", kind = "run" }, nil) end - -- fainted mons auto-replace with the next healthy teammate, in party - -- order, identically on both machines + -- linkChoiceQueued: awaitReplacement already pre-empted itself with this + -- side's ChooseNextMon and put the slot on the wire, so the handler the + -- faint queued has nothing left to do but drop the flag self.playerMonFainted = function(s) + if s.linkChoiceQueued then + s.linkChoiceQueued = nil + return + end for _, mon in ipairs(myParty) do if mon.hp > 0 then - s:act(function() sendOutPlayer(s, mon) end) + if not s.result then chooseReplacement(s) end return end end @@ -558,7 +628,7 @@ function LinkBattle.new(game, net, opts) self.enemyMonFainted = function(s) for _, mon in ipairs(theirParty) do if mon.hp > 0 then - s:act(function() sendOutEnemy(s, mon) end) + if not s.result then awaitReplacement(s) end return end end @@ -579,6 +649,9 @@ function LinkBattle.new(game, net, opts) s.remoteHashes[msg.turn or 0] = msg.value s.remoteParts[msg.turn or 0] = msg.parts checkHashes(s) + elseif msg.type == "replace" then + local idx = math.floor(tonumber(msg.index) or 1) + s.remoteReplace = math.max(1, math.min(#theirParty, idx)) elseif msg.type == "bye" then -- only a draw if our own simulation hasn't already decided -- (the winner's bye can arrive while we're still animating) @@ -770,6 +843,34 @@ function LinkBattle.newSpectator(game, net, opts) end) end + local function awaitHostReplacement(s) + s:actNext(function() + if s.result then return end + local idx = table.remove(s.hostReplace, 1) + if not idx then + awaitHostReplacement(s) + return + end + local mon = hostParty[idx] + if not mon or mon.hp <= 0 then mon = Party.firstHealthy(hostParty) end + sendOutHost(s, mon) + end) + end + + local function awaitGuestReplacement(s) + s:actNext(function() + if s.result then return end + local idx = table.remove(s.guestReplace, 1) + if not idx then + awaitGuestReplacement(s) + return + end + local mon = guestParty[idx] + if not mon or mon.hp <= 0 then mon = Party.firstHealthy(guestParty) end + sendOutGuest(s, mon) + end) + end + local function resolveSpecTurn(s, hostMsg, guestMsg) if hostMsg.kind == "run" or guestMsg.kind == "run" then endSpectate(s, "The match ended.") @@ -840,7 +941,7 @@ function LinkBattle.newSpectator(game, net, opts) self.playerMonFainted = function(s) for _, mon in ipairs(hostParty) do if mon.hp > 0 then - s:act(function() sendOutHost(s, mon) end) + if not s.result then awaitHostReplacement(s) end return end end @@ -852,7 +953,7 @@ function LinkBattle.newSpectator(game, net, opts) self.enemyMonFainted = function(s) for _, mon in ipairs(guestParty) do if mon.hp > 0 then - s:act(function() sendOutGuest(s, mon) end) + if not s.result then awaitGuestReplacement(s) end return end end @@ -862,6 +963,7 @@ function LinkBattle.newSpectator(game, net, opts) end self.hostMsg, self.guestMsg = nil, nil + self.hostReplace, self.guestReplace = {}, {} local baseUpdate = self.update self.update = function(s, dt) net:update() @@ -875,6 +977,13 @@ function LinkBattle.newSpectator(game, net, opts) s.hostMsg, s.guestMsg = nil, nil resolveSpecTurn(s, h, g) end + elseif inner.type == "replace" then + local idx = math.floor(tonumber(inner.index) or 1) + if msg.side == "host" then + table.insert(s.hostReplace, math.max(1, math.min(#hostParty, idx))) + else + table.insert(s.guestReplace, math.max(1, math.min(#guestParty, idx))) + end elseif inner.type == "bye" or inner.type == "forfeit" then if not s.result then endSpectate(s, "The match ended.") end end diff --git a/src/link/LinkState.lua b/src/link/LinkState.lua index 9f3da68d..c2464beb 100644 --- a/src/link/LinkState.lua +++ b/src/link/LinkState.lua @@ -19,6 +19,7 @@ LinkState.__index = LinkState LinkState.isOpaque = true local CURSOR = 0xED +local CURSOR_HOLLOW = 0xEC local ANY = "ANY" -- sentinel: a leading nil array entry breaks ipairs under -- LuaJIT even though # still reports the full size, so -- the level picker cycles this string instead of nil, @@ -114,6 +115,7 @@ end function LinkState:exitWith(message, reason) DiscordPresence.setJoinCode(nil) self.game.linkSession = nil -- back to the player's own GAME SPEED + if self.game.linkNet == self.net then self.game.linkNet = nil end Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") }) if self.net then self.net:close() end self.game.stack:pop() @@ -519,6 +521,9 @@ function LinkState:startMode(mode, isHost) }) self.net:send(self.trade:opening()) self.index = 1 + self.theirIndex = 1 + self.side = "mine" + self.pickChoice = nil else self.stage = "battleWait" -- the host deals the shared RNG seed for the lockstep simulation @@ -536,7 +541,14 @@ end -- trade flow -- ------------------------------------------------------------------- +function LinkState:openStats(mon) + if not mon then return end + self.game.linkNet = self.net + Screens.push(self.game, "SummaryMenu", mon) +end + function LinkState:updateTrade(input) + if self.game.linkNet == self.net then self.game.linkNet = nil end for _, msg in ipairs(self.net:poll()) do local reply = self.trade:handle(msg) if reply then self.net:send(reply) end @@ -591,10 +603,51 @@ function LinkState:updateTrade(input) return end - if t.stage == "picking" and input:wasPressed("up") then - self.index = math.max(1, self.index - 1) + -- pokered engine/link/cable_club.asm TradeCenter_SelectMon: A on one of + -- your own mons opens the "STATS TRADE" row (.displayStatsTradeMenu) + -- and only TRADE commits the pick, while the enemy list carries its own + -- cursor whose A shows that mon's status pages (.displayEnemyMonStats). + -- The cart's enemy path sets hl but never wMonDataLocation, the way the + -- battle menu's STATS (engine/battle/core.asm) does, so LoadMonData_ reads + -- the player's party and it draws YOUR mon at that slot -- an omission, + -- not behaviour, so we show the peer's mon. + if t.stage == "picking" and self.pickChoice then + if input:wasPressed("left") then + self.pickChoice = 1 + elseif input:wasPressed("right") then + self.pickChoice = 2 + elseif input:wasPressed("b") then + self.pickChoice = nil -- .cancelPlayerMonChoice: back to the list, not + -- out of the trade + elseif input:wasPressed("a") then + if self.pickChoice == 1 then + self.pickChoice = nil + self:openStats(self.game.save.party[self.index]) + elseif t:canPick(self.index) then + self.pickChoice = nil + self.side = "mine" + self.net:send(t:pick(self.index)) + end + end + elseif t.stage == "picking" and input:wasPressed("up") then + if self.side == "theirs" then + self.theirIndex = math.max(1, self.theirIndex - 1) + else + self.index = math.max(1, self.index - 1) + end elseif t.stage == "picking" and input:wasPressed("down") then - self.index = math.min(#self.game.save.party, self.index + 1) + if self.side == "theirs" then + self.theirIndex = math.min(#(t.theirParty or {}), self.theirIndex + 1) + else + self.index = math.min(#self.game.save.party, self.index + 1) + end + elseif t.stage == "picking" and input:wasPressed("right") then + if t.theirParty and #t.theirParty > 0 then + self.side = "theirs" + self.theirIndex = math.min(self.theirIndex, #t.theirParty) + end + elseif t.stage == "picking" and input:wasPressed("left") then + self.side = "mine" elseif self.confirmed == nil and input:wasPressed("b") then -- once confirm=true has been sent to the peer, backing out here -- would desync the two sides (the peer may already be committing @@ -603,8 +656,10 @@ function LinkState:updateTrade(input) self.net:send({ type = "bye" }) self:exitWith(Strings("The trade was\ncancelled.")) elseif t.stage == "picking" and input:wasPressed("a") then - if t:canPick(self.index) then - self.net:send(t:pick(self.index)) + if self.side == "theirs" then + self:openStats((t.theirParty or {})[self.theirIndex]) + else + self.pickChoice = 1 end elseif t.stage == "confirming" and self.confirmed == nil then if input:wasPressed("a") then @@ -738,25 +793,40 @@ function LinkState:draw() local label = (mon.nickname or def.name):sub(1, 8) if not t:canPick(i) then label = label .. "X" end Font.draw(label, 16, 20 + i * 12) - if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end + if i == self.index and self.side ~= "theirs" then + Font.drawCode(CURSOR, 8, 20 + i * 12) + end end Font.draw(Strings("THEIRS"), 84, 20) for i, mon in ipairs(t.theirParty or {}) do local def = self.game.data.pokemon[mon.species] Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12) - if t.theirPick == i then Font.drawCode(CURSOR, 84, 20 + i * 12) end + if self.side == "theirs" and i == self.theirIndex then + Font.drawCode(CURSOR, 84, 20 + i * 12) + elseif t.theirPick == i then + Font.drawCode(CURSOR_HOLLOW, 84, 20 + i * 12) + end end - local hint - if t.stage == "waitRecords" then hint = "Comparing games..." - elseif t.stage == "waitParty" then hint = "Exchanging data..." - elseif t.stage == "picking" then - hint = t:canPick(self.index) and "Pick one to trade" - or Strings("X: not on theirs") - elseif t.stage == "waitPick" then hint = "Waiting for them..." - elseif t.stage == "confirming" then - hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel") + if self.pickChoice then + Font.draw(Strings("STATS"), 16, 128) + Font.draw(Strings("TRADE"), 96, 128) + Font.drawCode(CURSOR, self.pickChoice == 1 and 8 or 88, 128) + else + local hint + if t.stage == "waitRecords" then hint = "Comparing games..." + elseif t.stage == "waitParty" then hint = "Exchanging data..." + elseif t.stage == "picking" then + if self.side == "theirs" then hint = Strings("A: stats") + else + hint = t:canPick(self.index) and "Pick one to trade" + or Strings("X: not on theirs") + end + elseif t.stage == "waitPick" then hint = "Waiting for them..." + elseif t.stage == "confirming" then + hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel") + end + Font.draw(hint or "", 8, 132) end - Font.draw(hint or "", 8, 132) elseif self.stage == "battleWait" or self.stage == "battleRunning" then drawTitle("LINK BATTLE") diff --git a/src/pokemon/Evolution.lua b/src/pokemon/Evolution.lua index 83b6a1d6..279daa50 100644 --- a/src/pokemon/Evolution.lua +++ b/src/pokemon/Evolution.lua @@ -140,10 +140,12 @@ function Evolution.learnEvolutionMoves(game, mon, onDone) if #mon.moves < 4 then table.insert(mon.moves, { id = moveId, pp = mdef.pp }) Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) - require("src.core.Sound").play(game.data, "Get_Item1") + -- LearnedMove1Text: text_far, sound_get_item_1, text_promptbutton + -- (learn_move.asm), so the jingle rides the box game.stack:push(TextBox.new(game, romText(game.data, "_LearnedMove1Text", - "%s learned\n%s!", name, mdef.name), nextStep)) + "%s learned\n%s!", name, mdef.name), nextStep, + TextBox.soundOpts(game, "Get_Item1"))) else -- LearnMoveFromLevelUp with a full moveset: the forget UI Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep) diff --git a/src/render/HudTiles.lua b/src/render/HudTiles.lua index 8f148039..c9712a43 100644 --- a/src/render/HudTiles.lua +++ b/src/render/HudTiles.lua @@ -141,13 +141,20 @@ end -- Tinting first would double-apply the color: GREENBAR's fill {0,189,0} has -- red channel 0, so the tint zeroes the whole bar's red and the zone's -- red-channel-keyed shade shader then maps every pixel to color 3 = black. -function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments) +-- +-- pixels: an explicit 0..48 bar length on GetHPBarLength's scale, for a +-- caller that is animating the bar between two HP values +-- (UpdateHPBar_AnimateHPBar); it scales with `segments` like the color +-- thresholds do. Without it the length comes from mon.hp as before. +function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments, pixels) local x, y = tx * 8, ty * 8 segments = math.max(1, math.floor(segments or 6)) HudTiles.tile(0x71, x, y) HudTiles.tile(0x62, x + 8, y) local px = 0 - if mon.stats.hp > 0 and mon.hp > 0 then + if pixels then + px = math.max(0, math.floor(pixels * segments / 6)) + elseif mon.stats.hp > 0 and mon.hp > 0 then px = math.max(1, math.floor(mon.hp * segments * 8 / mon.stats.hp)) end local tint diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index ea654d9d..90fb6580 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -691,10 +691,12 @@ function PaletteFX.spriteObp(spriteDef, seed) return PaletteFX.darkObp(w.spritePalettes[group], group) end --- GetHealthBarColor (home/palettes.asm) on the standard 48px bar -function PaletteFX.barPalName(hp, maxHp) - local px = maxHp > 0 and math.floor(hp * 48 / maxHp) or 0 - if hp > 0 and px < 1 then px = 1 end +-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar. It reads +-- the bar's own length, so a caller mid-drain passes the animated `pixels` +-- rather than let it be re-derived from hp. +function PaletteFX.barPalName(hp, maxHp, pixels) + local px = pixels or (maxHp > 0 and math.floor(hp * 48 / maxHp) or 0) + if not pixels and hp > 0 and px < 1 then px = 1 end return px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR" end diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 67ef8910..3843cbc1 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -100,6 +100,26 @@ function TextBox.new(game, text, onDone, opts) return self end +-- soundOpts: a jingle carried at the end of the string as a trailing text +-- command (sound_get_item_1 and friends -> home/text.asm TextCommand_SOUND), +-- which runs PlaySound then WaitForSoundToFinish once the last page has +-- typed, so the box holds until the fanfare is over. Merges into a caller's +-- opts table; auto.wait keeps the trailing button press the plain A/B path +-- gives every other box. +function TextBox.soundOpts(game, sound, opts) + opts = opts or {} + local auto = opts.auto + -- auto = true is the no-button-wait arming (text_opts); keep that choice + if auto == true then auto = { wait = false } end + auto = auto or {} + if auto.wait == nil then auto.wait = true end + auto.delay = auto.delay or 0 + auto.sound = type(sound) == "function" and sound + or function() return require("src.core.Sound").play(game.data, sound) end + opts.auto = auto + return opts +end + -- The runtime tokens substitute() knows, as handlers the tokens registry -- serves. Each is fn(game, arg) -> replacement, or nil to drop the token. -- RAM keeps pokered's stale-buffer semantics: give_item copies the item diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 9390be2a..104b2c50 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -592,6 +592,17 @@ function Commands.play_sound(ctx, soundId) require("src.core.Sound").play(ctx.game.data, soundId) end +-- text_sound : the jingle the ROM parks at the END of a string as +-- a trailing text command (sound_get_item_1, sound_get_key_item -> +-- home/text.asm TextCommand_SOUND). It arms the NEXT show_text the same +-- way play_cry arms ctx.pendingCry, so the fanfare fires once the last page +-- has typed and the box holds on WaitForSoundToFinish before the button +-- wait. play_sound stays the bare PlaySound used for the non-blocking +-- beats (Bill's teleporter, the S.S. Anne horn). +function Commands.text_sound(ctx, soundId) + ctx.textOpts = TextBox.soundOpts(ctx.game, soundId, ctx.textOpts) +end + -- play_once : one-shot jingle (Music_PkmnHealed, etc.); blocks -- until it finishes so heal-rest scripts (Mom, captain text_asm) match -- Gen1's wait-on-channel loop. The map theme resumes when it ends @@ -1050,8 +1061,10 @@ function Commands.trade(ctx, tradeIndex, doneFlag) onDone = function() runner:resume() end, }) runner:yield() - -- TradedForText (sound_get_key_item) then the dialogset's thanks - require("src.core.Sound").play(data, "Get_Key_Item") + -- TradedForText carries sound_get_key_item after the text, so the jingle + -- rides the box and blocks it (home/text.asm TextCommand_SOUND), then the + -- dialogset's thanks + Commands.text_sound(ctx, "Get_Key_Item") say(texts.tradedFor or "_TradedForText") say(texts.thanks or "_Thanks" .. dialogset .. "Text") end diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 11226454..25440f22 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -33,12 +33,12 @@ local function save_name(game) return game.save.player.name end -local function showMessages(game, msgs, onDone) +local function showMessages(game, msgs, onDone, opts) if not msgs or #msgs == 0 then if onDone then onDone() end return end - game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone)) + game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone, opts)) end -- run the use-flow for an item on a chosen target. `picker` is the party @@ -181,9 +181,11 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) end if #target.moves < 4 then table.insert(target.moves, { id = moveId, pp = mdef.pp }) - require("src.core.Sound").play(game.data, "Get_Item1") + -- LearnedMove1Text: text_far, sound_get_item_1, text_promptbutton + -- (learn_move.asm), so the jingle rides the box showMessages(game, { Strings("%s learned\n%s!", target.nickname or - game.data.pokemon[target.species].name, mdef.name) }) + game.data.pokemon[target.species].name, mdef.name) }, nil, + TextBox.soundOpts(game, "Get_Item1")) if result == "learn" then consume(game, id) end list.items = buildItems(game) list.index = math.min(list.index, math.max(1, #list.items)) @@ -333,10 +335,9 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) local mdef = game.data.moves[moveId] if #target.moves < 4 then table.insert(target.moves, { id = moveId, pp = mdef.pp }) - require("src.core.Sound").play(game.data, "Get_Item1") local name = target.nickname or def.name showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) }, - nextStep) + nextStep, TextBox.soundOpts(game, "Get_Item1")) else require("src.ui.Screens").push(game, "MoveLearnMenu", target, moveId, nextStep) diff --git a/src/ui/EvolutionState.lua b/src/ui/EvolutionState.lua index d819b03e..d704a3e7 100644 --- a/src/ui/EvolutionState.lua +++ b/src/ui/EvolutionState.lua @@ -46,9 +46,27 @@ function EvolutionState:sgbPalettes(game) return P.wholeNamed(game.data, "MEWMON") end -local FLASH_FRAMES = 220 -- evolution.asm EvolveMon delays 80 frames before .animLoop, polling nothing (#968, #1031) local CANCEL_GRACE_FRAMES = 80 +-- .animLoop starts at `lb bc, $1, $10` and runs 8 iterations: iteration k holds +-- the old pic for c = 18-2k frames inside Evolution_CheckForCancel (72 in all), +-- then Evolution_BackAndForthAnim swaps b = k times, each swap a pair of +-- Evolution_ChangeMonPic that end in Delay3 (6 frames a swap, 216 in all). +local ANIM_LOOP_FRAMES = 288 +local FLASH_FRAMES = CANCEL_GRACE_FRAMES + ANIM_LOOP_FRAMES + +-- which pic is on screen t frames into .animLoop +local function evoShowsNew(t) + for b = 1, 8 do + local hold = 18 - 2 * b + if t < hold then return false end + t = t - hold + local swap = b * 6 + if t < swap then return t % 6 < 3 end + t = t - swap + end + return true +end local function frontSprite(game, species, mon) local path, trueColor = require("src.pokemon.Sprites").path( @@ -140,14 +158,10 @@ function EvolutionState:draw() else sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor end + elseif evoShowsNew(self.t - CANCEL_GRACE_FRAMES) then + sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor else - local period = math.max(4, 28 - math.floor(self.t / 40) * 6) - local showNew = math.floor(self.t / period) % 2 == 1 - if showNew then - sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor - else - sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor - end + sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor end if sprite then local x = math.floor((160 - sprite:getWidth()) / 2) diff --git a/src/ui/IntroMovie.lua b/src/ui/IntroMovie.lua index ed89cd5e..b35dcef5 100644 --- a/src/ui/IntroMovie.lua +++ b/src/ui/IntroMovie.lua @@ -6,6 +6,7 @@ local GameVersion = require("src.core.GameVersion") local Music = require("src.core.Music") local Sound = require("src.core.Sound") local Strings = require("src.core.Strings") +local Timing = require("src.core.Timing") local IntroMovie = {} IntroMovie.__index = IntroMovie @@ -232,6 +233,10 @@ function IntroMovie:fightStep() self:finish() return end + -- an op that consumed frames ends ON its last frame; only the instant + -- ops (sfx / pose / frame, which are plain writes between DelayFrames + -- calls in PlayIntroScene) chain into the next op the same frame + local timed = false if op.sfx then Sound.play(self.game.data, op.sfx) elseif op.pose then @@ -250,6 +255,7 @@ function IntroMovie:fightStep() end self.opTimer = self.opTimer + 1 if self.opTimer < (op.px or math.abs(op.dx)) then return end + timed = true elseif op.anim then -- one {dy,dx} delta per 5 frames (AnimateIntroNidorino: DelayFrames 5) if self.opTimer % 5 == 0 then @@ -259,9 +265,11 @@ function IntroMovie:fightStep() end self.opTimer = self.opTimer + 1 if self.opTimer < #ANIM[op.anim] * 5 then return end + timed = true elseif op.wait then self.opTimer = self.opTimer + 1 if self.opTimer < op.wait then return end + timed = true elseif op.fade then self.opTimer = self.opTimer + 1 self.fade = self.opTimer / op.fade @@ -270,6 +278,7 @@ function IntroMovie:fightStep() end self.opIndex = self.opIndex + 1 self.opTimer = 0 + if timed then return end end end @@ -284,8 +293,9 @@ function IntroMovie:update(dt) return end local input = self.game.input - if input:wasPressed("a") or input:wasPressed("b") - or input:wasPressed("start") then + if input:wasPressed("a") or input:wasPressed("start") then + -- CheckForUserInterruption (home/overworld.asm:2395) returns carry only + -- on a fresh START or A -- B alone never skips the intro. -- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the -- white hold stands in for that beat before the title is built. self:exitToTitle() @@ -300,7 +310,10 @@ function IntroMovie:update(dt) end if self.timer >= SPLASH_FRAMES then self:startPhase(3) end else - self:fightStep() + -- PlayShootingStar ends `jp Delay3` once Music_IntroBattle is playing + -- (intro.asm:337), so PlayIntroScene's first op is not on the music's + -- own frame + if self.timer > Timing.DELAY3 then self:fightStep() end end end diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 013b87fd..ee471590 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -87,6 +87,15 @@ local function refuseUnavailable(self) t._SleepingPikachuText1 or Strings("There isn't any\nresponse..."))) end +-- .newBadgeRequired (start_sub_menus.asm): every badge-gated arm of +-- .outOfBattleMovePointers prints this and jumps back to the open submenu +local function refuseBadge(self) + local TextBox = require("src.render.TextBox") + local t = self.game.data and self.game.data.text or {} + self.game.stack:push(TextBox.new(self.game, + t._NewBadgeRequiredText or Strings("No! A new BADGE\nis required."))) +end + -- where DIG escapes work: escape_rope_tilesets.asm (Agatha's room is -- excluded by map id in ItemUseEscapeRope) local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true, @@ -411,6 +420,23 @@ function PartyMenu:update(dt) -- flyTo (OverworldController) validates the fly-warp + runs the -- departure/warp, so we just hand it the chosen mapId (#195). local ow = self.game.overworld + -- .fly checks THUNDERBADGE first, then CheckIfInOutsideMap + -- (OVERWORLD + PLATEAU -- Route 23 / Indigo Plateau outdoor -- not + -- OVERWORLD alone, #83); both refusals loop back to the submenu + if ow and not ow:partyKnows("FLY") then + refuseBadge(self) + return + end + if ow and not Map.isOutside(ow.map.def, + FieldDefaults.field(self.game.data, "outsideTilesets")) then + local TextBox = require("src.render.TextBox") + local def = self.game.data.pokemon[mon.species] + local txt = (self.game.data.text._CannotFlyHereText + or Strings("{RAM:wNameBuffer} can't\nFLY here.")) + :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) + self.game.stack:push(TextBox.new(self.game, txt)) + return -- .loop: submenu stays open behind the message + end self.game.stack:pop() -- close the party menu Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId) if ow then ow:flyTo(mapId) end @@ -423,9 +449,17 @@ function PartyMenu:update(dt) -- over the menu, and the cave is lit when the blink hands the -- screen back, never under the text (#385). local ow = self.game.overworld + if ow and not ow:partyKnows("FLASH") then + refuseBadge(self) + return + end local TextBox = require("src.render.TextBox") local Transition = require("src.render.Transition") - self.game.save.flashLit = true + -- .flash prints on any map, but the light it records is this map's: + -- home/overworld.asm re-arms wMapPalOffset on the next dark map, so + -- a FLASH used in daylight must not carry into Rock Tunnel + local wasDark = ow and ow.dark + if wasDark then self.game.save.flashLit = true end self.game.stack:push(TextBox.new(self.game, self.game.data.text._FlashLightsAreaText or Strings("A blinding FLASH\nlights the area!"), function() @@ -440,13 +474,13 @@ function PartyMenu:update(dt) -- seconds of per-pixel atlas baking on a phone -- on screen as a -- solid white frame with nothing under it, which reads as a -- lockup (#610). - ow:setDark(false) + if wasDark then ow:setDark(false) end self.game.stack:push(Transition.whiteFlash(self.game)) end)) return elseif action == "surf" then - -- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time - -- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F + -- start_sub_menus.asm .surf: SOULBADGE-gated (useSurfFieldMove), + -- then IsSurfingAllowed (the Cycling Road / Seafoam B4F -- current refusals, both of which loop back to the submenu), then -- ItemUseSurfboard: while surfing it tries to dismount instead; -- otherwise it mounts only if the FACING tile is water, else @@ -503,8 +537,8 @@ function PartyMenu:update(dt) return -- .loop: submenu stays open behind the message elseif action == "cut" then -- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm): - -- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the - -- submenu when the FACING tile isn't a cuttable tree. + -- CASCADEBADGE-gated (useCutFieldMove); _NothingToCutText loops back + -- to the submenu when the FACING tile isn't a cuttable tree. local ow = self.game.overworld local reason = ow:useCutFieldMove() if reason == "ok" then @@ -522,7 +556,7 @@ function PartyMenu:update(dt) self.game.stack:push(TextBox.new(self.game, txt)) return -- .loop: submenu stays open behind the message elseif action == "strength" then - -- start_sub_menus.asm .strength: RAINBOWBADGE-gated (list time); + -- start_sub_menus.asm .strength: RAINBOWBADGE-gated; -- predef PrintStrengthText (field_move_messages.asm) sets -- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate -- push_boulder.asm reads -- then prints _UsedStrengthText (no @@ -532,6 +566,10 @@ function PartyMenu:update(dt) -- .strength, GBPalWhiteOutWithDelay3 blinks the screen white -- before CloseTextDisplay returns to the map. local ow = self.game.overworld + if ow and not ow:partyKnows("STRENGTH") then + refuseBadge(self) + return + end local TextBox = require("src.render.TextBox") local Transition = require("src.render.Transition") local def = self.game.data.pokemon[mon.species] @@ -565,6 +603,33 @@ function PartyMenu:update(dt) -- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares -- the exact departure; the fade + warp fire when the spin ends. local ow = self.game.overworld + if entry.move == "TELEPORT" then + -- .teleport: TELEPORT works only OUTDOORS (CheckIfInOutsideMap -- + -- OVERWORLD + PLATEAU, #83); dark maps don't block it + if ow and not Map.isOutside(ow.map.def, + FieldDefaults.field(self.game.data, "outsideTilesets")) then + local TextBox = require("src.render.TextBox") + local def = self.game.data.pokemon[mon.species] + local txt = (self.game.data.text._CannotUseTeleportNowText + or Strings("{RAM:wNameBuffer} can't\nuse TELEPORT now.")) + :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) + self.game.stack:push(TextBox.new(self.game, txt)) + return -- .loop: submenu stays open behind the message + end + elseif ow and not (DIG_TILESETS[ow.map.def.tileset] + and ow.map.id ~= "AGATHAS_ROOM") then + -- .dig runs ItemUseEscapeRope (it sets wCurItem = ESCAPE_ROPE): + -- usable in the dungeon tilesets of escape_rope_tilesets.asm minus + -- Agatha's room, even in the dark (Rock Tunnel); anywhere else + -- .notUsable -> ItemUseNotTime, the same line BagMenu prints for a + -- bagged ESCAPE ROPE + local TextBox = require("src.render.TextBox") + self.game.stack:push(TextBox.new(self.game, + self.game.data.text._ItemUseNotTimeText + or Strings("OAK: %s!\nThis isn't the\ntime to use that!", + self.game.save.player.name))) + return -- .loop: submenu stays open behind the message + end self.game.stack:pop() if ow then ow:beginTeleportOut() end return @@ -658,42 +723,30 @@ function PartyMenu:update(dt) -- Battle still excludes this list via `not self.battle`. Softboiled -- can appear for a fainted user; its heal transfer then no-ops. if not self.battle and ow then - -- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU -- - -- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83) - local outside = Map.isOutside(ow.map.def, - FieldDefaults.field(self.game.data, "outsideTilesets")) + -- GetMonFieldMoves (engine/menus/text_box.asm) matches the mon's + -- four moves against FieldMoveDisplayData and nothing else -- no + -- badge, no map, no tileset test. Every one of those lives in + -- .outOfBattleMovePointers, i.e. on selection, where the refusal + -- prints and .loop returns to this still-open submenu (#1022). for _, mv in ipairs(mon.moves) do - if mv.id == "FLY" and outside - and self.game.save.inventory.THUNDERBADGE then + if mv.id == "FLY" then table.insert(items, { label = Strings("FLY"), action = "fly" }) - elseif mv.id == "FLASH" and ow.dark - and self.game.save.inventory.BOULDERBADGE then + elseif mv.id == "FLASH" then table.insert(items, { label = Strings("FLASH"), action = "flash" }) - elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then - -- CUT/SURF/STRENGTH are party-menu field moves too - -- (start_sub_menus.asm .outOfBattleMovePointers); listed here - -- with the same list-time badge filter this file already uses - -- for FLY/FLASH. The facing-tile/activation check happens on - -- selection (useCutFieldMove/useSurfFieldMove). + elseif mv.id == "CUT" then table.insert(items, { label = Strings("CUT"), action = "cut" }) - elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then + elseif mv.id == "SURF" then table.insert(items, { label = Strings("SURF"), action = "surf" }) - elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then + elseif mv.id == "STRENGTH" then table.insert(items, { label = Strings("STRENGTH"), action = "strength" }) elseif mv.id == "SOFTBOILED" then table.insert(items, { label = Strings("SOFTBOILED"), action = "softboiled" }) - elseif mv.id == "TELEPORT" and outside then - -- TELEPORT works only OUTDOORS (start_sub_menus.asm - -- .teleport -> CheckIfInOutsideMap); dark maps don't - -- block it - table.insert(items, { label = Strings("TELEPORT"), action = "escape" }) - elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset] - and ow.map.id ~= "AGATHAS_ROOM" then - -- DIG runs ItemUseEscapeRope (.dig sets wCurItem = - -- ESCAPE_ROPE): usable in the dungeon tilesets of - -- escape_rope_tilesets.asm minus Agatha's room, even in - -- the dark (Rock Tunnel) - table.insert(items, { label = Strings("DIG"), action = "escape" }) + elseif mv.id == "TELEPORT" then + table.insert(items, { label = Strings("TELEPORT"), + action = "escape", move = "TELEPORT" }) + elseif mv.id == "DIG" then + table.insert(items, { label = Strings("DIG"), + action = "escape", move = "DIG" }) end end end diff --git a/src/ui/kit/Kit.lua b/src/ui/kit/Kit.lua index 8c423e2e..0a8162b0 100644 --- a/src/ui/kit/Kit.lua +++ b/src/ui/kit/Kit.lua @@ -368,6 +368,7 @@ Kit._navPrevN = 0 Kit._navSeen = {} Kit._navQueue = nil Kit._activateId = nil +Kit._ringShown = false -- Register a focusable. Returns true when it currently holds the ring. -- Shielded widgets do not register: while a modal owns the frame the ring @@ -383,11 +384,12 @@ function Kit.focusable(id, x, y, w, h) -- First focusable ever drawn adopts the ring, so keyboard users start -- somewhere rather than nowhere. if Kit.focusId == nil then Kit.focusId = id end - return Kit.focusId == id + return Kit._ringShown and Kit.focusId == id end function Kit.navigate(dir) Kit._navQueue = dir + Kit._ringShown = true end function Kit.activateFocused() @@ -396,6 +398,7 @@ end function Kit.setFocus(id) Kit.focusId = id + Kit._ringShown = id ~= nil end -- Pick the nearest focusable in `dir` from the current one. Candidates must @@ -530,13 +533,10 @@ Kit._audit = audit function Kit.tapMin() return math.floor(30 * Kit.scale) end -- ---------------------------------------------------------------- surfaces -function Kit.card(x, y, w, h, emphasis) - Theme.card(x, y, w, h, emphasis) +function Kit.card(x, y, w, h, variant) + Theme.card(x, y, w, h, variant) end --- A list row. `id` opts it into the focus ring; pass nil for decorative --- rows. Returns (clicked, inkColor) -- a selected row fills white, so the --- caller must print with the returned ink or it will draw white on white. function Kit.row(x, y, w, h, selected, id) audit("row", x, y, w, h, id or "row") local focused = id and Kit.focusable(id, x, y, w, h) or false @@ -560,7 +560,7 @@ end -- hairline says the same thing for one rect.) function Kit.emptyBox(x, y, w, h, message) if not G then return end - Theme.strokeRounded(x, y, w, h, PAL.line, 0.22, 1, Theme.radius()) + Theme.card(x, y, w, h, "empty") Kit.textCenter("button", Kit.ellipsize("button", message, w - 24 * Kit.scale), x, y + (h - Kit.textHeight("button")) / 2, w, PAL.muted) end @@ -598,61 +598,130 @@ local KINDS = { disabled = { fill = PAL.steel, ink = PAL.inverse, flat = true }, } Kit.KINDS = KINDS +local NO_OPTS = {} --- opts: { kind, font, enabled, align, id, glow, fill, ink } --- id -- opts into the focus ring (give every real control one) --- glow -- a pulsing outline for "something is waiting for you" (the --- update button). No blend-mode change: the alpha of the --- existing outline is animated instead. --- fill/ink -- override the kind's colours. The ONE caller is the --- launcher's Play button, which wears its cartridge colour --- (red/blue/gold) rather than a semantic one: on that screen --- "which game am I launching" outranks "what kind of verb is --- this", and the colour is already the tab's identity. --- Returns true when activated, by click OR by the focus ring's Enter/A. function Kit.button(x, y, w, h, label, opts) - opts = opts or {} + opts = opts or NO_OPTS local enabled = opts.enabled ~= false - -- Disabled buttons audit too: they stay visible, so they still must not - -- paint over a neighbour. audit("control", x, y, w, h, label) local focused = enabled and opts.id and Kit.focusable(opts.id, x, y, w, h) or false - local kind = KINDS[enabled and (opts.kind or "ghost") or "disabled"] - if enabled and opts.fill then - kind = { fill = opts.fill, ink = opts.ink or PAL.inverse } - end local hot = enabled and Kit.hover(x, y, w, h) - - if G then - -- The fill IS the control: a rounded, embossed, colour-coded key. A - -- disabled button keeps its shape in a dead grey rather than - -- disappearing, so a layout never reflows on state. - Theme.fillRounded(x, y, w, h, kind.fill, enabled and 1 or 0.45) - Theme.emboss(x, y, w, h, enabled and (hot and 1.3 or 1) or 0.4) - if hot or focused then - -- White ring outside the fill: legible on green, blue, yellow, red and - -- white alike, which one darker/lighter shade per colour would not be. - Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong, - Theme.A.focus, 2, Theme.radius() + 2) - elseif opts.glow and enabled then - -- "Something is waiting for you" (the update button): a pulsing ring. - -- Pure alpha on one existing stroke -- no extra draw calls, no blend - -- mode change. - local a = 0.25 + 0.75 * (0.5 + 0.5 * math.sin(Kit.time * 3)) - Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong, a, 2, - Theme.radius() + 2) + local face = opts.face or "fill" + local B = Theme.BUTTON + local radius = opts.radius or B.radius + local active = opts.active or opts.on + local invert = false + local fill, ink, stroke, strokeA, doEmboss, doRing, glowA + if face == "invert" then + invert = hot + fill = invert and (opts.hotFill or PAL.ink) or (opts.fill or PAL.surface) + ink = invert and (opts.hotInk or PAL.inverse) or (opts.ink or PAL.heading) + stroke = opts.stroke or PAL.line + strokeA = invert and Theme.A.focus or Theme.A.hairline + doRing = focused and not hot + elseif face == "tab" then + invert = active or focused or hot + local tint = opts.color or opts.fill or PAL.ink + fill = invert and tint or PAL.surface + ink = invert and PAL.inverse or (opts.color or PAL.text) + if not invert then + stroke = tint + strokeA = opts.color and Theme.A.hover or Theme.A.hairline end - local fname = opts.font or "button" - local ink = enabled and kind.ink or PAL.inverse - local ty = y + (h - Kit.textHeight(fname)) / 2 - local shown = Kit.ellipsize(fname, label, w - 16 * Kit.scale) - -- Button labels are bold: they are the shortest, most-scanned text on - -- screen and sit on a saturated fill. - if opts.align == "left" then - Kit.textBold(fname, shown, x + 10 * Kit.scale, ty, ink) + elseif face == "chip" then + local c = opts.color or PAL.line + invert = active and true or false + if active then + fill = c + ink = PAL.inverse + doEmboss = true else - Kit.textCenterBold(fname, shown, x, ty, w, ink) + fill = PAL.bg + ink = c + stroke = c + strokeA = (focused or hot) and Theme.A.focus or Theme.A.hover + end + doRing = focused or hot + else + local kind = KINDS[enabled and (opts.kind or "ghost") or "disabled"] + fill = (enabled and opts.fill) or kind.fill + ink = (enabled and opts.ink) or kind.ink + doEmboss = true + doRing = hot or focused + if opts.glow and enabled and not doRing then + glowA = B.glowBase + B.glowAmp * (0.5 + 0.5 * math.sin(Kit.time * B.glowHz)) + end + end + if opts.emboss ~= nil then doEmboss = opts.emboss end + if opts.ring ~= nil then doRing = opts.ring end + if G then + Theme.fillRounded(x, y, w, h, fill, enabled and 1 or B.disabledA, radius) + if doEmboss then + local es = enabled and ((hot or focused) and B.embossHot or B.embossRest) + or B.embossDisabled + Theme.emboss(x, y, w, h, es) + end + if strokeA then + Theme.strokeRounded(x, y, w, h, stroke, strokeA, 1, radius) + end + if doRing then + Theme.strokeRounded(x - B.ringPad, y - B.ringPad, + w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong, + Theme.A.focus, B.ringWidth, radius + B.ringPad) + elseif glowA then + Theme.strokeRounded(x - B.ringPad, y - B.ringPad, + w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong, + glowA, B.ringWidth, radius + B.ringPad) + end + local fname = opts.font or ((face == "chip") and "micro" or "button") + local ty = y + (h - Kit.textHeight(fname)) / 2 + local image = opts.image + local drawFn = opts.drawFn + local letter = opts.letter + local hasLabel = label and label ~= "" + local bold = opts.bold + if bold == nil then bold = face ~= "tab" end + if image then + local box = h + local boxX, boxY = x, y + if not hasLabel then + box = math.min(w, h) + boxX = x + (w - box) / 2 + boxY = y + (h - box) / 2 + end + local iw, ih = image:getDimensions() + local pad = math.floor(box * (opts.iconPad or B.iconPad)) + local s = math.min((box - 2 * pad) / iw, (box - 2 * pad) / ih) + if invert then Theme.col(PAL.inverse, 1) + else Theme.col(PAL.ink, B.iconRestA) end + G.draw(image, Theme.snap(boxX + (box - iw * s) / 2), + Theme.snap(boxY + (box - ih * s) / 2), 0, s, s) + if hasLabel then + local lx = x + h + B.letterGap * Kit.scale + if bold then Kit.textBold(fname, label, lx, ty, ink) + else Kit.text(fname, label, lx, ty, ink) end + end + elseif drawFn then + drawFn(x, y, w, h, invert or hot or focused) + elseif letter then + Kit.textCenter(fname, letter, x, ty, h, ink) + if hasLabel then + local lx = x + h + B.letterGap * Kit.scale + if bold then Kit.textBold(fname, label, lx, ty, ink) + else Kit.text(fname, label, lx, ty, ink) end + end + elseif hasLabel then + local shown = Kit.ellipsize(fname, label, w - B.labelInset * Kit.scale) + if opts.align == "left" then + local lx = x + B.labelPad * Kit.scale + if bold then Kit.textBold(fname, shown, lx, ty, ink) + else Kit.text(fname, shown, lx, ty, ink) end + elseif bold then + Kit.textCenterBold(fname, shown, x, ty, w, ink) + else + Kit.textCenter(fname, shown, x, ty, w, ink) + end end end if not enabled then return false end @@ -661,7 +730,6 @@ function Kit.button(x, y, w, h, label, opts) and Kit._activateId == opts.id) end --- A small square control: +/- steppers, arrow cyclers, the row X. function Kit.stepper(x, y, w, h, glyph, opts) opts = opts or {} opts.kind = opts.kind or "ghost" @@ -669,31 +737,13 @@ function Kit.stepper(x, y, w, h, glyph, opts) return Kit.button(x, y, w, h, glyph, opts) end --- A pill toggle (badges, dex SEEN/OWN, sub-tabs). `on` inverts it. +local CHIP_OPTS = { face = "chip", font = "micro" } + function Kit.chip(x, y, w, h, label, on, color, id) - audit("control", x, y, w, h, label) - local focused = id and Kit.focusable(id, x, y, w, h) or false - local c = color or PAL.line - if G then - local hot = focused or Kit.hover(x, y, w, h) - if on then - Theme.fillRounded(x, y, w, h, c, 1) - Theme.emboss(x, y, w, h, 1) - Kit.textCenterBold("micro", label, x, - y + (h - Kit.textHeight("micro")) / 2, w, PAL.inverse) - else - Theme.fillRounded(x, y, w, h, PAL.bg, 1) - Theme.strokeRounded(x, y, w, h, c, - hot and Theme.A.focus or Theme.A.hover, 1) - Kit.textCenterBold("micro", label, x, - y + (h - Kit.textHeight("micro")) / 2, w, c) - end - if hot then - Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong, - Theme.A.focus, 2, Theme.radius() + 2) - end - end - return Kit.press(x, y, w, h) or (id ~= nil and Kit._activateId == id) + CHIP_OPTS.active = on + CHIP_OPTS.color = color + CHIP_OPTS.id = id + return Kit.button(x, y, w, h, label, CHIP_OPTS) end -- A status label with no interaction: outlined text, the "INSTALLED"/"UPDATE" diff --git a/src/ui/kit/Theme.lua b/src/ui/kit/Theme.lua index 185c6aef..6773e3e0 100644 --- a/src/ui/kit/Theme.lua +++ b/src/ui/kit/Theme.lua @@ -35,8 +35,8 @@ local PAL = { -- it in the same hue, so a card reads as a raised object rather than as an -- outline drawn on the page. These are still flat fills -- the depth comes -- from the value step plus Theme.shadow, not from a gradient. - field = { 16, 8, 10 }, -- the page BEHIND the cards - bg = { 0, 0, 0 }, -- true black: button rests, field interiors + field = { 20, 20, 20 }, -- the page BEHIND the cards + bg = { 20, 20, 20 }, -- light black surface = { 28, 21, 24 }, -- card interiors rowBg = { 20, 14, 17 }, -- rows inside a card, one step below it raised = { 44, 34, 38 }, -- hover feedback @@ -82,6 +82,67 @@ Theme.A = { disabled = 0.30, } +Theme.BUTTON = { + radius = 8, + ringPad = 2, + ringWidth = 2, + labelInset = 16, + labelPad = 10, + iconPad = 0.24, + letterGap = 4, + disabledA = 0.45, + embossHot = 1.3, + embossRest = 1, + embossDisabled = 0.4, + glowHz = 3, + glowBase = 0.25, + glowAmp = 0.75, + iconRestA = 0.85, +} + +Theme.CARD = { + radius = 14, + shadow = true, + fill = PAL.surface, + fillA = 1, + stroke = PAL.line, + strokeA = Theme.A.hairline, +} + +Theme.CARD_VARIANT = { + emphasis = { strokeA = Theme.A.focus }, + muted = { + fill = PAL.bg, fillA = 0.8, stroke = PAL.muted, strokeA = 0.25, shadow = false, + }, + mutedHot = { + fill = PAL.bg, fillA = 0.8, stroke = PAL.muted, strokeA = Theme.A.hover, shadow = false, + }, + empty = { fillA = 0, strokeA = 0.22, shadow = false, radius = "ctl" }, + warn = { + fill = PAL.rowBg, stroke = PAL.yellow, strokeA = Theme.A.hover, + shadow = false, radius = "ctl", + }, + row = { + fill = PAL.rowBg, stroke = PAL.line, strokeA = Theme.A.hairline, + shadow = false, radius = "ctl", + }, + rowHover = { + fill = PAL.raised, stroke = PAL.line, strokeA = Theme.A.hover, + shadow = false, radius = "ctl", + }, + rowSelected = { + fill = PAL.ink, strokeA = 0, shadow = false, radius = "ctl", + }, + hairline = { + fillA = 0, stroke = PAL.line, strokeA = Theme.A.hairline, + shadow = false, radius = "ctl", + }, + badge = { + fill = PAL.bg, stroke = PAL.yellow, strokeA = Theme.A.hover, + shadow = false, radius = 0, + }, +} + local G = love and love.graphics or nil local has = {} @@ -123,11 +184,11 @@ end -- Controls get the smaller one, containers the larger, so a button never -- looks like a card and a card never looks like a button. function Theme.radius() - return 8 + return Theme.BUTTON.radius end function Theme.cardRadius() - return 14 + return Theme.CARD.radius end -- DROP SHADOW. Three stacked rounded rects at low alpha, each one step wider @@ -206,28 +267,42 @@ end -- The design's only container: a rounded surface a few values above the -- field, its own drop shadow, and a white hairline. `emphasis` raises the -- outline to full white (used for the focused/active card). -function Theme.card(x, y, w, h, emphasis) - local r = Theme.cardRadius() - Theme.shadow(x, y, w, h, r) - Theme.fillRounded(x, y, w, h, PAL.surface, 1, r) - Theme.strokeRounded(x, y, w, h, PAL.line, - emphasis and Theme.A.focus or Theme.A.hairline, 1, r) +function Theme.card(x, y, w, h, variant) + local spec = Theme.CARD + local v + if variant == true then + v = Theme.CARD_VARIANT.emphasis + elseif type(variant) == "string" then + v = Theme.CARD_VARIANT[variant] + elseif type(variant) == "table" then + v = variant + end + local radius = (v and v.radius) or spec.radius + if radius == "ctl" then radius = Theme.BUTTON.radius end + local fill = (v and v.fill) or spec.fill + local fillA = spec.fillA + if v and v.fillA ~= nil then fillA = v.fillA end + local stroke = (v and v.stroke) or spec.stroke + local strokeA = spec.strokeA + if v and v.strokeA ~= nil then strokeA = v.strokeA end + local shadow = spec.shadow + if v and v.shadow ~= nil then shadow = v.shadow end + local strokeW = (v and v.strokeW) or 1 + if fillA > 0 and fill then + if shadow then Theme.shadow(x, y, w, h, radius) end + Theme.fillRounded(x, y, w, h, fill, fillA, radius) + end + if strokeA > 0 and stroke then + Theme.strokeRounded(x, y, w, h, stroke, strokeA, strokeW, radius) + end end --- A list row. Three states, each one rect plus one outline: --- normal one value below the card it sits in, hairline --- hover lifted fill, brighter hairline --- selected WHITE fill (callers print ink = PAL.inverse over it) function Theme.row(x, y, w, h, state) - local r = Theme.radius() if state == "selected" then - Theme.fillRounded(x, y, w, h, PAL.ink, 1, r) + Theme.card(x, y, w, h, "rowSelected") return PAL.inverse end - Theme.fillRounded(x, y, w, h, - state == "hover" and PAL.raised or PAL.rowBg, 1, r) - Theme.strokeRounded(x, y, w, h, PAL.line, - state == "hover" and Theme.A.hover or Theme.A.hairline, 1, r) + Theme.card(x, y, w, h, state == "hover" and "rowHover" or "row") return PAL.text end diff --git a/src/world/NPC.lua b/src/world/NPC.lua index 06d96da6..14b48378 100644 --- a/src/world/NPC.lua +++ b/src/world/NPC.lua @@ -8,7 +8,7 @@ local SpriteRenderer = require("src.render.SpriteRenderer") local NPC = {} NPC.__index = NPC -local STEP_FRAMES = 16 +local STEP_FRAMES = 32 local FACING_FROM_RANGE = { DOWN = "down", UP = "up", LEFT = "left", RIGHT = "right", @@ -52,7 +52,16 @@ function NPC:facePlayer(player) end function NPC:update(map, entities) - -- self.stepFrames overrides the shared 16-frame walk for an object whose + -- An NPC tile is 32 frames, half the player's rate: TryWalking loads + -- WALKANIMATIONCOUNTER with $10 and UpdateSpriteInWalkingAnimation adds + -- the 1px step vector once per call (engine/overworld/movement.asm), but + -- UpdateSprites runs once per OverworldLoop pass and every pass opens + -- with two DelayFrame calls (home/overworld.asm) -- so those 16 ticks + -- cost 32 frames for one 16px cell, against AdvancePlayerSprite's 8 + -- ticks of 2px. That halving is why pokeyellow's NormalPikachuFollow + -- needs TryDoubleAddPikachuStepVectorToScreenPixelCoords to keep up. + -- + -- self.stepFrames overrides the shared walk for an object whose -- step has to stay in phase with something else: Yellow's follower -- Pikachu takes the player's own step length, halved while it is more -- than a cell behind (FastPikachuFollow, engine/pikachu/ @@ -78,7 +87,7 @@ function NPC:update(map, entities) return end local d = Collision.DELTA[self.facing] - -- 1px per frame at the default length; a shortened step scales instead, + -- 1px per 2 frames at the default length; a shortened step scales instead, -- so the cell still lands on a 16px boundary (Player:update does the -- same for the bicycle) local moved = math.floor(self.progress * 16 * span / stepLen) @@ -113,8 +122,9 @@ end function NPC:walkPhase() if not self.moving then return 0 end - local p = self.progress % 16 - return (p >= 4 and p < 12) and 1 or 0 + local stepLen = self.stepFrames or STEP_FRAMES + local p = self.progress % stepLen + return (p >= stepLen / 4 and p < stepLen * 3 / 4) and 1 or 0 end -- Same contract as Player:pose -- the sheet, position, facing and step diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index bfd61826..325991d0 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -384,12 +384,21 @@ function OverworldState:setMap(mapId, x, y, facing, opts) -- NPC instances persist across connection crossings in self.npcPool -- (keyed by NPC.id): a neighbor map's wandering ghosts ARE the -- objects that become the real NPCs when the player crosses the - -- seam, so nothing snaps back to its spawn point in view of the - -- survey zoom. Warps rebuild from scratch, like the original's - -- per-entry sprite init (home/overworld.asm LoadMapHeader - -- .loadSpriteData). + -- seam, so a map the player is not entering keeps its ghosts alive + -- in view of the survey zoom. The map being entered does not -- + -- crossing a seam runs .loadNewMap -> LoadMapHeader, whose + -- .loadSpriteData zeroes the sprite state data and re-seeds every + -- SPRITESTATEDATA2_MAPY/MAPX from the map header's object data + -- (home/overworld.asm), so an NPC who walked up to the player stands + -- on her spawn cell again the next time that map loads (#1028). Only + -- the save-side spawn flags survive, and those live in Game.save, not + -- here. Warps rebuild the whole pool from scratch. if not (opts and opts.seamless and self.npcPool) then self.npcPool = {} + elseif fromMapId ~= mapId then + for _, obj in ipairs(self.map.def.objects or {}) do + self.npcPool[mapId .. "_obj_" .. obj.index] = nil + end end self.npcs = {} for _, obj in ipairs(self.map.def.objects or {}) do @@ -1986,10 +1995,12 @@ function OverworldState:tryHiddenObject(fx, fy) end save.hiddenTaken[key] = true local name = Game.data.items[h.item] and Game.data.items[h.item].name or h.item - -- hidden items always play SFX_GET_ITEM_2 (hidden_items.asm) - require("src.core.Sound").play(Game.data, "Get_Item2") + -- hidden items always play SFX_GET_ITEM_2, and FoundHiddenItemText's + -- text_asm tail runs it as PlaySoundWaitForCurrent + + -- WaitForSoundToFinish once the box has printed (hidden_items.asm) Game.stack:push(TextBox.new(Game, - Strings("%s found\n%s!", save.player.name, name))) + Strings("%s found\n%s!", save.player.name, name), + nil, TextBox.soundOpts(Game, "Get_Item2"))) return true end end @@ -2001,9 +2012,9 @@ function OverworldState:tryHiddenObject(fx, fy) if not save.inventory.COIN_CASE then return false end save.hiddenTaken[key] = true save.coins = math.min(9999, (save.coins or 0) + h.coins) - require("src.core.Sound").play(Game.data, "Get_Item2") Game.stack:push(TextBox.new(Game, - Strings("%s found\n%d coins!", save.player.name, h.coins))) + Strings("%s found\n%d coins!", save.player.name, h.coins), + nil, TextBox.soundOpts(Game, "Get_Item2"))) return true end end @@ -2650,10 +2661,11 @@ function OverworldState:talkTo(npc) end local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item local ddef = Game.data.items[d.item] - require("src.core.Sound").play(Game.data, - (ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1") + -- FoundItemText: text_far, sound_get_item_1, text_end (pick_up_item.asm) Game.stack:push(TextBox.new(Game, - Strings("%s found\n%s!", Game.save.player.name, name))) + Strings("%s found\n%s!", Game.save.player.name, name), nil, + TextBox.soundOpts(Game, + (ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1"))) return end @@ -3182,6 +3194,58 @@ local function giveVictoryItem(reward) return true end +-- A gym's reward text is not one box. Every gym script carries a sound +-- command right after the FIRST label of each reward group +-- (scripts/PewterGym.asm PewterGymBrockReceivedBoulderBadgeText's +-- sound_level_up, PewterGymReceivedTM34Text's sound_get_item_1, and the +-- equivalents in the other seven), and home/text.asm TextCommand_SOUND +-- plays it only once that page has typed out, then blocks on +-- WaitForSoundToFinish before the next page prints. So the pages +-- accumulate and split into separate boxes at the sound points, each box +-- chained off the previous one's button press. +local function rewardChain() + local chain = { boxes = {}, pending = {} } + function chain.flush(sound) + if #chain.pending > 0 then + table.insert(chain.boxes, + { text = table.concat(chain.pending, "\f"), sound = sound }) + chain.pending = {} + end + end + -- one reward group; `sound` rides its first page, where the scripts put it + function chain.add(labels, sound) + local text = Game.data.text or {} + local n = 0 + for _, label in ipairs(labels or {}) do + if text[label] and text[label] ~= "" then + table.insert(chain.pending, text[label]) + n = n + 1 + if n == 1 and sound then chain.flush(sound) end + end + end + end + -- the synthetic stand-in line a reward with no `dialogue` shows + function chain.line(str, sound) + table.insert(chain.pending, str) + if sound then chain.flush(sound) end + end + function chain.push(done) + chain.flush() + local function step(i) + local box = chain.boxes[i] + if not box then + if done then done() end + return + end + local opts = box.sound and TextBox.soundOpts(Game, box.sound) or nil + Game.stack:push(TextBox.new(Game, box.text, + function() step(i + 1) end, opts)) + end + step(1) + end + return chain +end + -- Badges/items awarded after specific battles (data/scripts/victories.lua). -- `deactivate` retires unfought gym/dojo trainers the way the originals' -- SetEvent / SetEventRange do after the leader victory. @@ -3218,44 +3282,31 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex) -- retries the hand-over later (offerGymTm via gyms.lua) tmGiven = giveVictoryItem(reward) end - local lines = {} + local chain = rewardChain() if reward.dialogue then - local text = Game.data.text or {} - for _, label in ipairs(reward.dialogue) do - if text[label] and text[label] ~= "" then - table.insert(lines, text[label]) - end - end + chain.add(reward.dialogue, reward.badgeSound) if reward.item then - for _, label in ipairs(reward.tmPre or {}) do - if text[label] and text[label] ~= "" then - table.insert(lines, text[label]) - end - end + chain.add(reward.tmPre) if tmGiven then - for _, label in ipairs(reward.tmDialogue or {}) do - if text[label] and text[label] ~= "" then - table.insert(lines, text[label]) - end - end - elseif reward.noRoom and text[reward.noRoom] and text[reward.noRoom] ~= "" then - table.insert(lines, text[reward.noRoom]) + chain.add(reward.tmDialogue, reward.tmSound) + else + chain.add({ reward.noRoom }) end end elseif reward.badge or reward.item then if reward.badge then local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name or reward.badge - table.insert(lines, Strings("%s received\nthe %s!", Game.save.player.name, name)) + chain.line(Strings("%s received\nthe %s!", Game.save.player.name, name), + reward.badgeSound) end if tmGiven then local name = Game.stringBuffer or reward.item - table.insert(lines, Strings("%s received\n%s!", Game.save.player.name, name)) + chain.line(Strings("%s received\n%s!", Game.save.player.name, name), + reward.tmSound) end end - if #lines > 0 then - Game.stack:push(TextBox.new(Game, table.concat(lines, "\f"))) - end + chain.push() self:runVictoryHook() end @@ -3266,24 +3317,14 @@ end -- show again, then the same GiveItem check decides between the received -- lines and the "make room" text. function OverworldState:offerGymTm(reward, done) - local text = Game.data.text or {} - local lines = {} - local function addLine(label) - if label and text[label] and text[label] ~= "" then - table.insert(lines, text[label]) - end - end - for _, label in ipairs(reward.tmPre or {}) do addLine(label) end + local chain = rewardChain() + chain.add(reward.tmPre) if giveVictoryItem(reward) then - for _, label in ipairs(reward.tmDialogue or {}) do addLine(label) end + chain.add(reward.tmDialogue, reward.tmSound) else - addLine(reward.noRoom) - end - if #lines > 0 then - Game.stack:push(TextBox.new(Game, table.concat(lines, "\f"), done)) - elseif done then - done() + chain.add({ reward.noRoom }) end + chain.push(done) end -- pokered reloads the map after every battle, re-running the map @@ -3661,8 +3702,9 @@ function OverworldState:onStepComplete() end end - -- wild encounters in grass, on water while surfing, or -- on indoor - -- maps whose tileset is not FOREST -- on EVERY tile + -- wild encounters in grass, on water while surfing (at the map's water + -- rate, which is 0 on every indoor map), or -- on indoor maps whose + -- tileset is not FOREST -- on every other tile -- (wild_encounters.asm: caves, towers, the Mansion, Power Plant) -- The cooldown is checked after all other step processing so repel and -- movement systems continue to advance during the protected steps. @@ -3670,10 +3712,10 @@ function OverworldState:onStepComplete() local encDef = Game.data.encounters[self.map.id] local enc local indoor = Game.data.field.indoorEncounters - if p.surfing and encDef and encDef.water and self.map:isWaterCell(p.cellX, p.cellY) then - enc = self:rollEncounter({ grass = encDef.water }, "water") - elseif self.map:isGrassCell(p.cellX, p.cellY) then + if self.map:isGrassCell(p.cellX, p.cellY) then enc = self:rollEncounter(encDef, "grass") + elseif p.surfing and self.map:isWaterCell(p.cellX, p.cellY) then + enc = self:rollEncounter({ grass = encDef and encDef.water }, "water") elseif indoor and self.map.def.index >= indoor.firstIndoorMap and self.map.def.tileset ~= indoor.excludedTileset then enc = self:rollEncounter(encDef, "indoor") @@ -3794,10 +3836,10 @@ function OverworldState:checkBadgeGate() if Game.save.inventory[g.badge] then if not Game.save.flags[passedFlag] then Game.save.flags[passedFlag] = true - -- Route22GateGuardGoRightAheadText plays sound_get_item_1 - require("src.core.Sound").play(Game.data, "Get_Item1") + -- Route22GateGuardGoRightAheadText carries sound_get_item_1 Game.stack:push(TextBox.new(Game, - t["_" .. g.passText] or Strings("Go right ahead!"))) + t["_" .. g.passText] or Strings("Go right ahead!"), + nil, TextBox.soundOpts(Game, "Get_Item1"))) end return false end @@ -3822,11 +3864,11 @@ function OverworldState:checkBadgeGate() and Game.data.items[guard.badge].name or guard.badge if Game.save.inventory[guard.badge] then Game.save.flags[guard.event] = true - -- Route23OhThatIsTheBadgeText plays sound_get_item_1 - require("src.core.Sound").play(Game.data, "Get_Item1") + -- Route23OhThatIsTheBadgeText carries sound_get_item_1 local text = (t["_" .. g.passText] or Strings("Oh! That is the\n{RAM}!")):gsub("{RAM:wNameBuffer}", badgeName) - Game.stack:push(TextBox.new(Game, text)) + Game.stack:push(TextBox.new(Game, text, + nil, TextBox.soundOpts(Game, "Get_Item1"))) return false end -- Route23YouDontHaveTheBadgeYetText plays SFX_DENIED @@ -4470,7 +4512,7 @@ function OverworldState:scriptMove(entity, dir, tiles, onDone) }) end --- A step-in-place beat: the entity plays one walk-cycle animation (16 +-- A step-in-place beat: the entity plays one walk-cycle animation (32 -- frames) without translating, keeping its current facing. Ports the -- NPC_CHANGE_FACING movement byte (engine/overworld/movement.asm -- ChangeFacingDirection -> zero-delta TryWalking), used for Oak marching @@ -4483,7 +4525,7 @@ end -- Advance scripted moves in two phases so a chained step (a new move -- queued by a completing move's onDone) begins the SAME frame the --- previous one ends -- back-to-back 16-frame tiles like the GB's +-- previous one ends -- back-to-back 32-frame tiles like the GB's -- simulated-joypad / NPC scripted movement, with no idle frame between -- tiles. Phase 1 retires finished moves (which may chain new ones); -- phase 2 then starts every not-yet-moving move. diff --git a/src/world/PikachuFollower.lua b/src/world/PikachuFollower.lua index 688c62f6..cd05496b 100644 --- a/src/world/PikachuFollower.lua +++ b/src/world/PikachuFollower.lua @@ -821,11 +821,13 @@ end function PikachuFollower.onFanClubEntered(game, ow) if not (GameVersion.isYellow() and ow.map and ow.map.id == "POKEMON_FAN_CLUB") then return end + local active = game.save.pikachuMapScriptActive + game.save.pikachuMapScriptActive = true + if active then return end local starter = PikachuFollower.starterInParty(game.save) local npc = findFollower(ow) if not npc or (starter and starter.status) then return end ow.pikachuFanClubScene = true - ow.pikachuMapScriptActive = true ow.player.facing = "down" for _, other in ipairs(ow.npcs or {}) do if other.def and other.def.name == "POKEMONFANCLUB_SEEL" then @@ -844,6 +846,13 @@ function PikachuFollower.onBillsHouseEnter(game, ow) if not (GameVersion.isYellow() and ow.map and ow.map.id == "BILLS_HOUSE") then return end + -- BillsHouse_CheckMetBill (scripts/BillsHouse.asm:22-40) sets + -- BIT_PIKACHU_MAP_SCRIPT_ACTIVE and rets nz before it looks at + -- EVENT_MET_BILL_2; the bit rides sMainData, so a reload inside the house + -- resumes at BillsHouseScript1's bare ret (#919). + local active = game.save.pikachuMapScriptActive + game.save.pikachuMapScriptActive = true + if active then return end if game.save.flags.EVENT_MET_BILL_2 then return end -- BillsHouseScript0 (scripts/BillsHouse.asm:41-47) only runs the confused -- walk while CheckPikachuStatusCondition comes back clear diff --git a/tests/drivers/evolution_black_bug279_test.lua b/tests/drivers/evolution_black_bug279_test.lua index 8d9ca4e5..ffb7eb9c 100644 --- a/tests/drivers/evolution_black_bug279_test.lua +++ b/tests/drivers/evolution_black_bug279_test.lua @@ -191,12 +191,12 @@ return function(game) return cond() end - -- Jump the flash clock to just under FLASH_FRAMES (220) once the shot is + -- Jump the flash clock to just under FLASH_FRAMES (368) once the shot is -- taken. EvolutionState:update only compares self.t against that constant, -- so this ends the animation early without touching the palette logic. local function skipToEnd() local st = evoTop() - if st then st.t = 214 end + if st then st.t = 362 end end local function startEvo(species, into) @@ -212,7 +212,7 @@ return function(game) -- ---- case 1: the reported case, SGB, mid-flash -------------------------- local weedle = startEvo("WEEDLE", "KAKUNA") - U.wait(24) -- into the flash, far short of FLASH_FRAMES = 220 + U.wait(24) -- into the flash, far short of FLASH_FRAMES = 368 U.shot(game, DIR .. "/evo279_1_flash_sgb.png") local c1 = probe("flash/SGB", { monYellow1 = YELLOW[2], monYellow2 = YELLOW[3], diff --git a/tests/drivers/evolution_cancel_bug213_test.lua b/tests/drivers/evolution_cancel_bug213_test.lua index 46dbc006..db71c43a 100644 --- a/tests/drivers/evolution_cancel_bug213_test.lua +++ b/tests/drivers/evolution_cancel_bug213_test.lua @@ -6,7 +6,7 @@ -- LINK_STATE_TRADING) skip that poll and cannot be cancelled. -- -- Case 1 (level path, cancelable): open EvolutionState directly, wait past --- the 80-frame pre-animLoop delay (still well under FLASH_FRAMES=220), +-- the 80-frame pre-animLoop delay (still well under FLASH_FRAMES=368), -- press B, and assert the mon stays CATERPIE with the "stopped evolving" -- text on screen. -- Case 1b: after cancel, checkParty with no level-ups must not re-offer; @@ -81,7 +81,7 @@ return function(game) Evolution.evolve(game, mon, "METAPOD", function() done1 = true end) if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end - U.wait(100) -- past the 80-frame pre-animLoop delay, still under 220 + U.wait(100) -- past the 80-frame pre-animLoop delay, still under 368 U.log("case1 flash", "t=", top().t, "species=", mon.species) U.shot(game, DIR .. "/evo213_1_evolving.png") @@ -133,8 +133,8 @@ return function(game) local done2 = false Evolution.evolve(game, mon2, "METAPOD", function() done2 = true end) if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end - -- let the full flash run (FLASH_FRAMES=220) without pressing B - waitFor(function() return not evoTop() end, 400) + -- let the full flash run (FLASH_FRAMES=368) without pressing B + waitFor(function() return not evoTop() end, 500) if not waitFor(function() return findText("evolved into") ~= nil end, 120) then error("Congratulations text not shown (case2)") end diff --git a/tests/drivers/evolution_move_bug12_test.lua b/tests/drivers/evolution_move_bug12_test.lua index d390389c..731690a1 100644 --- a/tests/drivers/evolution_move_bug12_test.lua +++ b/tests/drivers/evolution_move_bug12_test.lua @@ -89,8 +89,8 @@ return function(game) Evolution.evolve(game, mon, "GYARADOS", function() done1 = true end) if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end - -- let the full flash run (FLASH_FRAMES=220) with no input, then apply - waitFor(function() return not evoTop() end, 400) + -- let the full flash run (FLASH_FRAMES=368) with no input, then apply + waitFor(function() return not evoTop() end, 500) if not waitFor(function() return findText("evolved into") ~= nil end, 120) then error("Congratulations text not shown (case1)") end @@ -124,7 +124,7 @@ return function(game) local done2 = false Evolution.evolve(game, mon2, "GYARADOS", function() done2 = true end) if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end - waitFor(function() return not evoTop() end, 400) + waitFor(function() return not evoTop() end, 500) if not waitFor(function() return findText("evolved into") ~= nil end, 120) then error("Congratulations text not shown (case2)") end diff --git a/tests/drivers/trainer_reset_bug1028_test.lua b/tests/drivers/trainer_reset_bug1028_test.lua new file mode 100644 index 00000000..f1608f22 --- /dev/null +++ b/tests/drivers/trainer_reset_bug1028_test.lua @@ -0,0 +1,188 @@ +-- Driver: a trainer who walked up to the player must be back on her spawn +-- cell the next time her map loads, connection crossings included (#1028). +-- .loadNewMap calls LoadMapHeader for a seam crossing exactly as a warp does, +-- and .loadSpriteData zeroes the sprite state data and re-seeds every +-- SPRITESTATEDATA2_MAPY/MAPX from the map header's object data +-- (pokered home/overworld.asm), so only the save-side defeat flag survives. +-- +-- Route 3's Lass (data/maps/objects/Route3.asm object_event 23, 4, +-- SPRITE_COOLTRAINER_F, STAY, LEFT, ..., OPP_LASS) has sight range 4, so +-- standing above the third Bug Catcher at (19,4) is inside her line and she +-- walks dist-1 = 3 cells west to (20,4) -- the reporter's exact setup. +-- +-- POKEPORT_DRIVER=tests/drivers/trainer_reset_bug1028_test.lua \ +-- POKEPORT_IDENTITY=bug1028 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local Zoom = require("src.render.Zoom") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local MAP = "ROUTE_3" + local TARGET = 6 + local SPAWN = { x = 23, y = 4 } + local STAND = { x = 19, y = 4 } + + local pass = true + local function check(label, ok) + if not ok then pass = false end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local function lass() + local ow = game.overworld + for _, n in ipairs(ow and ow.npcs or {}) do + if n.def and n.def.index == TARGET then return n end + end + return nil + end + + local function at(n) return n and (n.cellX .. "," .. n.cellY) or "absent" end + + -- hold a direction until cond() or the budget runs out, then let the + -- half-finished step land + local function holdUntil(btn, cond, budget) + local first = true + for _ = 1, budget or 900 do + if cond() then break end + if first then table.insert(game.input.pressQueue, btn); first = false end + game.input.state[btn] = true + coroutine.yield() + end + game.input.state[btn] = false + for _ = 1, 40 do + local ow = game.overworld + if ow and not ow.player.moving then break end + coroutine.yield() + end + U.wait(4) + return cond() + end + + local function walkTo(btn, axis, want, budget) + return holdUntil(btn, function() + local p = game.overworld and game.overworld.player + return p and p[axis] == want and not p.moving + end, budget) + end + + local function onMap(id) + return game.overworld and game.overworld.map and game.overworld.map.id == id + end + + -- mash A until the battle stack unwinds back to the overworld + local function mashToOverworld(budget) + for _ = 1, budget or 4000 do + if game.stack:top() == game.overworld and not game.overworld.engaging then + return true + end + U.tap(game, "a") + U.wait(3) + end + return false + end + + -- absolute zoom: reset first so every shot frames the same amount of world + local function survey(n) + Zoom.reset() + U.wait(2) + for _ = 1, n do game:zoomStep(-1) end + U.wait(30) + end + + -- back up to the sighting cell so the before/after shots share a camera + local function returnToStand() + walkTo("right", "cellX", 11) + walkTo("up", "cellY", 4) + walkTo("right", "cellX", STAND.x) + end + + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 80), + Pokemon.new(game.data, "SNORLAX", 80), + } + game.save.player.name = "MATT" + + U.teleport(game, MAP, STAND.x, STAND.y, "right") + U.wait(20) + + -- only the target Lass may engage: the reporter has already cleared the + -- Bug Catchers and Youngsters ahead of her + local ow = game.overworld + for _, n in ipairs(ow.npcs) do + if n.def.trainerClass and n.def.index ~= TARGET then + game.save.defeatedTrainers[n.id] = true + end + end + + local l = lass() + check("Lass starts on her spawn cell " .. SPAWN.x .. "," .. SPAWN.y, + l ~= nil and l.cellX == SPAWN.x and l.cellY == SPAWN.y) + U.log("spawn:", at(l)) + + -- she sights the player and walks up, then the battle runs + for _ = 1, 600 do + if ow.engaging then break end + coroutine.yield() + end + check("the Lass sighted the player at " .. STAND.x .. "," .. STAND.y, ow.engaging == true) + check("the battle ran to completion", mashToOverworld()) + U.wait(30) + + l = lass() + local movedX, movedY = l and l.cellX, l and l.cellY + U.log("after the walk-up she stands at", at(l)) + check("she is off her spawn cell after walking up", + l ~= nil and not (movedX == SPAWN.x and movedY == SPAWN.y)) + check("she is recorded as defeated", game.save.defeatedTrainers[l.id] == true) + + survey(4) + U.shot(game, DIR .. "/bug1028_1_walked_up.png") + Zoom.reset() + U.wait(5) + + -- west along row 4, down the single gap at column 11, then west on row 9 + -- to the Pewter City seam + walkTo("left", "cellX", 11) + walkTo("down", "cellY", 9) + check("reached the descent column", game.overworld.player.cellX == 11 + and game.overworld.player.cellY == 9) + + -- crossing x=0 westward is the seam: crossConnection, not a warp + holdUntil("left", function() return onMap("PEWTER_CITY") end, 1200) + check("crossed the seam into Pewter City", onMap("PEWTER_CITY")) + + survey(12) + U.shot(game, DIR .. "/bug1028_2_pewter_survey.png") + Zoom.reset() + U.wait(5) + + -- and back east into Route 3, the map load that must re-seed her + holdUntil("right", function() return onMap(MAP) end, 1200) + check("crossed back into Route 3", onMap(MAP)) + U.wait(30) + + l = lass() + U.log("after the return crossing she stands at", at(l)) + check("she is back on her spawn cell " .. SPAWN.x .. "," .. SPAWN.y, + l ~= nil and l.cellX == SPAWN.x and l.cellY == SPAWN.y) + check("she is still recorded as defeated", + l ~= nil and game.save.defeatedTrainers[l.id] == true) + + -- same cell, same zoom as shot 1: she should now be three cells further + -- east, and being defeated she must not re-engage on the way back in + returnToStand() + check("walked back to the sighting cell", + game.overworld.player.cellX == STAND.x + and game.overworld.player.cellY == STAND.y) + check("a defeated trainer does not re-engage", game.overworld.engaging ~= true) + + survey(4) + U.shot(game, DIR .. "/bug1028_3_reset.png") + + Zoom.reset() + U.log(pass and "RESULT PASS" or "RESULT FAIL") + love.event.quit(pass and 0 or 1) + while true do coroutine.yield() end +end diff --git a/tests/engine/party_fieldmove_order_bug792.lua b/tests/engine/party_fieldmove_order_bug792.lua index 6e93110f..a134df38 100644 --- a/tests/engine/party_fieldmove_order_bug792.lua +++ b/tests/engine/party_fieldmove_order_bug792.lua @@ -20,7 +20,7 @@ local PartyMenu = require("src.ui.PartyMenu") -- minimal stack/input doubles matching the StateStack and Input surfaces, -- plus an overworld stub: PALLET_TOWN's OVERWORLD tileset passes --- CheckIfInOutsideMap, and the badges cover the list-time HM gates +-- CheckIfInOutsideMap local function newGame(moves, inventory) local game = { data = { pokemon = { LAPRAS = { name = "LAPRAS" }, @@ -87,14 +87,15 @@ openSubmenu(pm2) same(actions(pm2.subItems), { "stats", "switch" }, "no field moves: the submenu is just STATS/SWITCH") --- the badge gates still filter the list: the same Lapras without the --- badges keeps its moves but shows none of them +-- GetMonFieldMoves is badge-blind: the same Lapras without the badges +-- still lists both HMs, and .outOfBattleMovePointers refuses on +-- selection instead (#1022) local noBadges = newGame( { { id = "STRENGTH", pp = 15 }, { id = "SURF", pp = 15 } }) local pm3 = PartyMenu.new(noBadges, {}) noBadges.stack:push(pm3) openSubmenu(pm3) -same(actions(pm3.subItems), { "stats", "switch" }, - "ungated badges keep the HM moves out of the submenu") +same(actions(pm3.subItems), { "strength", "surf", "stats", "switch" }, + "the HM moves are listed without the badges (#1022)") T.finish("party_fieldmove_order_bug792") diff --git a/tests/engine/viridian_fisher_pre_bug775.lua b/tests/engine/viridian_fisher_pre_bug775.lua index 134fc120..f40af157 100644 --- a/tests/engine/viridian_fisher_pre_bug775.lua +++ b/tests/engine/viridian_fisher_pre_bug775.lua @@ -16,6 +16,7 @@ local T = require("tests.modkit") local boxes = {} package.loaded["src.render.TextBox"] = { new = function(_, s, done) return { text = s, onDone = done } end, + soundOpts = function() return {} end, } package.loaded["src.core.Sound"] = { play = function() end } package.loaded["src.inventory.Bag"] = { diff --git a/tests/link_desync_fuzz.lua b/tests/link_desync_fuzz.lua index a1bcd603..c819a24f 100644 --- a/tests/link_desync_fuzz.lua +++ b/tests/link_desync_fuzz.lua @@ -187,6 +187,13 @@ local function runOne(seed) local function drive(side) local bt = side.bt if bt.result then return end + local top = side.game.stack:top() + if top and top.forceSwitch and top.party then + for i, mon in ipairs(top.party) do + if mon.hp > 0 then top.index = i break end + end + return + end if bt.phase ~= "menu" then side.menuFrames = 0 end if bt.phase == "moveSelect" then local usable = {} diff --git a/tests/link_tournament16.lua b/tests/link_tournament16.lua index 7a893cb4..c1985434 100644 --- a/tests/link_tournament16.lua +++ b/tests/link_tournament16.lua @@ -181,6 +181,13 @@ local function playMatch(hostEntry, guestEntry, rnd, label) local function drive(side) local bt = side.bt if bt.result then return end + local top = side.game.stack:top() + if top and top.forceSwitch and top.party then + for i, mon in ipairs(top.party) do + if mon.hp > 0 then top.index = i break end + end + return + end if bt.phase ~= "menu" then side.menuFrames = 0 end if bt.phase == "moveSelect" then local usable = {} diff --git a/tests/mod_battle_tests.lua b/tests/mod_battle_tests.lua index e42df2e4..e0c74e04 100644 --- a/tests/mod_battle_tests.lua +++ b/tests/mod_battle_tests.lua @@ -604,7 +604,7 @@ do local game = uiGame({ mon }) Bag.add(game.save, "RARE_CANDY", 1) game.stack:push(BagMenu.new(game)) - for _ = 1, 600 do + for _ = 1, 800 do local top = game.stack:top() if not top then break end pressed = { a = true } diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 4d9ecaf3..6ac0b329 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -584,7 +584,9 @@ check(not fpm.submenu and forced == fgame.save.party[1], -- ------- issues #320/#385: the STRENGTH texts print over the party menu do local owStub = { strengthActive = false, - map = { def = { tileset = "OVERWORLD" } }, dark = false } + map = { def = { tileset = "OVERWORLD" } }, dark = false, + partyKnows = function(self, id) return self.knows == id end, + knows = "STRENGTH" } local sgame = partyGame() sgame.overworld = owStub sgame.data.text = {} -- the strength texts fall back to Strings sources diff --git a/tests/modkit/link.lua b/tests/modkit/link.lua index 01c2ffc3..d11f596e 100644 --- a/tests/modkit/link.lua +++ b/tests/modkit/link.lua @@ -49,6 +49,14 @@ function Link.prepare(data) return Input end +local function steerReplacement(game) + local top = game.stack:top() + if not (top and top.forceSwitch and top.party) then return end + for i, mon in ipairs(top.party) do + if mon.hp > 0 then top.index = i return end + end +end + -- run a full lockstep battle over a loopback pair, mashing A on both -- sides, and report whether any turn's hashes disagreed function Link.lockstep(gameA, gameB, opts) @@ -80,7 +88,9 @@ function Link.lockstep(gameA, gameB, opts) while (resA == nil or resB == nil) and guard < limit do guard = guard + 1 Input.pressed = { a = true } + steerReplacement(gameA) gameA.stack:update(1 / 60) + steerReplacement(gameB) gameB.stack:update(1 / 60) end diff --git a/tests/parity_A.lua b/tests/parity_A.lua index 7c15caa0..7084a76f 100644 --- a/tests/parity_A.lua +++ b/tests/parity_A.lua @@ -315,12 +315,19 @@ do local Renderer = require("src.render.Renderer") local SaveData = require("src.core.SaveData") local OW = require("src.world.OverworldController") + -- the reward text is a CHAIN of boxes split at each gym script's sound + -- command, so walk it: read a box's pages, close it, let its onDone push + -- the next one local function stackedDialogue() - local top = Game.stack:top() - if not (top and top.pages) then return "" end local parts = {} - for _, page in ipairs(top.pages) do - parts[#parts + 1] = table.concat(page, "\n") + local top = Game.stack:top() + while top and top.pages do + for _, page in ipairs(top.pages) do + parts[#parts + 1] = table.concat(page, "\n") + end + Game.stack:pop() + if top.onDone then top.onDone() end + top = Game.stack:top() end return table.concat(parts, "\n") end diff --git a/tests/parity_I_M.lua b/tests/parity_I_M.lua index 6213e265..3d7df56d 100644 --- a/tests/parity_I_M.lua +++ b/tests/parity_I_M.lua @@ -176,7 +176,8 @@ check(not onStack(pmSurf), "party menu closes after a successful SURF") check(sawText("got on"), "_SurfingGotOnText shown on a successful SURF") -- =========================================================================== --- I: list-time badge filter, CUT/SURF/STRENGTH absent without the badge +-- I: GetMonFieldMoves is badge-blind -- CUT/SURF/STRENGTH are listed with or +-- without the badge, and .outOfBattleMovePointers refuses on selection (#1022) -- =========================================================================== Game.save.party = { mkMon("SQUIRTLE", "CUT", "SURF", "STRENGTH") } Game.save.inventory = {} @@ -185,8 +186,8 @@ local pmNoBadge = PartyMenu.new(Game) Game.stack:push(pmNoBadge) frame({ "a" }) local actsOff = submenuActions(pmNoBadge) -check(not actsOff.cut and not actsOff.surf and not actsOff.strength, - "no CUT/SURF/STRENGTH submenu entries without the required badges") +check(actsOff.cut and actsOff.surf and actsOff.strength, + "CUT/SURF/STRENGTH submenu entries listed without the badges (#1022)") popToOW() Game.save.inventory = { CASCADEBADGE = true, SOULBADGE = true, RAINBOWBADGE = true } local pmBadge = PartyMenu.new(Game) @@ -194,7 +195,7 @@ Game.stack:push(pmBadge) frame({ "a" }) local actsOn = submenuActions(pmBadge) check(actsOn.cut and actsOn.surf and actsOn.strength, - "CUT/SURF/STRENGTH submenu entries appear once the badges are held") + "CUT/SURF/STRENGTH submenu entries still there once the badges are held") -- =========================================================================== -- I: CUT from the party menu. The Cerulean tree BLOCK (50, at block 9,14) @@ -471,8 +472,9 @@ local pmLobby = PartyMenu.new(Game) Game.stack:push(pmLobby) frame({ "a" }) local actsLobby = submenuActions(pmLobby) -check(not actsLobby.fly, "FLY omitted inside Indigo Plateau lobby") -check(not actsLobby.escape, "TELEPORT omitted inside Indigo Plateau lobby") +check(actsLobby.fly, "FLY still listed inside Indigo Plateau lobby (#1022)") +check(actsLobby.escape, + "TELEPORT still listed inside Indigo Plateau lobby (#1022)") popToOW() -- restore fainted field-move mon for the STRENGTH/SURF cases below @@ -486,8 +488,8 @@ Game.save.inventory = { ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right") clearCaptured() local pmFaintStr = PartyMenu.new(Game) --- Seafoam is not OVERWORLD, so FLY is omitted: CUT, STRENGTH, SURF, STATS, SWITCH -selectSubItem(pmFaintStr, 2) +-- move order on the mon: FLY, CUT, STRENGTH, SURF, then STATS, SWITCH +selectSubItem(pmFaintStr, 3) eq(Game.overworld.strengthActive, true, "fainted mon can activate STRENGTH from the party menu") check(sawText("used") and sawText("STRENGTH"), diff --git a/tests/parity_gym_tm_bag_full_bug797.lua b/tests/parity_gym_tm_bag_full_bug797.lua index 2117fe87..84906c55 100644 --- a/tests/parity_gym_tm_bag_full_bug797.lua +++ b/tests/parity_gym_tm_bag_full_bug797.lua @@ -72,13 +72,19 @@ Game.input = Input; Input:init() Game.renderer = Renderer; Renderer:init() Game.stack = StateStack; StateStack:init() --- concatenates the pages of the TextBox checkVictoryRewards pushed +-- concatenates the pages of the box CHAIN checkVictoryRewards pushed: the +-- reward text splits into a box per gym-script sound command, so close each +-- one and let its onDone push the next local function stackedDialogue() - local top = Game.stack:top() - if not (top and top.pages) then return "" end local parts = {} - for _, page in ipairs(top.pages) do - parts[#parts + 1] = table.concat(page, "\n") + local top = Game.stack:top() + while top and top.pages do + for _, page in ipairs(top.pages) do + parts[#parts + 1] = table.concat(page, "\n") + end + Game.stack:pop() + if top.onDone then top.onDone() end + top = Game.stack:top() end return table.concat(parts, "\n") end diff --git a/tests/parity_yellow_bills_pikachu.lua b/tests/parity_yellow_bills_pikachu.lua index c0437c36..20e9420d 100644 --- a/tests/parity_yellow_bills_pikachu.lua +++ b/tests/parity_yellow_bills_pikachu.lua @@ -178,6 +178,7 @@ check(#moves == 0, "the parked Pikachu of the confused beat stays put") -- BillsHouseScript0 skips the whole entry beat for a statused starter -- (CheckPikachuStatusCondition, scripts/BillsHouse.asm:45-46) ow.pikachuBillsScene = nil +yellowGame.save.pikachuMapScriptActive = nil moves = {} yellowGame.save.party = { { species = "PIKACHU", hp = 12, status = "PAR" } } PikachuFollower.onBillsHouseEnter(yellowGame, ow) diff --git a/tests/parity_yellow_disabled_pikachu.lua b/tests/parity_yellow_disabled_pikachu.lua index 86625442..ef83038e 100644 --- a/tests/parity_yellow_disabled_pikachu.lua +++ b/tests/parity_yellow_disabled_pikachu.lua @@ -42,7 +42,7 @@ local ow = { Follower.onFanClubEntered({ save = save, data = Data }, ow) check(ow.pikachuFanClubScene, "Fan Club disables normal Pikachu following") -check(ow.pikachuMapScriptActive, "Fan Club sets the map-script flag") +check(save.pikachuMapScriptActive, "Fan Club sets the map-script flag") eq(ow.player.facing, "down", "Fan Club resets the player direction") eq(moves[1] and moves[1][1], "up", "Fan Club starts with slide-up displacement") eq(moves[1] and moves[1][2], 1, "Fan Club slide-up spans one tile") diff --git a/tests/run_tests.lua b/tests/run_tests.lua index d7122562..149483aa 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -2562,8 +2562,8 @@ do end eq(fanfares, 1, "one caught fanfare per capture") eq(tinks, 3, "three wobble tinks on a $43 capture") - check(fanfareAt and caughtAt and fanfareAt < caughtAt, - "Caught_Mon sounds with the caught text, not after its dismissal") + check(fanfareAt and caughtAt and caughtAt < fanfareAt, + "Caught_Mon sounds once the caught text is out, before its prompt") eq(cb4.result, "caught", "the capture resolved the battle") -- the nickname AskName that follows clears it (ClearSprites), so the -- assertion is sampled while the caught text is up