diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index b84aa405..9c6ae321 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -2,6 +2,8 @@ -- ghost, elevators, the Game Corner coins/prizes, the SS Anne departure -- and the Hall of Fame record. Each cites its pokered source. +local Runtime = require("src.mods.Runtime") + local M = {} -- ------------------------------------------------------------------- @@ -908,17 +910,34 @@ M.VERMILION_DOCK = { local f = game.save.flags if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then -- the ship is long gone: erase her right away, and anyone who - -- still lands here is sent back out past the guard + -- still lands here is sent back out past the guard unless a mod + -- explicitly permits this occupied map state. This hook surrounds + -- only the ejection decision; map-script registration and dispatch + -- stay unchanged, and the departed ship remains erased. for _, b in ipairs(DOCK_SHIP_BLOCKS) do ow.map:setBlock(b.bx, b.by, b.water) end ow.map.renderer:rebuild() - local TextBox = require("src.render.TextBox") - game.stack:push(TextBox.new(game, - game.data.text._VermilionCitySailor1ShipSetSailText - or "The ship set sail.", function() - ow:startWarpTo("VERMILION_CITY", 18, 29, "up") - end)) + local occupancyAllowed = false + if Runtime.wantsHook("map.occupancy_allowed") then + local player = ow.player or {} + occupancyAllowed = Runtime.call("map.occupancy_allowed", + function() return false end, game, { + mapId = "VERMILION_DOCK", + reason = "ss_anne_departed", + gameVersion = game.save and game.save.version, + x = player.cellX, + y = player.cellY, + }) == true + end + if not occupancyAllowed then + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, + game.data.text._VermilionCitySailor1ShipSetSailText + or "The ship set sail.", function() + ow:startWarpTo("VERMILION_CITY", 18, 29, "up") + end)) + end elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then -- VermilionDockSSAnneLeavesScript: only stepping OFF the ship -- triggers the departure (wDestinationWarpID == 1 in pokered) diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index 1e42c91f..ddc00b15 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -469,8 +469,11 @@ is warned once per name and the rest of the list still runs. The engine's own Gen 1 verbs are **not** seeded on Gold: a row-list verb handed Gold's ctx would find no runner on it, so `data.commands` under Gen 2 is the mod verbs alone. -**`mod.save`, `mod.options`, `mod.log`, `mod.assets`, `mod.find`, exports.** -Generation-agnostic; nothing to adapt. +**`mod.save`, `mod.options`, `mod.log`, `mod.assets`, `mod.find`, +`mod.developer`, exports.** Generation-agnostic; nothing to adapt. +`mod.developer` is the same fixed boot-time boolean on both generations and is +available while the entry chunk runs. Gold does not gain Gen 1's developer +console or F5 hot-reload hotkey; the field reports the loader's mode only. **`mod.world`.** Same method set, resolved against Gold's world (`src/world/gen2/WorldAPI.lua`). Two differences show through and are @@ -783,6 +786,9 @@ name and the existing payload, plus fields where Gen 2 genuinely carries more The list is much shorter than it was. What is outstanding, in descending value: +- `battle.field_residual`: the first guarded call site is in Gen 1 end-of-round + processing. Gold already has a native weather/between-turn pipeline but does + not yet expose the shared data-only descriptor hook. - `trainer.before_battle`: Gold constructs and pushes its trainer battle in `src/world/gen2/World.lua:startBattle`, which does not yet expose a deferred preparation boundary or a battle-local player-party view. Gen 1 mods can use diff --git a/docs/modding.md b/docs/modding.md index 587b1fc4..28a38c1d 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -228,6 +228,66 @@ optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at read-only snapshots; mods choose which layers to render. Red and Gold expose the same contract while applying their own object and event visibility rules. +### Active Gen 1 block checks + +Red, Blue, and Yellow expose +`mod.world:activeBlockAt(mapId, blockX, blockY)`. It returns the numeric block +ID at one zero-based block coordinate only when `mapId` is the active map. +The value is a scalar snapshot: changing it cannot change the map. This lets a +mod compare a small runtime map signature before it applies a lawful authored +replacement, without reading the mutable map or ROM cache through engine +internals. + +The method fails closed. Before an overworld exists it returns +`nil, "no overworld"`; for a different active map it returns +`nil, "map is not active"`; non-numeric, non-finite, or fractional coordinates +return `nil, "invalid block coordinates"`; and negative or out-of-range +coordinates return `nil, "block coordinates out of bounds"`. An unavailable +or malformed active block returns `nil, "block unavailable"`. The caller must +require every expected cell to match before changing presentation. This method +is Gen 1-only; Gold callers receive no parity promise for it. + +The same unavailable result covers missing or sparse active block storage and +an accessor result that does not match its validated active block slot. + +### Conditional map occupancy + +`map.occupancy_allowed` is a narrow Gen 1 hook around a map script's vanilla +decision to eject the player from an otherwise valid loaded map. Its first +call site is the post-departure `VERMILION_DOCK` branch. The ship has already +been erased when the hook runs, and the hook does not replace or suppress any +base or peer map handler. + +The wrapper receives `(next, game, context)`. The dock context is a copied +`{ mapId = "VERMILION_DOCK", reason = "ss_anne_departed", gameVersion, x, y }` +record. Vanilla returns `false`. Return exactly `true` to allow the player to +remain; every other value denies occupancy and preserves the normal message +and warp. A composable wrapper calls downstream first and only adds its own +permission: + +```lua +mod.hooks:wrap("map.occupancy_allowed", function(next, game, ctx) + local allowed = next(game, ctx) + local mine = ctx.mapId == "VERMILION_DOCK" + and ctx.reason == "ss_anne_departed" + and myPublicEligibilityCheck(game) + return allowed == true or mine == true +end) +``` + +With no wrapper, the hook allocates no context and vanilla behavior is +unchanged. A throwing wrapper is isolated by the normal hook bus. A nil, +string, number, table, or other malformed final answer fails closed. Disabling +or uninstalling the permitting mod therefore restores vanilla ejection without +changing the S.S. Anne story flag or restoring the ship. + +Normal hook-chain ownership applies: a wrapper that does not call `next` +intentionally owns the final answer and does not run lower-priority wrappers. +Permission wrappers must call `next` as shown above to compose. A noncompliant +wrapper that returns false without calling `next` safely denies occupancy and +can suppress downstream permission by this standard rule. A malformed answer +also fails closed and cannot force occupancy. + ## Party ordering Companion UIs and alternate party screens can call @@ -265,6 +325,80 @@ Red exposes FLY separately because it requires a destination picker: and `mod.world:flyTo(mapId)` accepts only a visited destination from the native Fly town list. Gold does not expose these two methods yet. +## Self-driven world actors + +`mod.world:spawnNpc()` returns a handle whose `scriptMove` queues onto the +overworld's scripted-movement list. A non-empty list is how the overworld +knows a cutscene is running, so it gates player input for as long as the +actor walks -- right for Oak marching to his lab, wrong for an actor that +moves on its own schedule (a networked player's ghost, an ambient walker). +Five handle methods drive one without that lockout: + +```lua +local ghost = mod.world:spawnNpc({ map = "ROUTE_1", x = 5, y = 7, + sprite = "SPRITE_RED" }) +if ghost:canStep("up") then ghost:stepNow("up") end -- one tile, now +if not ghost:isMoving() then ghost:placeAt(9, 3, "down") end -- snap, no walk +ghost:setPassable(true) -- walk-through +``` + +`stepNow(dir)` sets the same per-tile state `scriptMove` does, minus the +queue. It deliberately does **not** check collision: a caller replaying a +move that was already decided elsewhere (validated on a peer's machine, or +authored) would let the two copies disagree about where the actor is if this +re-judged it. Ask `canStep(dir)` first when you do want the map's opinion. +`placeAt(x, y, facing)` snaps with no animation and clears any step in +flight, for a warp arrival or a resync too far gone to walk off. +`isMoving()` lets a driver pace itself instead of stomping a move already +running. `setPassable(flag)` is the flag `Collision.occupied` skips (the +engine's own user is Yellow's companion Pikachu); a passable object still +draws and can still be talked to. + +An object spawned this way carries no `TEXT_*` id, so the vanilla talk path +has nothing to say for it. The **`world.talk`** hook is the A press on an +object, raised before the map's text tables get it: + +```lua +mod.hooks:wrap("world.talk", function(next, ow, target) + if mine(target) then + say(target) -- the mod answers for an object it owns + return -- ...by not calling next() + end + return next(ow, target) -- everything else falls through unchanged +end) +``` + +With no subscriber the A press reaches `talkTo` exactly as before. An object +mid-step raises no hook, matching the vanilla gate. + +## Adopting an already-paired link session + +`LinkState.newFromSession(game, transport, mode, isHost, opts)` starts a link +session on a transport that is *already* paired, skipping the address/code +entry UI while keeping the hello and fingerprint compatibility exchange +intact. `transport` is anything `Session` accepts, which is what lets a mode +tunnel a battle through its own connection rather than opening a second one. + +When the battle finishes, **`link.battle_ended`** reports the outcome: + +```lua +mod.events:on("link.battle_ended", function(ev) + -- ev = { result, myParty, theirParty, peerName, role } +end) +``` + +The party copies are the point. Cable rules leave the real party untouched, +so a mode built on link battles -- a tournament ladder, a battle royale -- +has no other way to learn what the fight cost, and by the time the state +unwinds the battle object is gone. `role` is `"host"` or `"guest"`. + +Two smaller pieces support the same shape of mode. `Game:startNewGame(opts)` +is the title screen's NEW GAME closure made callable, with `opts.intro = +false` to land straight in the world -- a mode that hands out its own starting +state has no use for Oak's speech. `CodeEntry.new` takes an optional +`{ length = , charset = }`, so the slot-scrub widget that enters a link code +can also carry a room code or an address. + ## Read-only battle snapshots `mod.battle:snapshot()` returns `nil` outside a battle and a copied battle @@ -687,15 +821,38 @@ the hook context. ## Developer console -Boot with developer mode on to unlock the in-game console and hot-reload -hotkeys. Either set `POKEPORT_DEV=1` in the environment or pass -`--developer` on the command line: +On a Gen 1 boot, developer mode unlocks the in-game console and hot-reload +hotkeys. Either set `POKEPORT_DEV=1` in the environment or pass `--developer` +on the command line: ```sh love . --developer ``` -While developer mode is active: +The mod loader independently derives a matching boolean for every sandboxed +entry chunk as `mod.developer`. It is available while the entry file is +loading, so a mod can keep diagnostic commands, screens, and verbose tracing +out of player builds: + +```lua +if mod.developer then + mod.commands:register("my_mod:diagnostics", function(ctx) + -- open or print this mod's diagnostic view + end) +end +``` + +`mod.developer` is a plain boolean snapshot for this boot. It grants no +permission and exposes neither the process environment nor the loader. In a +normal player boot it is `false`; `POKEPORT_DEV=1` and `--developer` make it +`true`. On Gen 1 those inputs separately enable the console and hot-reload +hotkeys. The headless loader's `opts.dev` test seam changes only the loader +signal and diagnostics; it does not enable the game's console or hot reload. +Gold exposes the same `mod.developer` boolean but does not implement the Gen 1 +console or hotkeys. Use a mod option for player-facing feature toggles rather +than treating developer mode as configuration. + +While developer mode is active on Gen 1: - `` ` `` (backtick) opens the console overlay — a Lua REPL with `game`, `data` and `mods` in scope. Press `` ` `` again to close it. @@ -857,6 +1014,47 @@ which stay unconditional and are never visible to a subscriber. Developer mode also arms the mod loader's dev tripwire, which flags mods that reach outside their permission set. +## Battle field residual hook + +`battle.field_residual` lets a Gen 1 battle-rule mod request end-of-round +damage without mutating live battlers. It is guarded and runs after vanilla +status residuals, before field-token expiry and `battle.turn_ended`. The +wrapper receives `(next, context)`, calls `next(context)` for the existing +descriptor list, and appends data-only rows: + +```lua +mod.hooks:wrap("battle.field_residual", function(next, context) + local rows = next(context) + rows[#rows + 1] = { + side = "enemy", amount = 7, + message = context.battlers.enemy.name .. " is buffeted!", + } + return rows +end) +``` + +`context.field` is a detached, data-only view with the same +`{ weather, tokens }` shape that battle checkpoints capture; it does not expose +`field.sides` or any live battler aliases. The projection recursively retains +raw tables and finite numbers, strings, and booleans under scalar keys. It +strips metatables and omits functions, userdata, threads, unsupported keys, and +cyclic edges. Consequently a wrapper cannot obtain or invoke an engine callback +even if a live field token uses one internally, and changing any nested view +value cannot change live field state. `context.battlers.player` and `.enemy` are +detached `{ side, name, hp, maxHp, types, vanished }` views, and `context.turn` +is the current turn number. A descriptor accepts `side` +(`player` or `enemy`), a positive, finite integer number `amount`, and an +optional string `message`. +Numeric strings and invalid rows are ignored; damage is clamped to current HP. +The engine retains HP-bar, faint, experience, and replacement authority. If +both active battlers take terminal residual damage together and the player has +no healthy reserve, this hook batch queues only the player faint authority and +resolves as a blackout loss without an enemy-faint EXP award or replacement. +That precedence is local to accepted rows from this hook; native faint paths +are unchanged when no hook is active. Hook callbacks remain process-local; +checkpoints serialize only field data. Gold does not yet raise this hook; its +native weather pipeline is documented in `docs/mod-api-gen2-compat.md`. + ## Process-lifecycle hooks These exist so a platform-specific launcher integration (a native shell diff --git a/docs/preparing-your-mod-for-gen2.md b/docs/preparing-your-mod-for-gen2.md index 486e6b6a..b4153ea0 100644 --- a/docs/preparing-your-mod-for-gen2.md +++ b/docs/preparing-your-mod-for-gen2.md @@ -768,7 +768,8 @@ the same: So: read the log for coverage problems, and the manager for load problems. `POKEPORT_IDENTITY=` sandboxes the save directory if you want a clean -profile to test in, and `POKEPORT_DEV=1` adds the console and `F5` hot reload. +profile to test in. `POKEPORT_DEV=1` makes `mod.developer` true for loader-gated +diagnostics; Gold does not add Gen 1's console or `F5` hot reload. ## What this guide does not promise diff --git a/docs/rfcs/0013-map-occupancy-and-active-block.md b/docs/rfcs/0013-map-occupancy-and-active-block.md new file mode 100644 index 00000000..64e6aa04 --- /dev/null +++ b/docs/rfcs/0013-map-occupancy-and-active-block.md @@ -0,0 +1,142 @@ +# RFC 0013: Conditional map occupancy and active-block reads + +## Status + +Proposed. + +## Motivation + +The Gen 1 Vermilion Dock script correctly ejects a player who enters after the +S.S. Anne has departed. A content mod can add a city-side route back to that +empty harbor, but it cannot preserve the dock visit: replacing the complete +dock handler would discard vanilla behavior and peer handlers, while an added +handler cannot cancel the base handler's ejection. + +A mod that changes one active map block also needs to prove that it is looking +at the expected Red, Blue, or Yellow layout before it acts. `mapOverview()` is +intentionally presentation-oriented and does not expose block identity. +Requiring internal `Map` state or generated ROM data would cross the public mod +boundary and make a wrong-version edit difficult to fail closed. + +## Decision and plan extended + +This extends Route B in `CONTRIBUTING-mods.md`: new behavior is additive, +ordinary hook composition remains the authority, an empty hook chain is a +provable no-op, and mods receive copied or scalar data rather than mutable +engine state. It supports the approved Mew-under-the-truck implementation plan +without adding any Mew-specific rule, asset, flag, or content to the engine. + +## Exact API delta + +### `map.occupancy_allowed` + +The post-departure `VERMILION_DOCK` script calls this hook after it replaces the +ship blocks with water and immediately before it would display the departure +message and warp the player to Vermilion City. + +A wrapper has this shape: + +```lua +function(next, game, context) -> boolean +``` + +The context is a new table with these fields: + +| Field | Meaning | +|---|---| +| `mapId` | `"VERMILION_DOCK"` at this call site | +| `reason` | Stable reason key `"ss_anne_departed"` | +| `gameVersion` | Active save version (`red`, `blue`, or `yellow`) when present | +| `x`, `y` | Current player cell coordinates when present | + +Vanilla returns `false`. The player remains only when the final chain result is +exactly `true`. Absent, throwing, or malformed wrappers therefore preserve +ejection. A wrapper composes by calling `next(game, context)` and returning +true when either downstream or its own narrow rule permits occupancy. The hook +does not replace `MapScripts` registration, merging, or dispatch, and does not +change the departure flag or reconstruct the ship. + +As with every wrapper hook, a callback that does not call `next` intentionally +owns the final answer and does not run lower-priority callbacks. Permission +wrappers must call downstream to compose. A false, non-forwarding wrapper +safely denies occupancy and can suppress downstream permission by this normal +rule. A malformed final result also fails closed and cannot permit occupancy. + +The call is guarded by `Runtime.wantsHook`, so an empty chain allocates no +context and follows the prior branch exactly. + +### `WorldAPI:activeBlockAt` + +Gen 1's public `mod.world` facade adds: + +```lua +activeBlockAt(mapId, blockX, blockY) -> blockId + | nil, reason +``` + +`mapId` must equal the active map ID. Coordinates are finite, integral, +zero-based block coordinates. A successful result is a numeric scalar copied +from the active runtime map. The method never returns the map's mutable block +array and never writes game or save state. + +Failure reasons are stable: + +| Condition | Reason | +|---|---| +| No active overworld map | `no overworld` | +| `mapId` differs from the active map | `map is not active` | +| Coordinate has the wrong type, is non-finite, or is fractional | `invalid block coordinates` | +| Coordinate is negative or outside the active map | `block coordinates out of bounds` | +| Active block data is absent or malformed | `block unavailable` | + +The block slot and the active map accessor must both contain the same valid +nonnegative integer. Missing or sparse storage and inconsistent accessor data +return `block unavailable`. + +Requiring the expected map ID and rejecting all ambiguous input lets a mod +compare every cell in its version-specific signature before it calls an +existing mutation API. Red, Blue, and Yellow each use their own loaded map +data. Gold does not gain this method in this RFC. + +## Migration and compatibility + +Existing mods change nothing. No hook, event, registry, manifest field, save +field, map handler, or WorldAPI method is removed or renamed. With no hook +subscriber the departed-dock behavior is unchanged. Existing callers cannot +invoke the new WorldAPI method accidentally. + +The occupancy answer is not persisted by the engine. Disabling or uninstalling +a mod removes its wrapper through normal owner cleanup, so a later dock entry +uses vanilla ejection. The engine writes no new save state and neither API +returns or serializes ROM or save data. + +## Verification requirements + +The parity gate must prove: + +- vanilla departed-dock message and warp remain with no subscriber; +- one permission wrapper can allow occupancy without removing the ship-erasure + work or any map handler; +- multiple cooperative wrappers preserve downstream permission; +- absent, throwing, nil, false, malformed, and non-forwarding hook answers fail + closed according to the normal wrapper-chain rule; +- Red, Blue, and Yellow contexts keep their version identity separate; +- disabling or removing the owner restores vanilla behavior without a save + migration; +- `activeBlockAt` accepts only the active map and valid in-range integral + coordinates, returns a scalar, and never mutates map or save state; and +- the test fixtures and resulting changes contain no ROM or save payload. + +The hook must be driven through a real public `hooks:wrap` chain. The block API +must be exercised through a public `WorldAPI` instance. Tests must not replace +the production map handler with a test-only implementation. + +## Docs with the change + +`docs/modding.md` documents both contracts, their failure behavior, the +cooperative wrapper pattern, and the Gen 1-only block method. No registry or +schema changes occur, so generated registry documentation is unchanged. + +## Deprecation etiquette + +Nothing is removed, superseded, or deprecated. diff --git a/docs/rfcs/0014-mod-driven-actors-and-adopted-link-sessions.md b/docs/rfcs/0014-mod-driven-actors-and-adopted-link-sessions.md new file mode 100644 index 00000000..4294bcbe --- /dev/null +++ b/docs/rfcs/0014-mod-driven-actors-and-adopted-link-sessions.md @@ -0,0 +1,158 @@ +# RFC 0014: Mod-driven world actors and adopted link sessions + +## Status + +Proposed. + +## Motivation + +A mod can already spawn a runtime object with `mod.world:spawnNpc` and can +already start a link battle. It cannot make either of them behave like +something the mod itself owns. + +Three walls, each of which stops a mode rather than inconveniencing it: + +**An actor cannot move on its own schedule.** The only public way to animate a +spawned object is `Handle:scriptMove`, which queues onto +`OverworldState.scriptMoves`. A non-empty `scriptMoves` is how the overworld +knows a cutscene is running -- `handleInput` gates on it -- so anything +animated that way freezes the player's controls for as long as it walks. That +is correct for Oak marching into his lab and wrong for an ambient walker or a +networked player's ghost, which move continuously and must not lock anyone out. + +**A spawned object cannot answer the A press.** Talking is resolved from the +map's text tables by `TEXT_*` id. A runtime object has no id, so the vanilla +path has nothing to say for it, and the mod that created it has no way to say +anything either. + +**A link battle cannot ride a connection the mod already has.** `LinkState` +owns pairing, so a mode that already has a socket to its peer must either open +a second connection for the battle or reimplement lockstep. And when the +battle ends, cable rules leave the real party untouched, so the damage exists +only in the battle's own copies -- which are gone by the time the state +unwinds. A mode built on link battles cannot learn what the fight cost. + +The immediate consumer is an overworld multiplayer mode, but nothing here is +specific to it: the first two are wanted by any mod with an actor that moves +itself, and the third by any mode that runs battles over its own transport -- +a tournament ladder, a draft, a gauntlet. + +## The decision it extends + +This adds nothing to the compatibility surface's shape; it extends the +**additive, guarded seam convention** that Route B in `CONTRIBUTING-mods.md` +documents, and is gated by the parity guarantee `tests/engine/gate_meta_coverage.lua` +enforces ("21-testing-and-ci: a parity gate for every extension point"; M14). + +There is no in-repo D-number registry to amend; the consuming design lives +outside this repository, as it did for RFC 0011. + +## Exact API delta + +### New hook: `world.talk` + +```lua +mod.hooks:wrap("world.talk", function(next, ow, target) + -- ow = the OverworldState raising it + -- target = the object on the faced cell + if mine(target) then + say(target) + return -- the mod answered; the text path is skipped + end + return next(ow, target) -- anything else falls through unchanged +end) +``` + +Call site: `OverworldState:interact`, on the branch that has already resolved +an object on the faced cell (including across a counter) and confirmed it is +not mid-step and not the Pikachu follower. It runs before the map's text +tables are consulted. With no subscriber, `Runtime.call` invokes the vanilla +fallthrough, which is a file-local function rather than a per-press closure, +so an unhooked A press allocates nothing it did not allocate before. + +### New event: `link.battle_ended` + +```lua +mod.events:on("link.battle_ended", function(ev) + -- ev = { + -- result = "win" | "lose" | "draw" | "ended", + -- myParty = lockstep copy of our party, + -- theirParty = lockstep copy of theirs, + -- peerName = string, + -- role = "host" | "guest", + -- } +end) +``` + +Call site: `LinkState:update`, in the `battleRunning` stage, once the battle +state has been popped and before `exitWith` unwinds the link stack. Guarded by +`Runtime.wants("link.battle_ended")`, so with no subscriber no payload table +is allocated and the branch runs exactly as before. + +The party copies are the point of the event. They are the lockstep records the +battle actually fought with, which is where the damage lives under cable rules. + +### New `WorldAPI` handle methods + +```lua +handle:stepNow(dir) --> true when the step started +handle:canStep(dir) --> would stepNow land somewhere legal? +handle:placeAt(x, y, dir) --> snap, no animation, clears a step in flight +handle:isMoving() --> true while a step is animating +handle:setPassable(flag) --> may the player walk through this object? +``` + +`stepNow` sets the same per-tile state `scriptMove` does, without the queue and +therefore without the input lockout. It does **not** consult collision: the +intended caller is replaying a move already decided elsewhere (validated on a +peer, or authored), and re-judging it locally would let two copies of the same +actor disagree about where it is. `canStep` is the separate opinion for callers +that want it. `setPassable` sets the flag `Collision.occupied` already honours +for Yellow's companion Pikachu; a passable object still draws and still talks. + +### Two supporting changes + +`Game:startNewGame(opts)` -- the title screen's NEW GAME closure, made callable, +with `opts.intro = false` to land directly in the world. A mode that issues its +own starting state has no use for the intro cutscene. + +`CodeEntry.new(shape)` -- accepts an optional `{ length =, charset = }`, so the +slot-scrub widget that enters a link code can also carry a room code or a +host:port address. Called with no argument it is byte-for-byte the previous +widget. + +## Migration and compatibility + +**Existing mods change nothing.** Every item above is a new name or a new +optional argument; no existing name, payload, signature, or default changes. + +The v1 surface is unaffected: `content.X:register/override/get`, `events:on`, +`hooks:wrap`, `mod.log`, `mod:read`, the manifest v1 fields and +`pokemon.before_give` all behave as before. `mods/example_mew_starter` -- api 1, +`category = "GAMEPLAY"`, whole-species copy -- loads unchanged, which +`tests/run_modkit.lua` proves on every run. + +With no subscriber to either seam, Red, Blue, Yellow, Gold and Silver behave +exactly as they did: the A press reaches `talkTo`, the link battle unwinds +without building an event payload, and no handle method is reachable unless a +mod calls it. + +## Verification + +- `tests/modkit/cases/world_talk.lua` drives the real `OverworldState:interact` + path. It asserts the unhooked build first (the A press reaches `talkTo`), + then that a mod owning the object suppresses the text path by not calling + `next`, that an object the mod ignores still falls through, and that an + object mid-step raises no hook at all. +- `tests/modkit/cases/link_battle_ended.lua` drives the real `LinkState:update` + path. It asserts that with nothing subscribed the event is not wanted (so no + payload is built) and the battle still unwinds, then that a subscriber + receives the result, the role, the peer name and both party copies including + the damage the real party never took, from both sides of the cable. +- `tests/engine/gate_hooks.lua` and `tests/engine/gate_events.lua` walk the live + catalog, so both seams are parity-gated structurally; `gate_meta_coverage` + passes 208/208 with both names covered and no DEBT entry added. + +## Deprecation etiquette + +Nothing is removed, renamed, superseded or deprecated. diff --git a/docs/rfcs/0016-battle-field-residual.md b/docs/rfcs/0016-battle-field-residual.md new file mode 100644 index 00000000..07eba812 --- /dev/null +++ b/docs/rfcs/0016-battle-field-residual.md @@ -0,0 +1,123 @@ +# RFC 0016: Engine-owned field residual descriptors + +## Status + +Proposed. + +## Motivation + +Battle-rule mods can keep deterministic data-only state in the public battle +field and observe `battle.turn_ended`, but that event fires after the engine's +residual and faint pipeline. A listener cannot safely deal end-of-round field +damage: directly changing live HP bypasses bar drains, faint messages, +experience, replacements, double-faint resolution, and checkpoint continuation. +Putting callbacks into `battle.field` is also rejected by the checkpoint +serializer, correctly, because executable state is not save-safe. + +## Decision and plan extended + +This implements **D-AT-004: public engine-owned field residual execution**, the +consuming decision required by Adaptive Trainers capability `ENGINE-FIELD- +RESIDUALS`. The plan is +[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md), +Task 8. The engine delta defines only a generic end-of-round extension point; +it contains no weather names, immunities, damage formula, trainer identity, or +Adaptive Trainers policy. + +## Exact API delta + +Add the guarded Gen 1 hook: + +```lua +mod.hooks:wrap("battle.field_residual", function(next, context) + local rows = next(context) + rows[#rows + 1] = { + side = "enemy", + amount = 7, + message = context.battlers.enemy.name .. " is buffeted!", + } + return rows +end) +``` + +The hook runs once during an undecided battle's end-of-round processing, after +vanilla status residuals and before field/side token expiry and +`battle.turn_ended`. With no subscriber, the guarded site builds no context and +changes nothing. Vanilla contributes an empty list. + +`context` is `{ field, battlers, turn }`. `field` is a strictly data-only view +with the same `{ weather, tokens }` shape captured by battle checkpoints. Its +recursive projection retains raw tables and finite numbers, strings, and +booleans under scalar keys. It strips metatables and omits functions, userdata, +threads, unsupported keys, and cyclic edges. Thus it exposes neither +`field.sides` nor a graph or executable callback back into live engine state. +`battlers.player` and `battlers.enemy` are detached snapshots with `{ side, +name, hp, maxHp, types, vanished }`. Changing either detached view cannot +change the live battle. `turn` is the current Gen 1 turn counter. + +A result row is `{ side = "player"|"enemy", amount = +positive_finite_integer_number, message = optional_string }`. Numeric strings, +zero, negatives, fractions, NaN, infinities, malformed sides, and non-string +messages fail closed. Damage is clamped to current HP. The engine owns +mutation, HP-bar drain rows, and its existing faint pipeline. It applies every +accepted row before scheduling newly fainted battlers. If this hook batch +terminally faints the player with no healthy reserve, it queues only the player +faint authority, so descriptor order cannot race a blackout against an enemy +EXP/replacement path. Otherwise it schedules newly fainted battlers in fixed +player/enemy order. This precedence is scoped to this hook response; native +faint paths, including `enemyMonFainted`, keep their existing no-hook behavior. +Wrappers compose by calling `next(context)` and appending their own rows. +Callbacks are not part of the descriptor contract and hook functions are never +stored in the battle. + +Gold already owns native weather and a generation-specific between-turn order; +this first additive call site is Gen 1-only. A future Gold site must keep the +same context and descriptor contract and choose its native ordering explicitly. + +## Migration and compatibility + +Existing mods change nothing. No hook name or payload changes. With no wrapper, +Gen 1 performs the same residual, token, event, and faint work as before, +including native simultaneous-faint resolution, and allocates no context. Gen +2 is unchanged. Existing and new checkpoints keep +serializing only the data stored in `battle.field`; hook callbacks remain +process-local loader state and are never serialized. + +The v1 surface remains unchanged: `content.X:register/override/get`, +`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, manifest v1 fields, and +`pokemon.before_give` keep their existing behavior. + +## Verification + +- The catalog hook parity gate proves null and live-empty buses return the + vanilla list unchanged. +- A sandboxed fixture mod exercises the seam through `mod.hooks`, verifies the + detached checkpoint-shaped context, applies damage, and reaches the engine + faint pipeline. +- Engine validation tests cover strict number validation (including numeric + strings, NaN, infinities, zero, and negatives), nested mutation isolation, + omission of functions/userdata/threads/cycles and metatables from the public + field projection, optional messages, non-table results, clamping, and + settled-battle suppression. +- Both descriptor orders are driven through queue completion for a simultaneous + terminal residual. Each proves player blackout loss with no EXP event, + enemy replacement, or replacement UI. +- A disabled-bus sentinel proves the guard performs no `Runtime.call` or field + context construction. An ordering probe proves the enabled hook runs after + vanilla status residuals and before token expiry and `battle.turn_ended`. +- A no-hook native regression proves a simultaneous zero-HP state still enters + the pre-existing enemy-faint EXP and win authority outside this hook batch. +- Capture/restore/capture evidence proves checkpointed field state round-trips + while the enabled process-local hook remains installed and callable. + +## Docs with the change + +`docs/modding.md` documents the Gen 1 timing, detached payload, descriptor +validation, simultaneous-terminal result, and checkpoint boundary. +`docs/mod-api-gen2-compat.md` records that Gold does not yet expose the hook. +No registry or schema changes are involved, so generated registry docs do not +change. + +## Deprecation etiquette + +Nothing is deprecated. The hook is additive. diff --git a/docs/rfcs/0017-mod-developer-mode.md b/docs/rfcs/0017-mod-developer-mode.md new file mode 100644 index 00000000..7d564648 --- /dev/null +++ b/docs/rfcs/0017-mod-developer-mode.md @@ -0,0 +1,102 @@ +# RFC 0017: Public mod developer-mode signal + +## Status + +Proposed. + +## Motivation + +The loader already derives a boot-time developer-mode flag for its permission +diagnostics and headless test seam. Gen 1's `Game` independently derives a +similarly sourced flag for its console and hot reload. A sandboxed mod cannot +read either one. `mod.commands` can register a diagnostic command but cannot +say whether the current boot is a developer boot. `mod.exports` only publishes +values to other mods. `game.ready` fires after entry registration and carries +only the game. `mod.options` is player configuration, not engine mode, and +`mod.log` logs unconditionally. The pre-sandbox compatibility +`os.getenv("POKEPORT_DEV")` deliberately returns `nil`, because the process +environment is hidden from mods. + +The concrete consumer is **Adaptive Trainers**. Its approved Chapter 30 and +Phase H require trainer, boss, Rival, and League diagnostic views plus +seed-label tracing to exist only when `POKEPORT_DEV` is active. Without a +public signal, the mod must either ship those registrations in production, +misuse a player option, or import loader/Logger internals. All three violate +the approved observability boundary or the sandbox/public-API policy. + +## Decision and plan extended + +This implements **D-AT-005: diagnostics and seed tracing are admitted only by +the engine's developer-mode decision**. The consuming design is tracked in the +Adaptive Trainers implementation plan, +[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md), +Task 9. The engine delta is generic and contains no trainer, balancing, +diagnostic-layout, seed-label, or Adaptive Trainers policy. + +## Exact API delta + +Every sandboxed mod object adds one field: + +```lua +mod.developer -- boolean +``` + +The loader copies its existing `dev` decision into this field before invoking +the mod's entry chunk. It is therefore available for load-time registration: + +```lua +if mod.developer then + mod.commands:register("my_mod:diagnostics", diagnostics_command) +end +``` + +The value is a plain boolean snapshot, not a loader reference or environment +facade. `false` is the normal player-build answer. `POKEPORT_DEV=1` and the +`--developer` command-line path make the loader flag true; on Gen 1 those inputs +separately make `Game`'s own developer flag true for its console and hot +reload. The loader's existing injected `opts.dev` test seam changes only the +loader flag and diagnostics, not `Game` or its hotkeys. It grants no permission +and does not expose environment variables. The answer is fixed for the life of +that loader; changing a field on a mod's own table cannot change engine mode. + +The field is generation-independent and has identical semantics on Red, Blue, +Yellow, Gold, and Silver. Gold and Silver do not gain Gen 1's developer console +or hot-reload hotkeys from this field. + +## Migration and compatibility + +Existing mods change nothing. `mod.developer` is additive, requires no +permission, and does not bump the integer mod API. Existing API-v1 and API-v2 +entry chunks receive one extra scalar field and retain all prior fields and +methods unchanged. No name is removed or shadowed. + +With no mods installed, `Loader:_api` is never called, so the delta allocates no +mod object and changes no data, save, options, event, hook, command, or file. +With mods installed in a normal boot, the new field is `false` unless an author +explicitly reads it. Existing registration and logging behavior is unchanged. + +An adopting mod should gate developer-only registrations and verbose logging +directly on `mod.developer`. Player-facing behavior belongs behind +`mod.options`, not this signal. + +## Verification + +- `tests/engine/mod_developer_mode_test.lua` loads a real sandboxed mod through + the public SDK with developer mode both on and off. It proves the boolean is + available during entry execution and that the same source registers its + diagnostic command only for the developer load. It also covers the + command-line global path and a Gen 2 load. +- `tests/engine/mod_developer_mode_parity_test.lua` is the separate no-mod + parity suite. It proves both developer answers discover no mods, create no + files, and leave injected vanilla data unchanged. It also loads an unchanged + API-v1 probe and verifies identity, `mod:read`, exports, and options behavior. +- The full ROM-free engine and modkit tiers remain the compatibility proof for + `content.X:register/override/get`, `events:on`, `hooks:wrap`, `mod.log`, + `mod:read`, manifest v1 fields, and `pokemon.before_give`. + +No registry or schema changes are involved, so generated registry documentation +is unaffected. + +## Deprecation etiquette + +Nothing is removed, renamed, superseded, or deprecated. diff --git a/main.lua b/main.lua index 8880c075..8220c9c2 100644 --- a/main.lua +++ b/main.lua @@ -84,40 +84,8 @@ local closeEditor -- forward declaration: openEditor hands it to the editor -- Drop CacheFs / Data / mod Runtime / Assets / LegacyCompat for one mounted -- version session (save editor or game). closeEditor and returnToLauncher --- both go through here so neither path can forget a singleton the other resets. -local function teardownMountedSession(version) - if version then - require("src.import.CacheFs").unmountVersion(version) - end - require("src.core.Data"):unloadGenerated() - local Runtime = require("src.mods.Runtime") - if Runtime.reset then Runtime.reset() end - local Assets = require("src.render.Assets") - if Assets.installLoader then Assets.installLoader(nil) end - local okCompat, LegacyCompat = pcall(require, "src.mods.LegacyCompat") - if okCompat and LegacyCompat.reset then LegacyCompat.reset() end -end - --- Evict every save-editor module from package.loaded without a hardcoded --- panel whitelist. Flat require names (App, Party, …) resolve under --- tools/save-editor/; path-style keys may also appear. A key is flushed --- when it names a save-editor path or when tools/save-editor/{panels/}K.lua --- exists for a flat name K -- new panels are picked up automatically. -local function flushEditorPackageLoaded() - local fs = love and love.filesystem - local function isEditorFlat(name) - if not (fs and fs.getInfo) then return false end - if name:find("[./]") then return false end - return fs.getInfo("tools/save-editor/" .. name .. ".lua") ~= nil - or fs.getInfo("tools/save-editor/panels/" .. name .. ".lua") ~= nil - end - for k in pairs(package.loaded) do - if type(k) == "string" - and (k:find("save%-editor", 1, false) or isEditorFlat(k)) then - package.loaded[k] = nil - end - end -end +-- both go through SessionLifecycle so neither path forgets a singleton. +local SessionLifecycle = require("src.core.SessionLifecycle") -- The editor's modules use flat names (require("Kit"), require("Party")), so -- their directories have to be on the require path. It must be @@ -194,9 +162,7 @@ local function openEditor(version, slotId) local okReq, appOrErr = pcall(require, "App") if not okReq then editorMode = false - if version then - require("src.import.CacheFs").unmountVersion(version) - end + SessionLifecycle.endEditorSession({ version = version, app = nil }) restoreWindow() Importer = editorHost editorHost = nil @@ -216,10 +182,7 @@ local function openEditor(version, slotId) editorMode = false if EditorApp.unload then pcall(EditorApp.unload) end EditorApp = nil - if version then - teardownMountedSession(version) - end - flushEditorPackageLoaded() + SessionLifecycle.endEditorSession({ version = version, app = nil }) restoreWindow() Importer = editorHost editorHost = nil @@ -238,13 +201,10 @@ end -- next Edit or Play does not inherit the editor's dead mod loader. function closeEditor() local version = editorVersion + local app = EditorApp editorMode = false - if EditorApp and EditorApp.unload then EditorApp.unload() end EditorApp = nil - if version then - teardownMountedSession(version) - end - flushEditorPackageLoaded() + SessionLifecycle.endEditorSession({ version = version, app = app }) editorVersion = nil restoreWindow() Importer = editorHost @@ -345,32 +305,9 @@ end local function returnToLauncher() if not Game then return end - pcall(function() require("src.core.Music").stop() end) - pcall(function() require("src.core.Sound").stop() end) - if package.loaded["src.core.ChipAudio"] then - pcall(package.loaded["src.core.ChipAudio"].shutdown) - end - if package.loaded["src.core.DiscordPresence"] then - pcall(package.loaded["src.core.DiscordPresence"].shutdown) - end - if package.loaded["src.core.gen2.Clock"] then - pcall(package.loaded["src.core.gen2.Clock"].shutdown) - end - if package.loaded["src.net.Gen1Tls"] then - pcall(package.loaded["src.net.Gen1Tls"].shutdown) - end - if love.audio and love.audio.stop then - pcall(love.audio.stop) - end - pcall(function() require("src.render.SecondScreen").setEnabled(false) end) - local GameVersion = require("src.core.GameVersion") local currentVersion = GameVersion.get() - teardownMountedSession(currentVersion) - - if Game.reset then - pcall(function() Game:reset() end) - end + SessionLifecycle.endGameSession(Game) Game = nil autopilot = nil driverCo = nil @@ -380,10 +317,7 @@ local function returnToLauncher() require("src.core.SaveData").setCart(nil) require("src.core.GameSpeed").setAllowed(nil) - local Input = require("src.core.Input") - local TouchControls = require("src.core.TouchControls") - Input:reset() - TouchControls:reset() + SessionLifecycle.endMountedSession(currentVersion) require("src.core.Orientation").applyOptions( require("src.core.SaveData").loadOptions()) @@ -746,7 +680,7 @@ function love.gamepadpressed(joystick, button) end return end - if Studio then return end + if Studio then return Studio.gamepadpressed(joystick, button) end if Importer then return Importer:gamepadpressed(joystick, button) end if not Game then return end Game:gamepadpressed(joystick, button) @@ -766,7 +700,7 @@ function love.gamepadreleased(joystick, button) end return end - if Studio then return end + if Studio then return Studio.gamepadreleased(joystick, button) end if Importer then return Importer:gamepadreleased(joystick, button) end if not Game then return end Game:gamepadreleased(joystick, button) @@ -786,7 +720,7 @@ function love.gamepadaxis(joystick, axis, value) end return end - if Studio then return end + if Studio then return Studio.gamepadaxis(joystick, axis, value) end if Importer then return Importer:gamepadaxis(joystick, axis, value) end if not Game then return end Game:gamepadaxis(joystick, axis, value) @@ -806,7 +740,7 @@ function love.joystickpressed(joystick, button) end return end - if Studio then return end + if Studio then return Studio.joystickpressed(joystick, button) end if Importer then return Importer:joystickpressed(joystick, button) end if not Game then return end Game:joystickpressed(joystick, button) @@ -826,7 +760,7 @@ function love.joystickreleased(joystick, button) end return end - if Studio then return end + if Studio then return Studio.joystickreleased(joystick, button) end if Importer then return Importer:joystickreleased(joystick, button) end if not Game then return end Game:joystickreleased(joystick, button) @@ -846,7 +780,7 @@ function love.joystickaxis(joystick, axis, value) end return end - if Studio then return end + if Studio then return Studio.joystickaxis(joystick, axis, value) end if Importer then return Importer:joystickaxis(joystick, axis, value) end if not Game then return end Game:joystickaxis(joystick, axis, value) @@ -866,7 +800,7 @@ function love.joystickhat(joystick, hat, direction) end return end - if Studio then return end + if Studio then return Studio.joystickhat(joystick, hat, direction) end if Importer then return Importer:joystickhat(joystick, hat, direction) end if not Game then return end Game:joystickhat(joystick, hat, direction) @@ -1205,22 +1139,7 @@ function love.quit() pcall(function() require("src.core.DiscordPresence").shutdown() end) - -- LOVE waits for every live love.thread before the process exits, and both - -- background workers idle in a loop that only a "quit" command breaks, so - -- without this the process outlived the window and the next launch re-entered - -- the dead one instead of starting fresh (#339) - if package.loaded["src.core.ChipAudio"] then - pcall(package.loaded["src.core.ChipAudio"].shutdown) - end - if package.loaded["src.update.Check"] then - pcall(package.loaded["src.update.Check"].shutdown) - end - -- The launcher's fetch pool is the same story: its workers idle in - -- Channel:demand(), which never returns on its own, so a launcher that ever - -- touched the network would hang the process on exit (#339's shape again). - if package.loaded["src.net.Fetch"] then - pcall(package.loaded["src.net.Fetch"].shutdown) - end + SessionLifecycle.endProcess() end function love.filedropped(file) diff --git a/ports/uwp/third_party/love/README.md b/ports/uwp/third_party/love/README.md index cca8b2fa..2d0ea9d5 100644 --- a/ports/uwp/third_party/love/README.md +++ b/ports/uwp/third_party/love/README.md @@ -1,6 +1,6 @@ # LÖVE UWP binaries -These x64 UWP Release binaries were built from [`caorthann-celt/love-xbox-uwp`](https://github.com/caorthann-celt/love-xbox-uwp) at commit `cdf85f28ed794c95d6f5f7e3a8f23ca9ee1fbdcb`. +These x64 UWP Release binaries were built from [`caorthann-celt/love-xbox-uwp`](https://github.com/caorthann-celt/love-xbox-uwp) at commit `3f51bf0be5f3f86e5934da3893caba2b817f82c1`. The build uses LÖVE 11.5, LuaJIT, SDL2, and ANGLE. Keep the DLLs and import libraries together; they are one binary interface. diff --git a/ports/uwp/third_party/love/bin/love.dll b/ports/uwp/third_party/love/bin/love.dll index 306fc5c5..aff9cd5c 100644 Binary files a/ports/uwp/third_party/love/bin/love.dll and b/ports/uwp/third_party/love/bin/love.dll differ diff --git a/ports/uwp/third_party/love/lib/lovestatic.lib b/ports/uwp/third_party/love/lib/lovestatic.lib index af2d8ad1..31fbc9cb 100644 Binary files a/ports/uwp/third_party/love/lib/lovestatic.lib and b/ports/uwp/third_party/love/lib/lovestatic.lib differ diff --git a/ports/uwp/third_party/manifest.json b/ports/uwp/third_party/manifest.json index ec790a24..f0db3d4f 100644 --- a/ports/uwp/third_party/manifest.json +++ b/ports/uwp/third_party/manifest.json @@ -7,7 +7,7 @@ "love": { "version": "11.5", "repository": "https://github.com/caorthann-celt/love-xbox-uwp.git", - "commit": "cdf85f28ed794c95d6f5f7e3a8f23ca9ee1fbdcb" + "commit": "3f51bf0be5f3f86e5934da3893caba2b817f82c1" }, "luajit": { "repository": "https://github.com/LuaJIT/LuaJIT.git", @@ -99,7 +99,7 @@ }, { "path": "love/bin/love.dll", - "sha256": "A3622828014F03DDB502D2A7807DC8F94432172595BE03508099BD66937FD45F" + "sha256": "E2CE334817636AA9EFF97E181FB5C33C506971F2AC7C653F2CA751B5CDE2FCE6" }, { "path": "love/bin/lua51.dll", @@ -111,7 +111,7 @@ }, { "path": "love/lib/lovestatic.lib", - "sha256": "15FB3A9CB3C3CDAB8B33DE5B1D1F8D8FF6CE9719E9AA7BC2CF3D7B51CDCE0C98" + "sha256": "3794690ED3F44DB6F0C83BBC1673979922627929E852E7668C9911DE1F72B972" }, { "path": "love/lib/lua51.lib", diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index b47abf01..300cc345 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -2798,6 +2798,111 @@ function BattleState:queueResidual(b, opp) end end +local function fieldBattlerView(battler, side) + local types = {} + for index, typeId in ipairs(battler.curTypes or {}) do + types[index] = typeId + end + local mon = battler.mon or {} + return { + side = side, + name = battler.name, + hp = tonumber(mon.hp) or 0, + maxHp = tonumber(mon.stats and mon.stats.hp) or tonumber(mon.hp) or 0, + types = types, + vanished = battler.invulnerable and true or false, + } +end + +local function publicScalar(value) + local kind = type(value) + if kind == "string" or kind == "boolean" then return value, true end + if kind == "number" and value == value + and value < math.huge and value > -math.huge then + return value, true + end + return nil, false +end + +local function publicDataCopy(value, visiting) + local scalar, ok = publicScalar(value) + if ok then return scalar, true end + if type(value) ~= "table" then return nil, false end + + visiting = visiting or {} + if visiting[value] then return nil, false end + visiting[value] = true + + local copy = {} + for key, child in next, value do + local copiedKey, keyOk = publicScalar(key) + local copiedChild, childOk = publicDataCopy(child, visiting) + if keyOk and childOk then copy[copiedKey] = copiedChild end + end + visiting[value] = nil + return copy, true +end + +local function checkpointFieldView(field) + field = field or {} + local view = publicDataCopy({ + weather = field.weather, + tokens = field.tokens or {}, + }) + return view +end + +-- Public field residuals are data-only requests. Mods can inspect a detached +-- checkpoint-shaped field view and detached battler views, but only the engine +-- mutates HP, animates the bar, or enters the faint pipeline. +function BattleState:applyFieldResiduals() + if not Runtime.wantsHook("battle.field_residual") then return end + local views = { + player = fieldBattlerView(self.player, "player"), + enemy = fieldBattlerView(self.enemy, "enemy"), + } + local rows = Runtime.call("battle.field_residual", function() return {} end, { + field = checkpointFieldView(self.field), + battlers = views, + turn = self.turnCount or 0, + }) + if type(rows) ~= "table" then return end + + local fainted = {} + for _, row in ipairs(rows) do + local battler = type(row) == "table" and row.side == "player" + and self.player or type(row) == "table" and row.side == "enemy" + and self.enemy or nil + local amount = type(row) == "table" and row.amount or nil + if battler and battler.mon.hp > 0 and type(amount) == "number" + and amount > 0 + and amount < math.huge and amount == math.floor(amount) + and (row.message == nil or type(row.message) == "string") then + amount = math.min(amount, battler.mon.hp) + if type(row.message) == "string" and row.message ~= "" then + self:sayNext(row.message) + end + battler.mon.hp = battler.mon.hp - amount + self:drainNext(battler, battler.mon.hp) + if battler.mon.hp <= 0 then fainted[battler] = true end + end + end + -- A terminal player faint owns a simultaneous field-residual batch. Queue + -- only that authority so its blackout cannot race an enemy EXP/replacement + -- path from the same hook response. Native faint paths remain untouched. + if fainted[self.player] + and not Party.firstHealthy(self:playerPartyView()) then + self:onFaint(self.player) + return + end + + -- Otherwise resolve the two sides in engine order after every accepted + -- descriptor has landed. Descriptor order must not decide resolution. + for _, battler in ipairs({ self.player, self.enemy }) do + if fainted[battler] then self:onFaint(battler) end + end +end + function BattleState:endOfTurn() -- the same ret: a decided battle never reaches HandlePoisonBurnLeechSeed -- or CheckNumAttacksLeft (core.asm:417-421, 456-460), so the residual @@ -2851,6 +2956,7 @@ function BattleState:endOfTurn() b.trappingTurns = nil end end + self:applyFieldResiduals() self:tickTokens() Runtime.emit("battle.turn_ended", { battle = self, turn = self.turnCount or 0 }) end diff --git a/src/core/ChipAudio.lua b/src/core/ChipAudio.lua index b7b09faa..ef07d40e 100644 --- a/src/core/ChipAudio.lua +++ b/src/core/ChipAudio.lua @@ -371,7 +371,7 @@ function ChipAudio.shutdown() if workerReady and cmdCh then cmdCh:push({ cmd = "quit" }) end if worker then pcall(function() worker:wait() end) end worker, cmdCh, outCh = nil, nil, nil - workerReady = false + workerReady = nil end function ChipAudio.currentSource() @@ -498,6 +498,8 @@ end -- program (20 §2 cache contract, chip music row) Assets.register(ChipAudio.invalidate) +require("src.core.SessionLifecycle").registerProcessShutdown(ChipAudio.shutdown) + -- --------------------------------------------------------------------------- -- one-shot effects (SFX, cries, low-health alarm): synchronous static Sources -- --------------------------------------------------------------------------- diff --git a/src/core/Game.lua b/src/core/Game.lua index 3137193a..2cc890d7 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -147,29 +147,42 @@ function Game:bootConfig() return boot end +-- NEW GAME, as a call: a fresh skeleton (through save.new_game, so a mod +-- can reshape it), the overworld at the skeleton's spawn, and the intro +-- screen on top. The title menu's NEW GAME row is this; a mod that starts a +-- game on its own terms -- a match, a challenge mode -- calls it directly. +-- +-- opts.intro = false skips the newGame screen (Oak's speech) so the player +-- lands straight in the world; the skeleton then has to carry a name and a +-- party, which is the caller's job via save.new_game. +function Game:startNewGame(opts) + local OverworldState = require("src.world.OverworldController") + while self.stack:top() do self.stack:pop() end + -- New Game keeps the standalone options.lua preferences + self.sessionStartedAt = os.time() + self.save = SaveData.newGame(self:bootConfig()) + -- no bucket carry-over: mod state from an abandoned session must + -- not leak into a fresh slot; mods seed via save.created instead + self:adoptSave(self.save) + ModRuntime.emit("save.created", { save = self.save }) + self:applyOptions(self.save.options) + self.stack:push(OverworldState, self.save.player.map, + self.save.player.x, self.save.player.y, + self.save.player.facing, + { via = "boot", freshBoot = true }) + if not (opts and opts.intro == false) then + Screens.push(self, bootScreens(self).newGame or "OakSpeech", + function() end) + end +end + -- the title screen with its NEW GAME / CONTINUE wiring; used at boot -- and by the START-menu QUIT confirmation function Game:makeTitleState() local OverworldState = require("src.world.OverworldController") local factory = Screens.get(self, bootScreens(self).title or "TitleState") local title = factory.new(self, { - onNewGame = function() - while self.stack:top() do self.stack:pop() end - -- New Game keeps the standalone options.lua preferences - self.sessionStartedAt = os.time() - self.save = SaveData.newGame(self:bootConfig()) - -- no bucket carry-over: mod state from an abandoned session must - -- not leak into a fresh slot; mods seed via save.created instead - self:adoptSave(self.save) - ModRuntime.emit("save.created", { save = self.save }) - self:applyOptions(self.save.options) - self.stack:push(OverworldState, self.save.player.map, - self.save.player.x, self.save.player.y, - self.save.player.facing, - { via = "boot", freshBoot = true }) - Screens.push(self, bootScreens(self).newGame or "OakSpeech", - function() end) - end, + onNewGame = function() self:startNewGame() end, onContinue = function() local loaded, recovered = SaveData.load() if loaded then @@ -1382,12 +1395,25 @@ function Game:reset() if self.stack and self.stack.clear then pcall(function() self.stack:clear() end) end + if self.world and self.world.release then + pcall(function() self.world:release() end) + end + if self._canvases then + for _, canvas in pairs(self._canvases) do + if canvas and canvas.release then pcall(canvas.release, canvas) end + end + end if self.renderer and self.renderer.releaseCanvases then pcall(function() self.renderer:releaseCanvases() end) end local keys = {} for key, value in pairs(self) do if type(value) ~= "function" then + if key ~= "world" and key ~= "renderer" and key ~= "_canvases" then + if type(value) == "table" and value.release then + pcall(value.release, value) + end + end keys[#keys + 1] = key end end diff --git a/src/core/Game2.lua b/src/core/Game2.lua index db6f54b9..22d914ff 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -2208,9 +2208,25 @@ function Game2:reset() if self.stack and self.stack.clear then pcall(function() self.stack:clear() end) end + if self.world and self.world.release then + pcall(function() self.world:release() end) + end + if self._canvases then + for _, canvas in pairs(self._canvases) do + if canvas and canvas.release then pcall(canvas.release, canvas) end + end + end + if self.renderer and self.renderer.releaseCanvases then + pcall(function() self.renderer:releaseCanvases() end) + end local keys = {} for key, value in pairs(self) do if type(value) ~= "function" then + if key ~= "world" and key ~= "renderer" and key ~= "_canvases" then + if type(value) == "table" and value.release then + pcall(value.release, value) + end + end keys[#keys + 1] = key end end diff --git a/src/core/SessionLifecycle.lua b/src/core/SessionLifecycle.lua new file mode 100644 index 00000000..84508a67 --- /dev/null +++ b/src/core/SessionLifecycle.lua @@ -0,0 +1,101 @@ +-- Central session lifecycle orchestrator. Subsystems register teardown hooks +-- at module load (Assets release/invalidate bus, process shutdown below); the +-- host only calls phase entry points. +-- +-- Three tiers: +-- mount endMountedSession — GPU release + CacheFs/Data/Runtime/Assets +-- game endGameSession — audio + game:reset; workers stay alive +-- process endProcess — worker shutdown on real app exit (love.quit) +-- +-- Hot reload (Assets.flush / installLoader) stays invalidate-only forever. + +local SessionLifecycle = {} + +local processShutdowns = {} + +function SessionLifecycle.registerProcessShutdown(fn) + processShutdowns[#processShutdowns + 1] = fn +end + +-- Drop CacheFs / Data / mod Runtime / Assets / LegacyCompat for one mounted +-- version session (save editor or game). GPU release runs before soft +-- invalidate via installLoader(nil). +function SessionLifecycle.endMountedSession(version) + local Assets = require("src.render.Assets") + if Assets.releaseSession then Assets.releaseSession() end + if version then + require("src.import.CacheFs").unmountVersion(version) + end + require("src.core.Data"):unloadGenerated() + local Runtime = require("src.mods.Runtime") + if Runtime.reset then Runtime.reset() end + if Assets.installLoader then Assets.installLoader(nil) end + local okCompat, LegacyCompat = pcall(require, "src.mods.LegacyCompat") + if okCompat and LegacyCompat.reset then LegacyCompat.reset() end +end + +-- Evict every save-editor module from package.loaded without a hardcoded +-- panel whitelist. Flat require names (App, Party, …) resolve under +-- tools/save-editor/; path-style keys may also appear. +local function flushEditorPackageLoaded() + local fs = love and love.filesystem + local function isEditorFlat(name) + if not (fs and fs.getInfo) then return false end + if name:find("[./]") then return false end + return fs.getInfo("tools/save-editor/" .. name .. ".lua") ~= nil + or fs.getInfo("tools/save-editor/panels/" .. name .. ".lua") ~= nil + end + for k in pairs(package.loaded) do + if type(k) == "string" + and (k:find("save%-editor", 1, false) or isEditorFlat(k)) then + package.loaded[k] = nil + end + end +end + +function SessionLifecycle.endEditorSession(opts) + opts = opts or {} + if opts.app and opts.app.unload then pcall(opts.app.unload) end + flushEditorPackageLoaded() + if opts.version then + SessionLifecycle.endMountedSession(opts.version) + end +end + +-- EXIT GAME / intent_game before dropping Game. Stops audio and resets the +-- live game instance so map/GPU holders are gone before endMountedSession. +function SessionLifecycle.endGameSession(game) + pcall(function() require("src.core.Music").stop() end) + pcall(function() require("src.core.Sound").stop() end) + if package.loaded["src.core.ChipAudio"] then + pcall(package.loaded["src.core.ChipAudio"].shutdown) + end + if package.loaded["src.core.DiscordPresence"] then + pcall(package.loaded["src.core.DiscordPresence"].shutdown) + end + if package.loaded["src.core.gen2.Clock"] then + pcall(package.loaded["src.core.gen2.Clock"].shutdown) + end + if package.loaded["src.net.Gen1Tls"] then + pcall(package.loaded["src.net.Gen1Tls"].shutdown) + end + if love.audio and love.audio.stop then + pcall(love.audio.stop) + end + pcall(function() require("src.render.SecondScreen").setEnabled(false) end) + + if game and game.reset then + pcall(function() game:reset() end) + end + + local Input = require("src.core.Input") + local TouchControls = require("src.core.TouchControls") + Input:reset() + TouchControls:reset() +end + +function SessionLifecycle.endProcess() + for _, fn in ipairs(processShutdowns) do pcall(fn) end +end + +return SessionLifecycle diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua new file mode 100644 index 00000000..0459e895 --- /dev/null +++ b/src/import/CacheContract.lua @@ -0,0 +1,247 @@ +-- Engine-owned contract for generated ROM caches. +-- +-- A cache is playable only when its versioned marker matches the ROM and every +-- required output for that version exists. Extraction writers may differ by +-- platform, but they must publish through this contract so partial staging +-- cannot look ready to the runtime. +local GameVersion = require("src.core.GameVersion") + +local CacheContract = {} + +CacheContract.FORMAT = "rom-cache-v10:" +CacheContract.MARKER_PATH = "rom-cache.complete" + +CacheContract.REQUIRED_FILES = { + "data/generated/constants.lua", + "data/generated/maps.lua", + "data/generated/text.lua", + "data/generated/field.lua", + "data/generated/battle_anims.lua", + "assets/generated/title/pokemon_logo.png", + "assets/generated/fonts/font.png", + "assets/generated/battle/front/pikachu.png", + "assets/generated/battle/anims/move_anim_0.png", + "assets/generated/battle/anims/move_anim_1.png", + "assets/generated/audio/programs.bin", + "assets/generated/trade/game_boy.png", +} + +CacheContract.VERSION_REQUIRED_FILES = { + yellow = { + "assets/generated/battle/trainers/jessie_james.png", + "assets/generated/battle/profoakb.png", + "assets/generated/pikachu/pikapic_1.png", + }, +} + +CacheContract.VERSION_REQUIRED_FILES_OVERRIDE = { + gold = { + "data/generated/constants.lua", + "data/generated/maps.lua", + "data/generated/roofs.lua", + "data/generated/sprites.lua", + "data/generated/scripts.lua", + "data/generated/text.lua", + -- The engine's label-keyed strings are separate from Gen 2 script text. + -- Caches made before RomExtractorGen2:extractText must be rebuilt so + -- src/core/RomText.lua does not silently fall back to built-in wording. + "data/generated/rom_text.lua", + "data/generated/pokemon.lua", + "data/generated/tilesets.lua", + "data/generated/audio.lua", + "data/generated/marts.lua", + "assets/generated/fonts/font.png", + "assets/generated/fonts/frames.png", + "assets/generated/title/pokemon_logo.png", + "assets/generated/title/title_screen.png", + "assets/generated/title/hooh.png", + "assets/generated/title/hooh_5.png", + "assets/generated/title/clouds.png", + "assets/generated/title/copyright_splash.png", + "data/generated/oak_speech.lua", + "assets/generated/intro/oak.png", + "assets/generated/intro/cal.png", + "assets/generated/tilesets/johto.png", + "assets/generated/tilesets/roofs/new_bark.png", + "assets/generated/sprites/chris.png", + "assets/generated/battle/front/chikorita.png", + "assets/generated/battle/front/pikachu.png", + "assets/generated/battle/front/marill.png", + "assets/generated/battle/trainers/falkner.png", + "assets/generated/battle/hud/balls.png", + "assets/generated/audio/programs.bin", + "assets/generated/slots/gold_slots_1.png", + "assets/generated/card_flip/card_flip_1.png", + "assets/generated/pc/mail_item.png", + -- engine/events/fishing_gfx.asm:23 + "assets/generated/emotes/fishing.png", + }, + crystal = { + "data/generated/constants.lua", + "data/generated/maps.lua", + "data/generated/roofs.lua", + "data/generated/sprites.lua", + "data/generated/scripts.lua", + "data/generated/text.lua", + "data/generated/rom_text.lua", + "data/generated/pokemon.lua", + "data/generated/encounters.lua", + "data/generated/tilesets.lua", + "data/generated/landmarks.lua", + "data/generated/audio.lua", + "data/generated/marts.lua", + "data/generated/oak_speech.lua", + "data/generated/title.lua", + "data/generated/intro.lua", + "assets/generated/fonts/font.png", + "assets/generated/fonts/frames.png", + "assets/generated/title/crystal_logo.png", + "assets/generated/title/crystal_wordmark.png", + "assets/generated/title/crystal_suicune.png", + "assets/generated/splash/ditto.png", + "assets/generated/intro/chris.png", + "assets/generated/intro/kris.png", + "assets/generated/intro/suicune_run_sprites.png", + "assets/generated/intro/unowns_tiles.png", + "assets/generated/intro/oak.png", + "assets/generated/tilesets/johto.png", + "assets/generated/tilesets/roofs/new_bark.png", + "assets/generated/sprites/chris.png", + "assets/generated/sprites/kris.png", + "assets/generated/battle/front/chikorita.png", + "assets/generated/battle/front/wooper.png", + "assets/generated/battle/front/pikachu.png", + "assets/generated/battle/trainers/falkner.png", + "assets/generated/battle/hud/balls.png", + "assets/generated/audio/programs.bin", + "assets/generated/slots/gold_slots_1.png", + "assets/generated/card_flip/card_flip_1.png", + "assets/generated/pc/mail_item.png", + "assets/generated/trainer_card/card_f.png", + "data/generated/mobile_gfx.lua", + "assets/generated/battle/player_back_female.png", + "assets/generated/battle/trainers/kris.png", + "assets/generated/battle/trainers/chris.png", + -- ../pokecrystal/engine/events/fishing_gfx.asm:38-42 + "assets/generated/emotes/fishing.png", + }, +} +CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.silver = + CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.gold + +function CacheContract.requiredFilesFor(version) + local override = CacheContract.VERSION_REQUIRED_FILES_OVERRIDE[version] + if override then return override, true end + return CacheContract.REQUIRED_FILES, false +end + +function CacheContract.markerFor(version) + return CacheContract.FORMAT .. GameVersion.info(version).sha1 +end + +-- Keep the process-global CacheFs prefix isolated even when a filesystem +-- adapter raises while probing or publishing. The real CacheFs methods +-- return errors, but this also makes the contract safe for platform adapters +-- that surface I/O failures as Lua errors. +local function withVersionPrefix(version, fs, action) + local saved = fs.prefix + fs.prefix = GameVersion.cachePrefix(version) + local ok, first, second = pcall(action) + fs.prefix = saved + if not ok then return false, first end + return true, first, second +end + +function CacheContract.allRequiredFilesExist(version, fs) + fs = fs or require("src.import.CacheFs") + local ok, complete, missing = withVersionPrefix(version, fs, function() + local required, isOverride = CacheContract.requiredFilesFor(version) + local missingPath + for _, path in ipairs(required) do + if not fs.exists(path) then missingPath = path; break end + end + if not missingPath and not isOverride then + for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES[version] or {}) do + if not fs.exists(path) then missingPath = path; break end + end + end + return missingPath == nil, missingPath + end) + if not ok then return false, complete end + return complete, missing +end + +function CacheContract.readMarker(version, fs) + fs = fs or require("src.import.CacheFs") + local ok, marker, readError = withVersionPrefix(version, fs, function() + return fs.read(CacheContract.MARKER_PATH) + end) + if not ok then return nil, marker end + -- LÖVE may return contents plus a byte count; only a nil contents result + -- makes the auxiliary value an error. CacheFs' portable reader returns + -- just the contents, so this remains adapter-neutral. + if marker == nil then return nil, readError end + return marker +end + +function CacheContract.isReady(version, fs) + fs = fs or require("src.import.CacheFs") + if CacheContract.sourceTreeHasData(version) then return true end + local marker, readError = CacheContract.readMarker(version, fs) + if readError or marker ~= CacheContract.markerFor(version) then return false end + return CacheContract.allRequiredFilesExist(version, fs) +end + +function CacheContract.publish(version, fs) + fs = fs or require("src.import.CacheFs") + local complete, missing = CacheContract.allRequiredFilesExist(version, fs) + if not complete then + -- A caller may be retrying over a partially replaced cache. Do not + -- leave its old marker advertising readiness after this failed check. + local removed, removeError = withVersionPrefix(version, fs, function() + if not fs.remove then + error("cache filesystem cannot remove the completion marker") + end + return fs.remove(CacheContract.MARKER_PATH) + end) + if not removed then + return false, "cache is incomplete; missing " .. tostring(missing) + .. "; could not remove completion marker: " .. tostring(removeError) + end + return false, "cache is incomplete; missing " .. tostring(missing) + end + local changed, ok, err = withVersionPrefix(version, fs, function() + return fs.write(CacheContract.MARKER_PATH, CacheContract.markerFor(version)) + end) + if not changed then return false, tostring(ok) end + return ok, err +end + +function CacheContract.sourceTreeHasData(version) + if not (love and love.filesystem and love.filesystem.getInfo + and love.filesystem.getRealDirectory and love.filesystem.getSource) then + return false + end + local prefix = version == "red" and "" or GameVersion.cachePrefix(version) + local required, isOverride = CacheContract.requiredFilesFor(version) + local source = love.filesystem.getSource() + for _, path in ipairs(required) do + local fullPath = prefix .. path + if love.filesystem.getInfo(fullPath, "file") == nil + or love.filesystem.getRealDirectory(fullPath) ~= source then + return false + end + end + if not isOverride then + for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES[version] or {}) do + local fullPath = prefix .. path + if love.filesystem.getInfo(fullPath, "file") == nil + or love.filesystem.getRealDirectory(fullPath) ~= source then + return false + end + end + end + return true +end + +return CacheContract diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 0c7074f6..d0671ad7 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -35,189 +35,12 @@ local function cartOfScope(scope) return scope:match("^cart_(.+)$") end --- Cache generation tag; bump to force every imported version to re-extract. --- v9: Yellow audio re-anchored on pokeyellow.sym (#522) -- stale caches --- carry Red's bank $1f header, wave-table, and CryData offsets. --- v10: maps carry their raw map-header/connection/object bytes and tilesets --- their Tilesets row (#889), which a .sav export replays so a Continue on --- real hardware has a map to load; a v9 cache has none of them and exports --- the same unbootable save as before. --- Deliberately NOT bumped for the Gold trainer-pic gap: this tag invalidates --- every version at once, and that gap is Gold-only. A per-version marker in --- VERSION_REQUIRED_FILES_OVERRIDE.gold re-imports exactly the caches that lack --- the stage, which is what the Yellow markers below already do for #439/#557. --- Reach for a bump when the change spans versions or has no single file to --- point at. -local CACHE_FORMAT = "rom-cache-v10:" --- The completion marker is written under each version's cache prefix --- (red/rom-cache.complete, blue/rom-cache.complete, ...). -local MARKER_PATH = "rom-cache.complete" - --- The marker a finished import writes for a version: the generation tag plus --- that version's ROM hash, so both a format bump and a swapped ROM invalidate. -local function markerFor(version) - return CACHE_FORMAT .. GameVersion.info(version).sha1 -end +local CacheContract = require("src.import.CacheContract") local COMMUNITY_URL = "https://bois.icu" local TRUST_WARNING = "if you did not get this from bryanthaboi's github " .. "or a link from the discord that bryanthaboi himself posted, just know " .. "it might have been tampered with. go to the discord to verify " .. COMMUNITY_URL .. " (or click the logo above)" -local REQUIRED_FILES = { - "data/generated/constants.lua", - "data/generated/maps.lua", - "data/generated/text.lua", - "data/generated/field.lua", - "data/generated/battle_anims.lua", - "assets/generated/title/pokemon_logo.png", - "assets/generated/fonts/font.png", - "assets/generated/battle/front/pikachu.png", - "assets/generated/battle/anims/move_anim_0.png", - "assets/generated/battle/anims/move_anim_1.png", - "assets/generated/audio/programs.bin", - -- The trade cinematic's Game Boy / cable art. Caches built before #750 - -- carry none of it and fall back to plain rectangles, so listing one of - -- the files re-imports them without a CACHE_FORMAT bump. - "assets/generated/trade/game_boy.png", -} - --- Files only one version's cache carries. A version that predates one of --- them re-imports on its own, without dragging the other versions through a --- CACHE_FORMAT bump. -local VERSION_REQUIRED_FILES = { - yellow = { - "assets/generated/battle/trainers/jessie_james.png", -- #439 - -- Oak's own back pic and the pikapic base frames only exist in caches - -- built after their manifest symbols landed, so an older Yellow cache - -- has to re-import to stop falling back to the old man's back pic and - -- to the battle front pic (#557, #561). Both are gated on manifest - -- symbols in RomExtractor, so these markers must only ever list files - -- tools/rom_manifest_yellow.json can actually produce -- otherwise the - -- cache reads as incomplete and re-importing cannot clear it. - "assets/generated/battle/profoakb.png", - "assets/generated/pikachu/pikapic_1.png", - }, -} - --- Gold Phase 1 writes a thinner cache than Gen 1 (no battle anim sheets, --- trade art, or field.lua payload yet -- see docs/gold-phase1.md). This --- list replaces REQUIRED_FILES entirely for that version so a successful --- Gen 2 extract is not stuck as "incomplete" waiting on Gen 1 markers. -local VERSION_REQUIRED_FILES_OVERRIDE = { - gold = { - "data/generated/constants.lua", - "data/generated/maps.lua", - "data/generated/roofs.lua", -- Phase 2: forces re-import of Phase 1 caches - "data/generated/sprites.lua", -- OW sheets (Chris + NPCs) - "data/generated/scripts.lua", -- disassembled map scripts - "data/generated/text.lua", -- decoded Gen 2 dialogue strings - -- The engine's own strings, keyed by label rather than by address. A - -- cache built before RomExtractorGen2:extractText has none, and every - -- line that reads through src/core/RomText.lua would silently keep - -- printing its Lua fallback, so this re-imports those caches rather than - -- bumping CACHE_FORMAT and dragging Red, Blue and Yellow through it too. - "data/generated/rom_text.lua", - "data/generated/pokemon.lua", - "data/generated/tilesets.lua", - "data/generated/audio.lua", - -- Mart shelves + the heal machine art ride the same import, so listing - -- marts.lua alone re-imports the caches from before either existed - -- (empty shop shelves, no Pokecenter light show). - "data/generated/marts.lua", - "assets/generated/fonts/font.png", - "assets/generated/fonts/frames.png", -- the seven other OPTION textbox frames - "assets/generated/title/pokemon_logo.png", - "assets/generated/title/title_screen.png", -- TitleScreenTilemap composition - "assets/generated/title/hooh.png", - "assets/generated/title/hooh_5.png", -- wing-flap frames force re-import - "assets/generated/title/clouds.png", - "assets/generated/title/copyright_splash.png", - "data/generated/oak_speech.lua", -- Oak texts + trainer pics - "assets/generated/intro/oak.png", - "assets/generated/intro/cal.png", - "assets/generated/tilesets/johto.png", - "assets/generated/tilesets/roofs/new_bark.png", - "assets/generated/sprites/chris.png", - "assets/generated/battle/front/chikorita.png", - "assets/generated/battle/front/pikachu.png", - "assets/generated/battle/front/marill.png", -- Oak speech demo mon - -- The trainer class pics (TrainerPicPointers). FALKNER is row 0 of that - -- table, so a cache that produced any class pic at all produced this one. - -- Listed for the reason the Yellow markers above are: a cache built before - -- the stage existed reads as INCOMPLETE and re-imports itself, so this - -- particular gap cannot survive a tag bump being forgotten again. It - -- costs nothing on a current cache and is the difference between every - -- trainer battle opening with a picture and opening with none. - "assets/generated/battle/trainers/falkner.png", - -- BattleStart_TrainerHuds cannot draw its party rows from a cache made - -- before the four ball tiles were extracted (#1502). - "assets/generated/battle/hud/balls.png", - "assets/generated/audio/programs.bin", - -- Goldenrod Game Corner reel + board art (#1581). menu_gfx.lua used to - -- advertise these paths even when Slots*LZ / CardFlip* were absent from - -- the manifest, so a cache that never wrote the PNGs still looked - -- complete and SlotMachine crashed on its labelled-cell fallback. - "assets/generated/slots/gold_slots_1.png", - "assets/generated/card_flip/card_flip_1.png", - -- PCMailGFX (engine/pokemon/bills_pc.asm:2170-2173) - "assets/generated/pc/mail_item.png", - -- FishingGFX (engine/events/fishing_gfx.asm:23): the pose rows and the rod - -- tiles, which a cache built before #1708 has none of. - "assets/generated/emotes/fishing.png", - }, - crystal = { - "data/generated/constants.lua", - "data/generated/maps.lua", - "data/generated/roofs.lua", - "data/generated/sprites.lua", - "data/generated/scripts.lua", - "data/generated/text.lua", - "data/generated/rom_text.lua", - "data/generated/pokemon.lua", - "data/generated/encounters.lua", - "data/generated/tilesets.lua", - "data/generated/landmarks.lua", - "data/generated/audio.lua", - "data/generated/marts.lua", - "data/generated/oak_speech.lua", - "data/generated/title.lua", - "data/generated/intro.lua", - "assets/generated/fonts/font.png", - "assets/generated/fonts/frames.png", - "assets/generated/title/crystal_logo.png", - "assets/generated/title/crystal_wordmark.png", - "assets/generated/title/crystal_suicune.png", - "assets/generated/splash/ditto.png", - "assets/generated/intro/chris.png", - "assets/generated/intro/kris.png", - "assets/generated/intro/suicune_run_sprites.png", - "assets/generated/intro/unowns_tiles.png", - "assets/generated/intro/oak.png", - "assets/generated/tilesets/johto.png", - "assets/generated/tilesets/roofs/new_bark.png", - "assets/generated/sprites/chris.png", - "assets/generated/sprites/kris.png", - "assets/generated/battle/front/chikorita.png", - "assets/generated/battle/front/wooper.png", - "assets/generated/battle/front/pikachu.png", - "assets/generated/battle/trainers/falkner.png", - "assets/generated/battle/hud/balls.png", - "assets/generated/audio/programs.bin", - "assets/generated/slots/gold_slots_1.png", - "assets/generated/card_flip/card_flip_1.png", - "assets/generated/pc/mail_item.png", - "assets/generated/trainer_card/card_f.png", - "data/generated/mobile_gfx.lua", - "assets/generated/battle/player_back_female.png", - "assets/generated/battle/trainers/kris.png", - "assets/generated/battle/trainers/chris.png", - -- ../pokecrystal/engine/events/fishing_gfx.asm:38-42 - "assets/generated/emotes/fishing.png", - }, -} --- Same Gen 2 extract, so a Silver cache is complete when the same files exist. -VERSION_REQUIRED_FILES_OVERRIDE.silver = VERSION_REQUIRED_FILES_OVERRIDE.gold - -- "Split-screen ROM selector" first-run palette (matches the FirstRun mockup): -- a dark neon arcade panel, one column per game. -- Red, Blue, and Yellow share the same importer flow once listed in @@ -270,58 +93,6 @@ local PAL = { chipInkGold = { 58, 44, 0 }, -- #3a2c00 dark "Y" on the gold chip } --- Per-version required cache files. Gold replaces the Gen 1 list entirely --- (VERSION_REQUIRED_FILES_OVERRIDE); Yellow adds a few extra markers. -local function requiredFilesFor(version) - local override = VERSION_REQUIRED_FILES_OVERRIDE[version] - if override then return override, true end - return REQUIRED_FILES, false -end - --- CacheFs.exists checks the game folder directly for a portable install, --- otherwise the save directory through love.filesystem. It honors --- CacheFs.prefix, so we point it at the version's cache subtree (red/, --- blue/, yellow/, gold/). -local function allRequiredFilesExist(version) - local CacheFs = require("src.import.CacheFs") - local saved = CacheFs.prefix - CacheFs.prefix = GameVersion.cachePrefix(version) - local ok = true - local required, isOverride = requiredFilesFor(version) - for _, path in ipairs(required) do - if not CacheFs.exists(path) then ok = false; break end - end - if ok and not isOverride then - for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do - if not CacheFs.exists(path) then ok = false; break end - end - end - CacheFs.prefix = saved - return ok -end - --- A developer checkout / Python build leaves generated data in the physfs --- source: Red at the historical root, Blue/Yellow/Gold in their versioned --- trees. Imported Red caches still live under red/. Check source paths --- directly so that cache prefix cannot hide Red's source tree, and keep --- save-dir caches from counting as current source data. -local function sourceTreeHasData(version) - if not love.filesystem.getRealDirectory then return false end - local prefix = version == "red" and "" or GameVersion.cachePrefix(version) - local required, isOverride = requiredFilesFor(version) - for _, path in ipairs(required) do - if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end - end - if not isOverride then - for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do - if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end - end - end - local path = prefix .. required[1] - local real = love.filesystem.getRealDirectory(path) - return real == love.filesystem.getSource() -end - -- ------- ROM cache location -- -- The extracted cache (data/generated, assets/generated) plus the @@ -377,10 +148,15 @@ local function purgeSaveDirCache() -- / yellow/ / gold/ prefix) so it cannot shadow the portable game-folder cache. for _, version in ipairs(GameVersion.ORDER) do local prefix = GameVersion.cachePrefix(version) - if saveDirHas(prefix .. MARKER_PATH) or saveDirHas(prefix .. REQUIRED_FILES[1]) then + local required = CacheContract.requiredFilesFor(version) + local hasRequired = false + for _, path in ipairs(required) do + if saveDirHas(prefix .. path) then hasRequired = true; break end + end + if saveDirHas(prefix .. CacheContract.MARKER_PATH) or hasRequired then removeTree(prefix .. "data/generated") removeTree(prefix .. "assets/generated") - love.filesystem.remove(prefix .. MARKER_PATH) + love.filesystem.remove(prefix .. CacheContract.MARKER_PATH) end end end @@ -395,13 +171,7 @@ function RomImporter.isReady(version) -- save-directory copy that would otherwise shadow it at runtime. purgeSaveDirCache() end - -- Generated data in a developer checkout / Python build is always current. - if sourceTreeHasData(version) then return true end - local saved = CacheFs.prefix - CacheFs.prefix = GameVersion.cachePrefix(version) - local marker = CacheFs.read(MARKER_PATH) - CacheFs.prefix = saved - return marker == markerFor(version) and allRequiredFilesExist(version) + return CacheContract.isReady(version, CacheFs) end function RomImporter.syncAndroidShortcuts(activeVersion) @@ -1660,12 +1430,9 @@ function RomImporter.new(onComplete, opts) self.ready[version] = ready -- a marker present but for an older cache generation / different ROM means -- "update required" (re-import) rather than a clean first-run choose - local saved = CacheFs.prefix - CacheFs.prefix = info.cachePrefix - local marker = CacheFs.read(MARKER_PATH) - CacheFs.prefix = saved + local marker = CacheContract.readMarker(version, CacheFs) self.returning[version] = - (not ready) and marker ~= nil and marker ~= markerFor(version) + (not ready) and marker ~= nil and marker ~= CacheContract.markerFor(version) self.romName[version] = "pokemon_" .. info.id .. ((info.id == "yellow" or GameVersion.generation(version) == 2) and ".gbc" or ".gb") @@ -1980,10 +1747,10 @@ function RomImporter:startData(data, displayName) local cleared, clearError = pcall(function() removeTree(prefix .. "data/generated") removeTree(prefix .. "assets/generated") - love.filesystem.remove(prefix .. MARKER_PATH) + love.filesystem.remove(prefix .. CacheContract.MARKER_PATH) CacheFs.removeTree("data/generated") CacheFs.removeTree("assets/generated") - CacheFs.remove(MARKER_PATH) + CacheFs.remove(CacheContract.MARKER_PATH) end) CacheFs.prefix = savedPrefix if not cleared then @@ -2055,7 +1822,7 @@ function RomImporter:_completeImport(version, prefix, displayName) -- appear once every required file is in place. local savedPrefix = CacheFs.prefix CacheFs.prefix = prefix - local ok, writeError = CacheFs.write(MARKER_PATH, markerFor(version)) + local ok, writeError = CacheContract.publish(version, CacheFs) CacheFs.prefix = savedPrefix if not ok then error("could not finish the private cache: " .. tostring(writeError)) diff --git a/src/link/CodeEntry.lua b/src/link/CodeEntry.lua index f0335662..23c74241 100644 --- a/src/link/CodeEntry.lua +++ b/src/link/CodeEntry.lua @@ -1,28 +1,63 @@ --- Shared 6-slot room-code entry widget: the digit-scrub interaction --- LinkState's own `ipDigits`/`addrPos` already uses for IP entry, over the --- Crockford-32-style alphabet pokeserver room/tournament codes are drawn --- from (23456789ABCDEFGHJKMNPQRSTUVWXYZ -- no 0/O/1/I/L, so a code read --- aloud or handwritten never has to be checked twice). +-- Shared slot-scrub entry widget: the digit-scrub interaction LinkState's +-- own `ipDigits`/`addrPos` already uses for IP entry, over the Crockford-32 +-- style alphabet pokeserver room/tournament codes are drawn from +-- (23456789ABCDEFGHJKMNPQRSTUVWXYZ -- no 0/O/1/I/L, so a code read aloud or +-- handwritten never has to be checked twice). +-- +-- The Gen 1 naming grid cannot stand in for this: it has no digits at all +-- (data/text/alphabets.asm is letters and punctuation), so a room code or +-- an address typed there would be unenterable. That is what this exists +-- for. +-- +-- new() takes an optional {length=, charset=} so the same interaction can +-- carry something other than a room code -- a dotted IP over "0123456789.", +-- say. Both default to the room-code shape, so existing callers are +-- unaffected and CodeEntry.LENGTH / CodeEntry.CHARSET still describe them. local CodeEntry = {} CodeEntry.CHARSET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ" CodeEntry.LENGTH = 6 -function CodeEntry.new() +-- state carries its own length/charset so a caller holding two widgets of +-- different shapes cannot have one read the other's alphabet +function CodeEntry.new(opts) + local charset = (opts and opts.charset) or CodeEntry.CHARSET + local length = (opts and opts.length) or CodeEntry.LENGTH local chars = {} - for i = 1, CodeEntry.LENGTH do chars[i] = 1 end -- index into CHARSET, 1-based - return { chars = chars, pos = 1 } + for i = 1, length do chars[i] = 1 end -- index into charset, 1-based + return { chars = chars, pos = 1, charset = charset, length = length } end -local N = #CodeEntry.CHARSET +-- Seed the slots from an existing string: prefilling the LAN address means +-- the player scrubs the last octet instead of all twelve digits. Anything +-- not in the charset lands on slot 1's character. +-- Slots past the end of the seed -- and any character the charset does not +-- carry -- land on the charset's blank where it has one, so seeding a +-- 15-slot address widget with "192.168.1.40" reads back as that address and +-- not as "192.168.1.40000". A charset with no blank (the room code's) has +-- nowhere to put one, so those fall back to the first character as before. +function CodeEntry.fromText(text, opts) + local state = CodeEntry.new(opts) + local blank = state.charset:find(" ", 1, true) or 1 + for i = 1, state.length do + local ch = tostring(text or ""):sub(i, i) + state.chars[i] = (ch ~= "" and state.charset:find(ch, 1, true)) or blank + end + return state +end + +local function charsetOf(state) return state.charset or CodeEntry.CHARSET end +local function lengthOf(state) return state.length or CodeEntry.LENGTH end function CodeEntry.up(state) - state.chars[state.pos] = state.chars[state.pos] % N + 1 + local n = #charsetOf(state) + state.chars[state.pos] = state.chars[state.pos] % n + 1 end function CodeEntry.down(state) - state.chars[state.pos] = (state.chars[state.pos] - 2) % N + 1 + local n = #charsetOf(state) + state.chars[state.pos] = (state.chars[state.pos] - 2) % n + 1 end function CodeEntry.left(state) @@ -30,14 +65,18 @@ function CodeEntry.left(state) end function CodeEntry.right(state) - state.pos = math.min(CodeEntry.LENGTH, state.pos + 1) + state.pos = math.min(lengthOf(state), state.pos + 1) +end + +-- the character in slot i, which is what a draw loop wants +function CodeEntry.charAt(state, i) + local charset = charsetOf(state) + return charset:sub(state.chars[i], state.chars[i]) end function CodeEntry.text(state) local out = {} - for i = 1, CodeEntry.LENGTH do - out[i] = CodeEntry.CHARSET:sub(state.chars[i], state.chars[i]) - end + for i = 1, lengthOf(state) do out[i] = CodeEntry.charAt(state, i) end return table.concat(out) end diff --git a/src/link/LinkState.lua b/src/link/LinkState.lua index c2464beb..9e119a5d 100644 --- a/src/link/LinkState.lua +++ b/src/link/LinkState.lua @@ -112,6 +112,32 @@ function LinkState.newJoinOnline(game, code) return self end +-- Adopt a transport that is ALREADY paired and skip the connect UI: an +-- overworld multiplayer session handing one pair of players off to a battle +-- or a trade. The caller has settled which mode and which side hosts, so +-- all that is left is the hello exchange every link session runs before it +-- commits -- the fingerprint/mod compatibility check still gets its say, +-- exactly as it would have on the LAN or ONLINE path. +-- +-- `transport` is anything Session accepts (update/poll/send/close plus the +-- .paired/.closed/.error fields), which is what lets a mod route a battle +-- over a channel of its own. Ownership transfers with it: exitWith closes +-- the session, so the caller's transport is done once this state unwinds. +-- +-- opts.forceLevel the level rule, normally chosen on the battleOptions +-- screen; an adopted session has no menu to pick it on +function LinkState.newFromSession(game, transport, mode, isHost, opts) + local self = LinkState.new(game) + self.net = Session.new(transport, { role = isHost and "host" or "guest", + kind = "link" }) + self.adopted = true + self.adoptedMode, self.adoptedHost = mode, isHost and true or false + self.forceLevel = opts and opts.forceLevel or nil + self.stage = "adopted" + self:sendHello(isHost and mode or nil) + return self +end + function LinkState:exitWith(message, reason) DiscordPresence.setJoinCode(nil) self.game.linkSession = nil -- back to the player's own GAME SPEED @@ -203,8 +229,9 @@ function LinkState:decideCompat(mode, isHost) mods = peer and peer.mods, fingerprint = peer and peer.fingerprint }, }) if self.verdict == "full" or self.verdict == "vanilla_peer" then - if isHost and mode == "battle" then + if isHost and mode == "battle" and not self.adopted then -- host picks the level rule now, before the parties are exchanged + -- (an adopted session had it passed in; see newFromSession) self.stage = "battleOptions" else self:startMode(mode, isHost) @@ -243,6 +270,16 @@ function LinkState:update(dt) end end + -- handed over from an already-paired session (LinkState.newFromSession): + -- both hellos are in flight, and the first one to land settles compat and + -- drops us straight into the agreed mode + if self.stage == "adopted" then + if self:pollHello() then + self:decideCompat(self.adoptedMode, self.adoptedHost) + end + return + end + if self.stage == "menu" then if input:wasPressed("down") then self.index = self.index % 3 + 1 @@ -497,11 +534,26 @@ function LinkState:update(dt) return end self.game.stack:push(battle) + self.battle = battle self.stage = "battleRunning" end elseif self.stage == "battleRunning" then if self.game.stack:top() == self then + -- the lockstep copies carry the damage the real party never takes + -- (cable rules), so a mode that wants it -- a tournament ladder, a + -- battle royale -- reads it from here before the state unwinds + local battle = self.battle + if battle and Runtime.wants("link.battle_ended") then + Runtime.emit("link.battle_ended", { + result = battle.result or "ended", + myParty = battle.playerParty, + theirParty = battle.enemyParty, + peerName = self.peerName, + role = self.isHost and "host" or "guest", + }) + end + self.battle = nil self:exitWith(nil) -- battle finished end end diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 6ae33583..5b24bcd7 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -273,7 +273,7 @@ function Loader.new(opts) modInput = {}, modEnv = {}, stepsQueues = {}, cartSwitches = {}, fs = (opts and opts.fs) or (love and love.filesystem), cart = opts and opts.cart or nil, - dev = dev, + dev = dev == true, safeMode = false, -- Which generation this boot is (1 or 2). Fixed at construction: the -- active version is set once in main.lua's bootGame before anything @@ -1148,6 +1148,10 @@ function Loader:_api(mod) id = modId, version = mod.manifest.version, path = mod.path, + -- Fixed at Loader construction and copied as plain data: a sandboxed + -- entry chunk can decide whether to register developer-only diagnostics + -- without receiving the process environment or the loader itself. + developer = loader.dev == true, -- a deep copy: what a mod does to its own view never reaches the loader manifest = Merge.deepCopy(mod.manifest), content = {}, diff --git a/src/net/Fetch.lua b/src/net/Fetch.lua index b7955bc8..db7eb788 100644 --- a/src/net/Fetch.lua +++ b/src/net/Fetch.lua @@ -234,7 +234,9 @@ function Fetch.shutdown() end for _, th in ipairs(workers) do pcall(function() th:wait() end) end workers = {} - cmdCh, resCh, quitCh, ready = nil, nil, nil, false + cmdCh, resCh, quitCh, ready = nil, nil, nil, nil end +require("src.core.SessionLifecycle").registerProcessShutdown(Fetch.shutdown) + return Fetch diff --git a/src/render/Assets.lua b/src/render/Assets.lua index afcc577b..3469d0f1 100644 --- a/src/render/Assets.lua +++ b/src/render/Assets.lua @@ -14,6 +14,8 @@ local Assets = {} local cache = {} -- downstream caches that must empty when the search path changes local invalidators = {} +-- optional GPU release hooks for session end (never run on hot reload flush) +local releasers = {} -- The loader bridge: overrideOrder() yields mods highest-priority-first -- and derivedPath(rel) yields an existing save/mod-derived//. @@ -68,8 +70,18 @@ function Assets.imageData(path) return love.image.newImageData(Assets.resolve(path)) end -function Assets.register(invalidate) - invalidators[#invalidators + 1] = invalidate +-- Register a cache invalidator, or { invalidate = fn, release = fn } when a +-- module caches LOVE Images/Canvases and can eagerly free them at session end. +-- release is optional and is NOT run on flush/invalidate (HotReload safe). +-- MapLoader is the canonical split-hook example: invalidateAll clears tables +-- without GPU release; releaseAll evicts every resident map renderer. +function Assets.register(hooks) + if type(hooks) == "function" then + invalidators[#invalidators + 1] = hooks + return + end + if hooks.invalidate then invalidators[#invalidators + 1] = hooks.invalidate end + if hooks.release then releasers[#releasers + 1] = hooks.release end end -- hot reload's single entry point (20-developer-tooling): drop the central @@ -82,6 +94,17 @@ end Assets.flush = Assets.invalidate +-- In-process return-to-launcher / editor close: release central Images and +-- run release hooks only. Does not call invalidate hooks (MapLoader must +-- keep invalidateAll separate from releaseAll). +function Assets.releaseSession() + for _, img in pairs(cache) do + if img and img.release then pcall(img.release, img) end + end + cache = {} + for _, fn in ipairs(releasers) do pcall(fn) end +end + -- Loader:load hands over the live mod set once the merge is done. Load -- order is priority ascending, so the search walks it backwards: the mod -- that wins the record merge wins the asset lookup too. diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index c9da8b27..fa9250df 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -117,6 +117,8 @@ function Renderer:releaseCanvases() self.worldOverride = nil end +Renderer.release = Renderer.releaseCanvases + function Renderer:init() -- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua -- (#208). Every canvas below is sized in framebuffer pixels for the same diff --git a/src/ui/Screens.lua b/src/ui/Screens.lua index 755f2472..032441a3 100644 --- a/src/ui/Screens.lua +++ b/src/ui/Screens.lua @@ -166,6 +166,14 @@ local function build(game, id, ...) inst = factory.new(game, ...) end inst.screenId = inst.screenId or id + -- Standardized opt-in marker for mod-created options/settings screens. + -- A mod may declare `isModOptions = true` on its screen factory table or + -- on the returned instance. Either way the flag is propagated so that other + -- UI mods can detect mod options screens reliably without brittle screenId + -- string-matching (issue #1697). + if factory.isModOptions and inst.isModOptions == nil then + inst.isModOptions = true + end return inst end diff --git a/src/ui/SkinStudio.lua b/src/ui/SkinStudio.lua index b27aa26e..d7a111d8 100644 --- a/src/ui/SkinStudio.lua +++ b/src/ui/SkinStudio.lua @@ -6,6 +6,8 @@ local TouchControls = require("src.core.TouchControls") local SaveData = require("src.core.SaveData") local FilePicker = require("src.core.FilePicker") local SafeArea = require("src.core.SafeArea") +local GamepadMap = require("src.core.GamepadMap") +local PadCursor = require("src.ui.PadCursor") local Studio = {} @@ -501,6 +503,7 @@ function Studio.load(opts) Studio.thumbs = {} Studio.pointerX, Studio.pointerY = nil, nil Studio.pointerDown, Studio.touchId = false, nil + PadCursor.reset() -- Studio always opens on the library. Creating and choosing a skin are -- first-class tasks, not controls buried inside the editor workspace. Studio.mode = "library" @@ -758,6 +761,7 @@ function Studio.unload() Studio.undoStack, Studio.redoStack = {}, {} Studio.pointerX, Studio.pointerY = nil, nil Studio.pointerDown, Studio.touchId = false, nil + PadCursor.reset() end -- The Studio is a real touch editor on Android and iOS. Keeping this here, @@ -2678,7 +2682,12 @@ function Studio.draw() local f = Studio._frame Kit.layout(f.w, f.h) local mx, my = love.mouse.getPosition() - if Studio.pointerX ~= nil then mx, my = Studio.pointerX, Studio.pointerY end + local px, py, padActive = PadCursor.pointer() + if padActive then + mx, my = px, py + elseif Studio.pointerX ~= nil then + mx, my = Studio.pointerX, Studio.pointerY + end Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel) Studio.clicked, Studio.wheel = false, 0 Theme.fill(0, 0, W, H, PAL.bg, 1) @@ -2691,19 +2700,26 @@ function Studio.draw() Kit.blockClicks = false Studio.drawOverlay(f.w, f.h) Kit.endFrame() + PadCursor.draw() end -- ---------------------------------------------------------------- input -function Studio.update() +function Studio.update(dt) + PadCursor.update(dt or 0) + local x, y, active = PadCursor.pointer() + if active and Studio.pointerDown then Studio.mousemoved(x, y) end + local wheel = PadCursor.takeWheel() + if wheel ~= 0 then Studio.wheelmoved(0, wheel) end if not Studio.pendingPlay then return end Studio.pendingPlay = false local onPlay, version, canvas = Studio.onPlay, Studio.version, Studio.canvas() if onPlay then onPlay(version, canvas) end end -function Studio.mousepressed(x, y, button) +function Studio.mousepressed(x, y, button, fromPad) if button ~= 1 then return end + if not fromPad then PadCursor.yieldToPointer() end Studio.pointerX, Studio.pointerY = x, y Studio.pointerDown = true Studio.clicked = true @@ -2760,6 +2776,65 @@ function Studio.touchpressed(id, x, y) return Studio.mousepressed(x, y, 1) end +local function closeFromPad() + if Studio.confirm then + Studio.confirmNo() + elseif Studio.modal then + Studio.closeModal() + elseif Studio.mode == "editor" then + Studio.backToLibrary() + elseif Studio.onClose then + Studio.onClose() + end +end + +local function handlePadAction(action) + if action == "a" then + local x, y = PadCursor.pointer() + Studio.mousepressed(x, y, 1, true) + elseif action == "b" then + closeFromPad() + end +end + +function Studio.gamepadpressed(joystick, button) + handlePadAction(PadCursor.gamepadpressed(joystick, button)) +end + +function Studio.gamepadreleased(joystick, button) + PadCursor.gamepadreleased(joystick, button) + if GamepadMap.mapGamepadButton(button) == "a" then + local x, y = PadCursor.pointer() + Studio.mousereleased(x, y, 1) + end +end + +function Studio.gamepadaxis(joystick, axis, value) + PadCursor.gamepadaxis(joystick, axis, value) +end + +function Studio.joystickpressed(joystick, button) + handlePadAction(PadCursor.joystickpressed(joystick, button)) +end + +function Studio.joystickreleased(joystick, button) + PadCursor.joystickreleased(joystick, button) + if GamepadMap.ignoreRawForJoystick(joystick) then return end + local padButton = GamepadMap.mapRawToGamepadButton(button) + if padButton and GamepadMap.mapGamepadButton(padButton) == "a" then + local x, y = PadCursor.pointer() + Studio.mousereleased(x, y, 1) + end +end + +function Studio.joystickaxis(joystick, axis, value) + PadCursor.joystickaxis(joystick, axis, value) +end + +function Studio.joystickhat(joystick, hat, direction) + PadCursor.joystickhat(joystick, hat, direction) +end + function Studio.touchmoved(id, x, y) if Studio.touchId ~= id then return end return Studio.mousemoved(x, y) @@ -2774,7 +2849,8 @@ end function Studio.wheelmoved(_, dy) if Studio.mode == "editor" and not Studio.modalUp() and dy and dy ~= 0 then local work = Studio.canvasWorkspace - local mx, my = Studio.pointerX, Studio.pointerY + local mx, my, active = PadCursor.pointer() + if not active then mx, my = Studio.pointerX, Studio.pointerY end if (not mx or not my) and love and love.mouse and love.mouse.getPosition then mx, my = love.mouse.getPosition() end diff --git a/src/ui/gen2/Chrome.lua b/src/ui/gen2/Chrome.lua index 12f8a802..6a147077 100644 --- a/src/ui/gen2/Chrome.lua +++ b/src/ui/gen2/Chrome.lua @@ -138,11 +138,39 @@ function Chrome.printThrough(text, tx, ty, palette, invert) local paper = pal[1] or { 255, 255, 255 } love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) love.graphics.rectangle("fill", tx * 8, ty * 8, width, 8) - love.graphics.setColor(1, 1, 1, 1) + -- Per glyph, not per string: a TTF-mod build still keeps the multi-byte + -- charmap sequences (the naming screen's /, the 'd/'l/'s ligatures) + -- and anything the mod names in ttf.tiles (Font.lua's own note on keeping + -- digits tile-based for column alignment) on their ROM tiles, so a string + -- can freely mix the two kinds of glyph. + local ink = pal[4] or { 0, 0, 0 } local previous = love.graphics.getShader() - GbcPalette.useRaw(pal) - Font.draw(text, tx * 8, ty * 8) - love.graphics.setShader(previous) + local shaded = false + local pen = tx * 8 + for _, code in ipairs(Font.encode(text)) do + if code >= Font.TTF_BASE then + -- The shader below recovers a shade from the RED CHANNEL of an already + -- flat-shaded 2bpp tile sheet -- exactly what a TTF glyph is not. + -- LÖVE's font rasterizer stores glyph coverage as alpha over a plain + -- white texture, which this shader reads back as shade 0 no matter how + -- solid the glyph looks, painting the character the SAME colour as the + -- paper rect just drawn above it: invisible (reported against a real + -- Gold build with a TTF translation mod active, gen1recomp#1642). A TTF + -- glyph has no discrete shade to recover in the first place, so skip + -- the shader for it and tint it with the palette's own ink colour + -- (shade 3, the same entry `rgb = pal3` would have mapped a black tile + -- pixel to) directly. + if shaded then love.graphics.setShader(previous); shaded = false end + love.graphics.setColor(ink[1] / 255, ink[2] / 255, ink[3] / 255, 1) + elseif not shaded then + love.graphics.setColor(1, 1, 1, 1) + GbcPalette.useRaw(pal) + shaded = true + end + Font.drawCode(code, pen, ty * 8) + pen = pen + Font.advanceOf(code) + end + if shaded then love.graphics.setShader(previous) end love.graphics.setColor(0, 0, 0, 1) return width end diff --git a/src/ui/gen2/MainMenu.lua b/src/ui/gen2/MainMenu.lua index 15d653ec..da0d42e9 100644 --- a/src/ui/gen2/MainMenu.lua +++ b/src/ui/gen2/MainMenu.lua @@ -24,6 +24,7 @@ local Logger = require("src.core.Logger") local Music = require("src.core.Music") local Runtime = require("src.mods.Runtime") local Save = require("src.core.gen2.Save") +local Strings = require("src.core.Strings") local MainMenu = {} MainMenu.__index = MainMenu @@ -73,14 +74,14 @@ local function sameItems(_, items) return items end function MainMenu:buildList() local items = {} if self.hasSave then - items[#items + 1] = { label = "CONTINUE", value = "continue" } + items[#items + 1] = { label = Strings("CONTINUE"), value = "continue" } end - items[#items + 1] = { label = "NEW GAME", value = "new" } - items[#items + 1] = { label = "OPTION", value = "option" } + items[#items + 1] = { label = Strings("NEW GAME"), value = "new" } + items[#items + 1] = { label = Strings("OPTION"), value = "option" } -- Not on the cart: a cartridge is left by switching the console off, and -- there is no console here. Mirrors the Gen 1 port's title menu -- (src/ui/TitleState.lua), which adds the same row for the same reason. - items[#items + 1] = { label = "EXIT GAME", value = "exit" } + items[#items + 1] = { label = Strings("EXIT GAME"), value = "exit" } -- The same hook name and the same (game, items) payload the Gen 1 title -- menu raises (src/ui/TitleState.lua:openMenu), so one mod's title rows -- serve both games; only the row shape differs, because Chrome.List reads @@ -170,7 +171,7 @@ function MainMenu:drawClockBox() -- minutes; the AM/PM half is drawn by PrintHour itself. local display = hour % 12 if display == 0 then display = 12 end - local half = hour < 12 and "AM" or "PM" + local half = Strings(hour < 12 and "AM" or "PM") Chrome.print(("%s:%s %s"):format( Chrome.number(display, 2), Chrome.number(minute, 2, true), half), 4, 16) end @@ -180,15 +181,15 @@ function MainMenu:drawSavePanel() -- DisplaySaveInfoOnContinue: a box down the right side listing the trainer. Chrome.textbox(4, 0, 14, 9) if not summary then - Chrome.print("NO SAVE FILE", 5, 2) + Chrome.print(Strings("NO SAVE FILE"), 5, 2) return end - Chrome.print("PLAYER " .. summary.name, 5, 2) - Chrome.print("BADGES", 5, 4) + Chrome.print(Strings("PLAYER %s", summary.name), 5, 2) + Chrome.print(Strings("BADGES"), 5, 4) Chrome.printRight(tostring(summary.badges), 17, 4) - Chrome.print("POKéDEX", 5, 6) + Chrome.print(Strings("POKéDEX"), 5, 6) Chrome.printRight(tostring(summary.caught), 17, 6) - Chrome.print("TIME", 5, 8) + Chrome.print(Strings("TIME"), 5, 8) Chrome.printRight(("%d:%s"):format( summary.hours, Chrome.number(summary.minutes, 2, true)), 17, 8) end diff --git a/src/ui/gen2/NamingScreen.lua b/src/ui/gen2/NamingScreen.lua index 7107e296..6cca1deb 100644 --- a/src/ui/gen2/NamingScreen.lua +++ b/src/ui/gen2/NamingScreen.lua @@ -21,6 +21,7 @@ local Chrome = require("src.ui.gen2.Chrome") local Font = require("src.render.Font") local GbcPalette = require("src.render.GbcPalette") local Runtime = require("src.mods.Runtime") +local Strings = require("src.core.Strings") local NamingScreen = {} NamingScreen.__index = NamingScreen @@ -67,8 +68,15 @@ local BOX_INPUT_LOWER = { -- case target names the board it SWITCHES TO, not the one it is on: the last -- row of NameInputUpper is "lower DEL END" and the last row of -- NameInputLower is "UPPER DEL END" (data/text/name_input_chars.asm). -local BOTTOM_UPPER_LABELS = { "lower", "DEL", "END" } -local BOTTOM_LOWER_LABELS = { "UPPER", "DEL", "END" } +-- Wrapped in Strings.source, not Strings: both tables are built once at +-- require time, before any mod's Strings.load has a catalog to answer from +-- (src/core/Strings.lua's own note on this); drawPanel resolves them live. +local BOTTOM_UPPER_LABELS = { + Strings.source("lower"), Strings.source("DEL"), Strings.source("END"), +} +local BOTTOM_LOWER_LABELS = { + Strings.source("UPPER"), Strings.source("DEL"), Strings.source("END"), +} -- Cursor tile for each target: NamingScreen_AnimateCursor's .CaseDelEnd adds -- pixel $00 / $30 / $60 to the cursor's own XCOORD of 24 (`depixel 10, 3`), -- which is OAM x 24 / 72 / 120 and so screen tile 2 / 8 / 14. The bracket is @@ -81,11 +89,11 @@ local BOTTOM_CURSOR_TILES = 5 -- NAME_* types (constants/menu_constants.asm order) as prompts + field sizes. -- Lengths are the ASM's *_NAME_LENGTH - 1, i.e. usable characters. NamingScreen.TYPES = { - player = { prompt = "YOUR NAME?", maxLength = 7, sprite = "SPRITE_CHRIS", - spriteFemale = "SPRITE_KRIS" }, - rival = { prompt = "RIVAL'S NAME?", maxLength = 7, sprite = "SPRITE_RIVAL" }, - mom = { prompt = "MOTHER'S NAME?", maxLength = 7, sprite = "SPRITE_MOM" }, - box = { prompt = "BOX NAME?", maxLength = 8, isBox = true }, + player = { prompt = Strings.source("YOUR NAME?"), maxLength = 7, + sprite = "SPRITE_CHRIS", spriteFemale = "SPRITE_KRIS" }, + rival = { prompt = Strings.source("RIVAL'S NAME?"), maxLength = 7, sprite = "SPRITE_RIVAL" }, + mom = { prompt = Strings.source("MOTHER'S NAME?"), maxLength = 7, sprite = "SPRITE_MOM" }, + box = { prompt = Strings.source("BOX NAME?"), maxLength = 8, isBox = true }, nickname = { prompt = nil, maxLength = 10 }, } @@ -114,7 +122,7 @@ function NamingScreen.new(game, opts) or (game and game.save and game.save.player and game.save.player.gender) self.isBox = opts.isBox or kind.isBox or false self.maxLength = opts.maxLength or kind.maxLength or 7 - self.prompt = opts.prompt or kind.prompt or "NICKNAME?" + self.prompt = opts.prompt or kind.prompt or Strings.source("NICKNAME?") self.monName = opts.monName self.onDone = opts.onDone self.onCancel = opts.onCancel @@ -504,11 +512,15 @@ function NamingScreen:drawPanel() end local pal = self.palette if self.monName then - -- Nickname header is two lines: "'S" then "NICKNAME?". - Chrome.printThrough(self.monName .. "'S", 5, 2, pal) - Chrome.printThrough("NICKNAME?", 5, 4, pal) + -- Nickname header is two lines: "'S" then "NICKNAME?". Kept as two + -- Strings() calls, one per line (Chrome.printThrough draws a single row), + -- with the mon name folded into the first line's own format string so a + -- language whose possessive is not a bare suffix can restructure that + -- line rather than being stuck splicing one on. + Chrome.printThrough(Strings("%s'S", self.monName), 5, 2, pal) + Chrome.printThrough(Strings("NICKNAME?"), 5, 4, pal) else - Chrome.printThrough(self.prompt, 5, 2, pal) + Chrome.printThrough(Strings(self.prompt), 5, 2, pal) end self:drawEntry(5, self.isBox and 4 or 6) @@ -520,14 +532,18 @@ function NamingScreen:drawPanel() for col = 0, 8 do local ch = line[col + 1] if ch and ch ~= " " and ch ~= "" then - Chrome.printThrough(ch, 2 + col * 2, keyboardTop + row * 2, pal) + -- Same seam the Gen 1 board's cells go through (src/ui/NamingScreen + -- .lua's own Strings(cell)): a script whose alphabet does not fit + -- A-Z can swap a cell's glyph without needing the heavier + -- ui.naming.grid hook this screen also offers. + Chrome.printThrough(Strings(ch), 2 + col * 2, keyboardTop + row * 2, pal) end end end local labels = self.lower and BOTTOM_LOWER_LABELS or BOTTOM_UPPER_LABELS local bottomY = keyboardTop + bottom * 2 for i, label in ipairs(labels) do - Chrome.printThrough(label, BOTTOM_LABEL_TX[i], bottomY, pal) + Chrome.printThrough(Strings(label), BOTTOM_LABEL_TX[i], bottomY, pal) end local function cursor() self:drawCursorBox(self:cursorTile()) end diff --git a/src/ui/gen2/OptionsMenu.lua b/src/ui/gen2/OptionsMenu.lua index 61e2b431..e21f2b8a 100644 --- a/src/ui/gen2/OptionsMenu.lua +++ b/src/ui/gen2/OptionsMenu.lua @@ -23,6 +23,7 @@ local Logger = require("src.core.Logger") local Performance = require("src.core.Performance") local Runtime = require("src.mods.Runtime") local Save = require("src.core.gen2.Save") +local Strings = require("src.core.Strings") local OptionsMenu = {} OptionsMenu.__index = OptionsMenu @@ -43,43 +44,60 @@ end -- Each row: the label, the option key it edits, and the cycle of values with -- the exact strings the cart prints (trailing spaces included -- they are what -- blank the longer previous value, e.g. "MID " over "SLOW"). +-- Labels and cart-original display strings are wrapped in Strings.source so +-- the catalog generator harvests them even though this table is built once +-- at require time, before any mod's Strings.load has a catalog to answer +-- from (src/core/Strings.lua's own note on this). The lookup itself happens +-- live, in drawPanel, through plain Strings(...) calls. local ROWS = { { - label = "TEXT SPEED", key = "textSpeed", + label = Strings.source("TEXT SPEED"), key = "textSpeed", values = { "FAST", "MID", "SLOW" }, - display = { FAST = "FAST", MID = "MID ", SLOW = "SLOW" }, - }, - { - label = "BATTLE SCENE", key = "battleScene", - values = { true, false }, - display = { [true] = "ON ", [false] = "OFF" }, - }, - { - label = "BATTLE STYLE", key = "battleStyle", - values = { "SHIFT", "SET" }, - display = { SHIFT = "SHIFT", SET = "SET " }, - }, - { - label = "SOUND", key = "sound", - values = { "MONO", "STEREO" }, - display = { MONO = "MONO ", STEREO = "STEREO" }, - }, - { - label = "PRINT", key = "print", - values = { "LIGHTEST", "LIGHTER", "NORMAL", "DARKER", "DARKEST" }, display = { - LIGHTEST = "LIGHTEST", LIGHTER = "LIGHTER ", NORMAL = "NORMAL ", - DARKER = "DARKER ", DARKEST = "DARKEST ", + FAST = Strings.source("FAST"), MID = Strings.source("MID "), + SLOW = Strings.source("SLOW"), }, }, { - label = "MENU ACCOUNT", key = "menuAccount", + label = Strings.source("BATTLE SCENE"), key = "battleScene", + values = { true, false }, + display = { + [true] = Strings.source("ON "), [false] = Strings.source("OFF"), + }, + }, + { + label = Strings.source("BATTLE STYLE"), key = "battleStyle", + values = { "SHIFT", "SET" }, + display = { + SHIFT = Strings.source("SHIFT"), SET = Strings.source("SET "), + }, + }, + { + label = Strings.source("SOUND"), key = "sound", + values = { "MONO", "STEREO" }, + display = { + MONO = Strings.source("MONO "), STEREO = Strings.source("STEREO"), + }, + }, + { + label = Strings.source("PRINT"), key = "print", + values = { "LIGHTEST", "LIGHTER", "NORMAL", "DARKER", "DARKEST" }, + display = { + LIGHTEST = Strings.source("LIGHTEST"), LIGHTER = Strings.source("LIGHTER "), + NORMAL = Strings.source("NORMAL "), DARKER = Strings.source("DARKER "), + DARKEST = Strings.source("DARKEST "), + }, + }, + { + label = Strings.source("MENU ACCOUNT"), key = "menuAccount", values = { false, true }, - display = { [false] = "OFF", [true] = "ON " }, + display = { + [false] = Strings.source("OFF"), [true] = Strings.source("ON "), + }, }, -- FRAME is the textbox border style, 1-8, and prints its number after the -- word TYPE rather than in the shared value column. - { label = "FRAME", key = "frame", frame = true }, + { label = Strings.source("FRAME"), key = "frame", frame = true }, -- Everything from here down is the port's, not the cart's. They are the -- same settings the Gen 1 OPTION screen carries and they drive the same -- shared modules, so a player who learns them in Red knows them here. The @@ -87,17 +105,17 @@ local ROWS = { -- -- The two volume rows clamp at the ends rather than wrapping, the way -- pokered's text-speed cursor does, so holding left reaches OFF and stays. - { id = "controls", label = "CONTROLS", port = true, + { id = "controls", label = Strings.source("CONTROLS"), port = true, activate = function(game) require("src.ui.Screens").push(game, "BindingsMenu") end }, - { label = "MUSIC VOL", key = "musicVol", port = true, + { label = Strings.source("MUSIC VOL"), key = "musicVol", port = true, cycle = function(options, delta) options.musicVol = stepVolume(options.musicVol, delta) require("src.core.Music").setVolumeLevel(options.musicVol) end, text = function(options) return volLabel(options.musicVol) end }, - { label = "SFX VOL", key = "sfxVol", port = true, + { label = Strings.source("SFX VOL"), key = "sfxVol", port = true, cycle = function(options, delta) options.sfxVol = stepVolume(options.sfxVol, delta) require("src.core.Sound").setVolumeLevel(options.sfxVol) @@ -105,7 +123,7 @@ local ROWS = { text = function(options) return volLabel(options.sfxVol) end }, -- Each filter step keeps 40% of the previous step's treble, so 2X and 3X -- are the 1X low-pass applied twice and three times over. - { label = "MUSIC FILTER", key = "musicFilter", port = true, + { label = Strings.source("MUSIC FILTER"), key = "musicFilter", port = true, cycle = function(options, delta) options.musicFilter = ((options.musicFilter or 0) + delta) % #FILTERS require("src.core.Music").setFilterLevel(options.musicFilter) @@ -119,7 +137,7 @@ local ROWS = { -- file's own `text`/`cycle`) works here unmodified: OptionsMenu:cycle -- answers `row.step` first, and drawPanel already reads a function -- `row.value` -- both written for exactly this kind of shared mod row. - { id = "performance", label = "PERFORMANCE", port = true, + { id = "performance", label = Strings.source("PERFORMANCE"), port = true, value = function(g) return Performance.label(g.options and g.options.performance) end, @@ -129,7 +147,7 @@ local ROWS = { g:applyOptions() return true end }, - { label = "GAME SPEED", key = "speed", port = true, + { label = Strings.source("GAME SPEED"), key = "speed", port = true, cycle = function(options, delta) local GameSpeed = require("src.core.GameSpeed") options.speed = GameSpeed.cycle(options.speed, delta) @@ -137,7 +155,7 @@ local ROWS = { text = function(options) return require("src.core.GameSpeed").levelLabel(options.speed) end }, - { label = "ZOOM", key = "zoom", port = true, + { label = Strings.source("ZOOM"), key = "zoom", port = true, cycle = function(options, delta, game) local Zoom = require("src.render.Zoom") local scale = Zoom.windowFitScale() @@ -153,7 +171,7 @@ local ROWS = { -- a boundary; WATER / TREES force one outdoor block; BLACK is a flat void. -- #1418. Same key the Gen 1 OPTION screen uses, different ladder (FADE -- is Gold's default because that is already what the maps call for). - { label = "VOID FILL", key = "voidFill", port = true, + { label = Strings.source("VOID FILL"), key = "voidFill", port = true, cycle = function(options, delta) local BorderFill = require("src.world.gen2.BorderFill") BorderFill.setVoidFill(options.voidFill or "fade") @@ -162,7 +180,7 @@ local ROWS = { text = function(options) return require("src.world.gen2.BorderFill").voidFillLabel(options.voidFill) end }, - { label = "TILT", key = "tilt", port = true, + { label = Strings.source("TILT"), key = "tilt", port = true, cycle = function(options, delta) local Tilt = require("src.render.Tilt") -- Four levels (OFF, 15, 35, 50); left steps back through them. @@ -177,7 +195,7 @@ local ROWS = { -- CGB game whose colour comes from its own palettes, so there are no packs -- to swap -- what there is instead is the choice to turn that colour OFF, -- down to the grey Game Boy or the green one. GBC is the default. - { label = "COLOR", key = "color", port = true, + { label = Strings.source("COLOR"), key = "color", port = true, cycle = function(options, delta) local GbcPalette = require("src.render.GbcPalette") GbcPalette.setMode(options.color or "gbc") @@ -186,12 +204,26 @@ local ROWS = { text = function(options) return require("src.render.GbcPalette").modeLabel(options.color or "gbc") end }, + { label = Strings.source("GBC FX"), key = "gbcfx", port = true, + cycle = function(options, delta) + local GBCFX = require("src.render.GBCFX") + if not GBCFX.isSupported() then + options.gbcfx = 0 + return + end + local level = ((options.gbcfx or 0) + delta) % 5 + options.gbcfx = level + GBCFX.setLevel(level) + end, + text = function(options) + return require("src.render.GBCFX").levelLabel(options.gbcfx or 0) + end }, -- SHADER FX reaches Gen 2 too, not just Gen 1. Same "activate" shape as -- CONTROLS/TOUCH LAYOUT below (a pushed screen, not a `cycle` ladder) -- -- ShaderFXScreen is the shared list screen both generations push, `id` -- matching the Gen 1 row's so a mod filtering "shaderfx" on Red also -- reaches Gold. - { id = "shaderfx", label = "SHADER FX", port = true, + { id = "shaderfx", label = Strings.source("SHADER FX"), port = true, text = function(options) local ShaderFX = require("src.render.ShaderFX") local entry = ShaderFX.activeEntry("main") @@ -204,7 +236,7 @@ local ROWS = { -- Dual-shader secondary slot, same shared ShaderFXScreen as the row -- above, opened on "secondary" instead -- see src/ui/OptionsMenu.lua's -- mirror of this row for the full rationale. - { id = "shaderfx2", label = "SHADER FX 2", port = true, + { id = "shaderfx2", label = Strings.source("SHADER FX 2"), port = true, text = function(options) local ShaderFX = require("src.render.ShaderFX") local entry = ShaderFX.activeEntry("secondary") @@ -214,7 +246,7 @@ local ROWS = { activate = function(game) require("src.ui.Screens").push(game, "ShaderFXScreen", "secondary") end }, - { label = "VIDEO MODE", key = "videoMode", port = true, + { label = Strings.source("VIDEO MODE"), key = "videoMode", port = true, cycle = function(options, delta) local VideoMode = require("src.core.VideoMode") options.videoMode = VideoMode.cycle(options.videoMode, delta) @@ -225,20 +257,20 @@ local ROWS = { return VideoMode.normalize(options.videoMode) == "borderless" and "FULL" or "WINDOWED" end }, - { label = "SCREEN POS", key = "screenPos", port = true, + { label = Strings.source("SCREEN POS"), key = "screenPos", port = true, cycle = function(options, delta) local ScreenPosition = require("src.core.ScreenPosition") options.screenPos = ScreenPosition.cycle(options.screenPos, delta) ScreenPosition.setMode(options.screenPos) end, text = function(options) - return require("src.core.ScreenPosition").label(options.screenPos) + return Strings(require("src.core.ScreenPosition").label(options.screenPos)) end }, - { id = "touchControls", label = "TOUCH PAD", port = true, + { id = "touchControls", label = Strings.source("TOUCH PAD"), port = true, text = function(options) local tc = options.touchControls local on = not (type(tc) == "table" and tc.enabled == false) - return on and "ON" or "OFF" + return on and Strings("ON") or Strings("OFF") end, cycle = function(options, _delta, game) local tc = type(options.touchControls) == "table" and options.touchControls or {} @@ -247,13 +279,13 @@ local ROWS = { require("src.core.TouchControls"):applyOptions(options) if game and game.persistOptions then game:persistOptions() end end }, - { id = "touchLayout", label = "TOUCH LAYOUT", port = true, + { id = "touchLayout", label = Strings.source("TOUCH LAYOUT"), port = true, activate = function(game) game.stack:push(require("src.ui.TouchControlsEditor").new(game)) end }, - { id = "haptics", label = "VIBRATION", port = true, + { id = "haptics", label = Strings.source("VIBRATION"), port = true, text = function(options) - return require("src.core.TouchControls").hapticLabel(options.haptics) + return Strings(require("src.core.TouchControls").hapticLabel(options.haptics)) end, cycle = function(options, delta, game) local TC = require("src.core.TouchControls") @@ -262,7 +294,7 @@ local ROWS = { TC.buzz(options.haptics) if game and game.persistOptions then game:persistOptions() end end }, - { label = "MAX FPS", key = "fpsCap", port = true, + { label = Strings.source("MAX FPS"), key = "fpsCap", port = true, cycle = function(options, delta) local FrameCap = require("src.core.FrameCap") options.fpsCap = FrameCap.cycle(options.fpsCap, delta) @@ -273,10 +305,10 @@ local ROWS = { end }, -- BATTLE BG (#1709): the void around the battle screen. Gold has no WIDE -- layout and no WORLD backdrop, so the ladder is the WHITE/BLACK pair only. - { label = "BATTLE BG", key = "battleBg", port = true, + { label = Strings.source("BATTLE BG"), key = "battleBg", port = true, values = { "white", "black" }, display = { white = "WHITE", black = "BLACK" } }, - { label = "CANCEL", cancel = true }, + { label = Strings.source("CANCEL"), cancel = true }, } -- The cart's screen is one full-height textbox with every row on it. This one @@ -460,9 +492,9 @@ function OptionsMenu:drawPanel() local row = self.rows[i] if row then local labelY = 2 + (slot - 1) * 2 - Chrome.print(row.label, 2, labelY) + Chrome.print(Strings(row.label), 2, labelY) if row.frame then - Chrome.print(":TYPE", 10, labelY + 1) + Chrome.print(Strings(":TYPE"), 10, labelY + 1) Chrome.print(tostring(self.options.frame or 1), 16, labelY + 1) elseif row.text then Chrome.print(":", 10, labelY + 1) @@ -470,7 +502,7 @@ function OptionsMenu:drawPanel() elseif row.values then Chrome.print(":", 10, labelY + 1) local value = self.options[row.key] - local text = row.display and row.display[value] or tostring(value) + local text = row.display and Strings(row.display[value]) or tostring(value) Chrome.print(text, 11, labelY + 1) elseif type(row.value) == "function" then -- the Gen 1 row's value reader (src/ui/OptionRows.lua:4), so a mod row diff --git a/src/ui/gen2/Pokegear.lua b/src/ui/gen2/Pokegear.lua index bbb0e5e8..242a3515 100644 --- a/src/ui/gen2/Pokegear.lua +++ b/src/ui/gen2/Pokegear.lua @@ -28,6 +28,7 @@ local Font = require("src.render.Font") local Palettes = require("src.world.gen2.Palettes") local Phone = require("src.core.gen2.Phone") local SpriteRenderer = require("src.render.SpriteRenderer") +local Strings = require("src.core.Strings") local TileSheet = require("src.ui.gen2.TileSheet") local Pokegear = {} @@ -1959,7 +1960,7 @@ function Pokegear:drawClock() self:text(Chrome.number(display, 2), 6, 8) self:text(":", 8, 8) self:text(Chrome.number(minute, 2, true), 9, 8) - self:text(hour < 12 and "AM" or "PM", 12, 8) + self:text(Strings(hour < 12 and "AM" or "PM"), 12, 8) -- The bottom Textbox is part of the card (lb bc, 4, 18 at (0,12)), and -- PokegearClock_Init prints PokegearPressButtonText straight into it @@ -2373,7 +2374,7 @@ function Pokegear:drawPlain() if display == 0 then display = 12 end Chrome.print(("%s:%s %s"):format( Chrome.number(display, 2), Chrome.number(minute, 2, true), - hour < 12 and "AM" or "PM"), 5, 9) + Strings(hour < 12 and "AM" or "PM")), 5, 9) Chrome.print(Clock.daytimeLabel(hour), 5, 11) elseif id == "radio" then -- Without the gear sheet there is no dial art, so the frequencies go down diff --git a/src/ui/gen2/SaveMenu.lua b/src/ui/gen2/SaveMenu.lua index c0eeb47b..d3e0eacc 100644 --- a/src/ui/gen2/SaveMenu.lua +++ b/src/ui/gen2/SaveMenu.lua @@ -24,8 +24,10 @@ -- one-line call. local Chrome = require("src.ui.gen2.Chrome") +local Logger = require("src.core.Logger") local Save = require("src.core.gen2.Save") local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") local SaveMenu = {} SaveMenu.__index = SaveMenu @@ -55,10 +57,50 @@ local TIME_X, TIME_Y = 13, 8 local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 0, 7, 6, 5 -- AlreadyASaveFileText (AskOverwriteSaveFile, engine/menus/save.asm:47) and --- SavingDontTurnOffThePower's own line, shared with the PC's CHANGE BOX save. +-- SavingDontTurnOffThePower's own line, shared with the PC's CHANGE BOX save +-- (src/ui/gen2/PcMenu.lua:savePrompt() reads these two tables' lines[1]/ +-- lines[2] directly, so their shape is a cross-file contract: keep them +-- plain, untranslated tables). local OVERWRITE_PROMPT = { "There is already a", "save file. Is it" } local SAVING_PROMPT = { "SAVING… DON'T TURN", "OFF THE POWER." } +-- Translatable copies of the two prompts above, one \n-joined key each, used +-- only by this screen's own prompt() below. One key per prompt lets a +-- translation write one whole, freely reordered sentence instead of two +-- fragments translated in isolation, and lets a cart whose own text is a +-- single line (German's SAVING prompt) say so directly by simply omitting +-- the "\n" -- the per-line override style used elsewhere requires a +-- non-empty value for every line, so it can't express "this line is blank". +-- +-- Written as literals, not `table.concat(OVERWRITE_PROMPT, "\n")`: the +-- translation tooling's string harvester only recognizes a literal inside +-- Strings.source(...), not a computed expression, so a concat call here +-- would quietly never reach a translator. Keep byte-for-byte in sync with +-- OVERWRITE_PROMPT/SAVING_PROMPT above (checked by +-- tests/engine/gen2_save_menu_translation_test.lua). +local OVERWRITE_PROMPT_SOURCE = Strings.source("There is already a\nsave file. Is it") +local SAVING_PROMPT_SOURCE = Strings.source("SAVING… DON'T TURN\nOFF THE POWER.") + +-- Splits a translated "line one\nline two" string back into the two-slot +-- table drawPanel's fixed Chrome.print calls expect. No "\n" at all (a +-- single-line message, or German's one-line SAVING prompt) lands whole on +-- the first slot, matching the untranslated code's own { text, "" } shape. +-- +-- Only the first "\n" splits, since this box has room for exactly two +-- lines. A third line would otherwise draw as a raw newline byte -- garbage +-- glyph data -- with no other sign anything went wrong, so this warns once +-- per string instead. +local warnedTooManyLines = {} +local function twoLines(text) + local first, second = text:match("^(.-)\n(.*)$") + if second and second:find("\n", 1, true) and not warnedTooManyLines[text] then + warnedTooManyLines[text] = true + Logger.warn("SaveMenu: translation of %q has more than two lines; " .. + "only the first two fit this box", text) + end + return { first or text, second or "" } +end + function SaveMenu:wantsFillScale() return true end function SaveMenu:drawsWidescreen() return true end @@ -171,18 +213,18 @@ function SaveMenu:prompt() if self.phase == "overwrite" then -- AlreadyASaveFileText when the file is this player's; AnotherSaveFileText -- when the ID differs. Only the first can happen here. - return OVERWRITE_PROMPT + return twoLines(Strings(OVERWRITE_PROMPT_SOURCE)) end if self.phase == "saving" then - return SAVING_PROMPT + return twoLines(Strings(SAVING_PROMPT_SOURCE)) end if self.phase == "done" then if self.saved then - return { self:playerName() .. " saved", "the game." } + return twoLines(Strings("%s saved\nthe game.", self:playerName())) end - return { "Could not save.", "" } + return twoLines(Strings("Could not save.")) end - return { "Would you like to", "save the game?" } + return twoLines(Strings("Would you like to\nsave the game?")) end function SaveMenu:drawPanel() @@ -190,10 +232,10 @@ function SaveMenu:drawPanel() local summary = Save.summary(self.save) Chrome.box(PANEL_X, PANEL_Y, PANEL_W, PANEL_H) if summary then - Chrome.print("PLAYER " .. summary.name, LABEL_X, LABEL_Y) - Chrome.print("BADGES", LABEL_X, LABEL_Y + 2) - Chrome.print("POKéDEX", LABEL_X, LABEL_Y + 4) - Chrome.print("TIME", LABEL_X, LABEL_Y + 6) + Chrome.print(Strings("PLAYER %s", summary.name), LABEL_X, LABEL_Y) + Chrome.print(Strings("BADGES"), LABEL_X, LABEL_Y + 2) + Chrome.print(Strings("POKéDEX"), LABEL_X, LABEL_Y + 4) + Chrome.print(Strings("TIME"), LABEL_X, LABEL_Y + 6) -- PrintNum fills its field from the left, space padded. Chrome.print(Chrome.number(summary.badges, 2), BADGES_X, BADGES_Y) Chrome.print(Chrome.number(summary.caught, 3), DEX_X, DEX_Y) @@ -211,8 +253,8 @@ function SaveMenu:drawPanel() if self.phase == "confirm" or self.phase == "overwrite" then Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H) - Chrome.print("YES", YESNO_X + 2, YESNO_Y + 1) - Chrome.print("NO", YESNO_X + 2, YESNO_Y + 3) + Chrome.print(Strings("YES"), YESNO_X + 2, YESNO_Y + 1) + Chrome.print(Strings("NO"), YESNO_X + 2, YESNO_Y + 3) Chrome.cursor(YESNO_X + 1, YESNO_Y + (self.choice == 1 and 1 or 3)) end love.graphics.setColor(1, 1, 1, 1) diff --git a/src/update/Check.lua b/src/update/Check.lua index b918f2f3..938889e0 100644 --- a/src/update/Check.lua +++ b/src/update/Check.lua @@ -343,7 +343,9 @@ function Check.shutdown() if cmdCh then cmdCh:push({ cmd = "quit" }) end if worker then pcall(function() worker:wait() end) end worker, cmdCh, stateCh = nil, nil, nil - workerReady = false + workerReady = nil end +require("src.core.SessionLifecycle").registerProcessShutdown(Check.shutdown) + return Check diff --git a/src/world/MapLoader.lua b/src/world/MapLoader.lua index 0452fec0..c52e3ed4 100644 --- a/src/world/MapLoader.lua +++ b/src/world/MapLoader.lua @@ -114,12 +114,25 @@ function MapLoader.invalidateAll() lru = {} end +-- Eagerly release every resident map's TileRenderer GPU objects. Only safe +-- when nothing live holds map instances (session end after game:reset). +function MapLoader.releaseAll() + local ids = {} + for id in pairs(cache) do ids[#ids + 1] = id end + for _, id in ipairs(ids) do MapLoader.evict(id) end +end + -- kept as the pre-v2 name MapLoader.clearCache = MapLoader.invalidateAll -- the cached Map objects own the per-map TileRenderer instances, so a flush -- that skipped this one would leave live SpriteBatches built from the old --- search path (14 cache-invalidation contract, rows 1 and 3) -Assets.register(MapLoader.invalidateAll) +-- search path (14 cache-invalidation contract, rows 1 and 3). invalidateAll +-- deliberately does NOT release GPU (hot reload / live overworld); releaseAll +-- is the session-end path only. +Assets.register({ + invalidate = MapLoader.invalidateAll, + release = MapLoader.releaseAll, +}) return MapLoader diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 3ab5d66a..3c503fc9 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -246,6 +246,11 @@ function OverworldState.computeNeighbors(maps, rootId, hops, reachW, reachH) return out end +function OverworldState:exit() + self.map = nil + self.neighbors = nil +end + function OverworldState:enter(mapId, x, y, facing, opts) Game = require("src.core.Game") Game.overworld = self @@ -2125,6 +2130,10 @@ function OverworldState:pushableAtCell(cx, cy) return nil end +-- world.talk's fallthrough, hoisted so the A press does not build a closure +-- on every press just to have one to hand a hook nobody may have wrapped +local function vanillaTalk(ow, target) ow:talkTo(target) end + -- what the A press resolved to, for world.interacted's listeners local function interacted(self, fx, fy, kind, target) Runtime.emit("world.interacted", { mapId = self.map.id, x = fx, y = fy, @@ -2154,7 +2163,12 @@ function OverworldState:interact() -- talk() lands the follower on its cell first. require("src.world.PikachuFollower").talk(Game, self, npc) elseif not npc.moving then - self:talkTo(npc) + -- world.talk: the A press on an object, before the map's text tables + -- get it. A runtime object a mod spawned (WorldAPI:spawnNpc) carries + -- no TEXT_* id, so the vanilla path has nothing to say for it; a mod + -- that owns the object wraps this and simply does not call next(). + -- Everything else falls straight through to talkTo as before. + Runtime.call("world.talk", vanillaTalk, self, npc) end interacted(self, fx, fy, "npc", npc) return diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index 69b9eea2..7964dcc7 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -5,6 +5,7 @@ -- quiet no-op, never a crash. Reaching into OverworldState internals -- stays unsupported; anything a mod legitimately needs belongs here. +local Collision = require("src.world.Collision") local Logger = require("src.core.Logger") local FieldDefaults = require("src.world.FieldDefaults") local Map = require("src.world.Map") @@ -117,6 +118,47 @@ function WorldAPI:current() facing = p and p.facing } end +local function validBlockCoordinate(value) + return type(value) == "number" and value == value + and value ~= math.huge and value ~= -math.huge + and value == math.floor(value) +end + +-- Read one block from the active Gen 1 map without exposing the mutable block +-- array. Requiring the expected map id makes a stale signature fail closed +-- if a warp or reload moved the player before the caller completed its check. +-- Block coordinates are zero-based, matching replaceBlock. +function WorldAPI:activeBlockAt(mapId, bx, by) + local ow = self:overworld() + if not ow or not ow.map then return nil, NO_OVERWORLD end + local map = ow.map + if map.id ~= mapId then return nil, "map is not active" end + if not validBlockCoordinate(bx) or not validBlockCoordinate(by) then + return nil, "invalid block coordinates" + end + local def = map.def + if not def or not validBlockCoordinate(def.width) or def.width <= 0 + or not validBlockCoordinate(def.height) or def.height <= 0 then + return nil, "block unavailable" + end + if bx < 0 or by < 0 or bx >= def.width or by >= def.height then + return nil, "block coordinates out of bounds" + end + if type(def.blocks) ~= "table" or type(map.blockAt) ~= "function" then + return nil, "block unavailable" + end + local stored = def.blocks[by * def.width + bx + 1] + if not validBlockCoordinate(stored) or stored < 0 then + return nil, "block unavailable" + end + local ok, blockId = pcall(map.blockAt, map, bx, by) + if not ok or not validBlockCoordinate(blockId) or blockId < 0 + or blockId ~= stored then + return nil, "block unavailable" + end + return blockId +end + -- Companion UIs may offer party ordering while the player is in free roam. -- The same guard that makes opening a menu safe keeps scripts, transitions, -- movement and screens above the overworld from observing a mid-action swap. @@ -407,6 +449,72 @@ function Handle:position() return self.npc.cellX, self.npc.cellY end +-- Walk one tile starting NOW, outside the scripted-movement queue. +-- +-- scriptMove queues onto OverworldState.scriptMoves, and a non-empty +-- scriptMoves is how the overworld knows a cutscene is running -- it gates +-- handleInput (OverworldController "local scripted = ... #self.scriptMoves > +-- 0"), so an actor animated that way freezes the player's controls for as +-- long as it walks. That is right for Oak marching to his lab and wrong for +-- an actor that moves on its own schedule: a networked player's ghost, an +-- ambient walker. This is the same per-tile state scriptMove sets, minus +-- the queue and therefore minus the lockout. +-- +-- Collision is deliberately not checked. The caller is replaying a move +-- that was already decided somewhere else (validated on the peer's machine, +-- or authored), and re-judging it here would let the two copies disagree +-- about where the actor is. Use canStep first if you want the check. +function Handle:stepNow(dir) + local npc = self.npc + if not Collision.DELTA[dir] then return nil, "bad direction: " .. tostring(dir) end + if npc.moving then return nil, "already moving" end + npc.facing = dir + npc.targetX, npc.targetY = Collision.target(npc.cellX, npc.cellY, dir) + npc.moving = true + npc.progress = 0 + return true +end + +-- Would stepNow land somewhere legal? Exposed separately so a caller that +-- does want the map's opinion can ask for it without giving up the "replay +-- verbatim" default above. +function Handle:canStep(dir) + local ow = self.ow + if not (ow and ow.map) then return false end + return Collision.canMove(ow.map, ow.entities, self.npc, dir) and true or false +end + +-- Snap to a cell with no animation: a warp arrival, or a resync that has +-- drifted too far to walk off. Clears any step in flight so the entity +-- cannot land on its old target a frame later. +function Handle:placeAt(x, y, facing) + local npc = self.npc + npc.moving = false + npc.marching = false + npc.targetX, npc.targetY = nil, nil + npc.progress = 0 + npc.cellX, npc.cellY = x, y + npc.px, npc.py = x * 16, y * 16 + if facing then npc.facing = facing end + return true +end + +-- True while a step is still animating, so a driver can pace itself rather +-- than stomping a move in flight. +function Handle:isMoving() + return self.npc.moving and true or false +end + +-- Whether the player may walk through this object (Collision.occupied skips +-- passable entities -- Yellow's companion Pikachu is the engine's own user +-- of the flag). It still draws and can still be talked to; it just stops +-- being an obstacle, which is what a dynamic actor wants when standing in a +-- doorway would otherwise wall someone in. +function Handle:setPassable(passable) + self.npc.passable = passable and true or false + return true +end + function WorldAPI:npc(mapId, indexOrName) local ow = self:overworld() if not ow then return nil, NO_OVERWORLD end diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index 13a3f101..c6c9a58f 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -5470,6 +5470,37 @@ function World:dropMapImages(mapId) if self.connectionMaps then self.connectionMaps[mapId] = nil end end +local function safeRelease(obj) + if obj and obj ~= false and obj.release then pcall(obj.release, obj) end +end + +-- Eagerly free session-owned GPU caches. Assets.image-backed atlases are +-- nilled without release; unique bakes, strips, and roof composites are released. +function World:release() + if self.mapImages then + for _, img in pairs(self.mapImages) do safeRelease(img) end + self.mapImages = {} + end + if self.scrollStrips then + for _, strip in pairs(self.scrollStrips) do safeRelease(strip) end + self.scrollStrips = {} + end + safeRelease(self.tiltCanvas) + self.tiltCanvas = nil + if self.grassAtlases then + for _, atlas in pairs(self.grassAtlases) do safeRelease(atlas) end + self.grassAtlases = {} + end + if self.atlasCache then + for key, atlas in pairs(self.atlasCache) do + if key:find("|", 1, true) then safeRelease(atlas) end + end + self.atlasCache = {} + end + self.animQuads = nil + self.connectionMaps = nil +end + -- LoadMapAttributes' refill, for every map the session has edited. Neighbour -- strips share the same buffer on the cart, so a connection crossing reloads -- them too: this runs on any setMap, seamless or not. diff --git a/tests/engine/android_host_extension_test.lua b/tests/engine/android_host_extension_test.lua index 4033cbed..efe7a839 100644 --- a/tests/engine/android_host_extension_test.lua +++ b/tests/engine/android_host_extension_test.lua @@ -88,8 +88,13 @@ check(destroy < destroyTeardown and destroyTeardown < destroySuper, local mainFile = assert(io.open("main.lua", "rb")) local main = mainFile:read("*a") mainFile:close() -check(main:find('require("src.render.SecondScreen").setEnabled(false)', 1, true), - "returning from a game disables mod-owned secondary output") +check(main:find("SessionLifecycle.endGameSession", 1, true), + "returning from a game goes through SessionLifecycle.endGameSession") +local lifecycleFile = assert(io.open("src/core/SessionLifecycle.lua", "rb")) +local lifecycle = lifecycleFile:read("*a") +lifecycleFile:close() +check(lifecycle:find('require("src.render.SecondScreen").setEnabled(false)', 1, true), + "endGameSession disables mod-owned secondary output") check(not source:lower():find("openxr", 1, true), "generic Android activity must not require OpenXR") diff --git a/tests/engine/battle_field_residual_validation.lua b/tests/engine/battle_field_residual_validation.lua new file mode 100644 index 00000000..a61c589b --- /dev/null +++ b/tests/engine/battle_field_residual_validation.lua @@ -0,0 +1,336 @@ +-- Validation and fail-closed behavior for battle.field_residual descriptors. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.modkit") +local BattleState = require("src.battle.BattleState") +local Checkpoint = require("src.core.Checkpoint") +local Events = require("src.mods.Events") +local GameMethods = require("src.core.Game") +local Hooks = require("src.mods.Hooks") +local Pokemon = require("src.pokemon.Pokemon") +local Runtime = require("src.mods.Runtime") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") +local TypeChart = require("src.battle.TypeChart") + +local data = T.fixtures.fresh() +TypeChart.load(data) + +local function newBattle() + local save = SaveData.newGame() + save.party = { Pokemon.new(data, "FIXMON_A", 30) } + local game = { + data = data, + save = save, + stack = { top = function() return nil end, push = function() end }, + } + local battle = BattleState.newWild(game, "FIXMON_C", 30) + battle.phase, battle.queue = "menu", {} + return battle +end + +local savedEvents, savedHooks, savedErrors = Runtime.events, Runtime.hooks, + Runtime.errors +local hooks = Hooks.new() +Runtime.install(savedEvents, hooks, {}) + +local battle = newBattle() +local playerHp, enemyHp = battle.player.mon.hp, battle.enemy.mon.hp +local playerType = battle.player.curTypes[1] +local cyclic = { label = "cycle-data" } +cyclic.self = cyclic +local metatableData = setmetatable({ label = "plain-data" }, { + __index = { hidden = "metatable-data" }, +}) +battle.field.weather = { + id = "probe", turns = 3, + callback = function() end, + handle = io.stdout, + worker = coroutine.create(function() end), + cyclic = cyclic, + metatableData = metatableData, +} +battle.field.tokens[1] = { id = "nested", turns = 2, + state = { intensity = 4 } } +battle:enter() +hooks:wrap("battle.field_residual", function(next, context) + local vanilla = next(context) + T.same(vanilla, {}, "the vanilla contribution is an empty descriptor list") + context.battlers.player.hp = 0 + context.battlers.player.types[1] = "MUTATED" + T.eq(context.field.sides, nil, + "the field view has the checkpoint shape and no live side graph") + T.eq(context.field.weather.callback, nil, + "the field view omits executable values") + T.eq(context.field.weather.handle, nil, + "the field view omits userdata") + T.eq(context.field.weather.worker, nil, + "the field view omits threads") + T.eq(context.field.weather.cyclic.label, "cycle-data", + "the field view retains scalar data around a cycle") + T.eq(context.field.weather.cyclic.self, nil, + "the field view omits cyclic edges") + T.eq(getmetatable(context.field.weather.metatableData), nil, + "the field view carries no metatable") + T.eq(context.field.weather.metatableData.label, "plain-data", + "the field view retains raw data from a metatable-bearing table") + T.eq(context.field.weather.metatableData.hidden, nil, + "the field view does not expose metatable-provided values") + context.field.weather.turns = 0 + context.field.tokens[1].state.intensity = 99 + return { + false, + { side = "unknown", amount = 20, message = "invalid side" }, + { side = "player", amount = "3", message = "numeric string" }, + { side = "player", amount = 0, message = "zero" }, + { side = "player", amount = -2, message = "negative" }, + { side = "player", amount = 1.5, message = "fractional" }, + { side = "player", amount = "not a number", message = "bad amount" }, + { side = "player", amount = 0 / 0, message = "not finite" }, + { side = "player", amount = math.huge, message = "non-finite" }, + { side = "player", amount = 2, message = function() end }, + { side = "enemy", amount = 3 }, + } +end, 0, "validation_probe") + +battle:applyFieldResiduals() +T.eq(battle.player.mon.hp, playerHp, + "a descriptor with a non-string message fails closed") +T.eq(battle.enemy.mon.hp, enemyHp - 3, + "a valid descriptor may omit its message") +T.eq(battle.player.curTypes[1], playerType, + "mutating the detached type view cannot mutate the live battler") +T.check(battle.player.mon.hp ~= 0, + "mutating detached HP cannot replace engine damage authority") +T.eq(battle.field.weather.turns, 3, + "mutating the detached weather view cannot mutate live field state") +T.eq(battle.field.tokens[1].state.intensity, 4, + "mutating nested detached token state cannot mutate live field state") + +local guarded = newBattle() +local fieldReads, runtimeCalls = 0, 0 +guarded.field = setmetatable({}, { __index = function() + fieldReads = fieldReads + 1 + return nil +end }) +local realRuntimeCall = Runtime.call +Runtime.call = function(...) + runtimeCalls = runtimeCalls + 1 + return realRuntimeCall(...) +end +Runtime.install(savedEvents, Hooks.new(), {}) +guarded:applyFieldResiduals() +Runtime.call = realRuntimeCall +T.eq(runtimeCalls, 0, + "a disabled field hook never enters Runtime.call") +T.eq(fieldReads, 0, + "a disabled field hook does not construct its field context") + +local nilBattle = newBattle() +local nilHp = nilBattle.player.mon.hp +local nilHooks = Hooks.new() +Runtime.install(savedEvents, nilHooks, {}) +nilHooks:wrap("battle.field_residual", function() return nil end, + 0, "nil_probe") +nilBattle:applyFieldResiduals() +T.eq(nilBattle.player.mon.hp, nilHp, + "a non-table hook result fails closed") + +local settled = newBattle() +local settledCalls = 0 +local settledHooks = Hooks.new() +Runtime.install(savedEvents, settledHooks, {}) +settledHooks:wrap("battle.field_residual", function(next, context) + settledCalls = settledCalls + 1 + return next(context) +end, 0, "settled_probe") +settled.result = "win" +settled:endOfTurn() +T.eq(settledCalls, 0, + "a settled battle never invokes field residual policy") + +local function drainQueue(battle) + local rows, guard = {}, 0 + while battle.queue[1] and guard < 1000 do + guard = guard + 1 + local row = table.remove(battle.queue, 1) + rows[#rows + 1] = row + if row.fn then + battle.nextInsert = 0 + row.fn() + end + end + T.check(guard < 1000, "the simultaneous-faint queue completes") + return rows +end + +local function simultaneousTerminal(order) + local save = SaveData.newGame() + save.party = { Pokemon.new(data, "FIXMON_A", 30) } + local game = { + data = data, + save = save, + stack = { top = function() return nil end, push = function() end }, + } + local double = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) + double.phase, double.queue = "menu", {} + local originalEnemy, originalEnemyIndex = double.enemy, double.enemyIndex + local startingExp = double.player.mon.exp + local events, doubleHooks = Events.new(), Hooks.new() + local awardCalls, expEvents, switches = 0, 0, 0 + events:on("battle.exp_gained", function() expEvents = expEvents + 1 end, + 0, "double_probe") + events:on("battle.battler_switched", function() switches = switches + 1 end, + 0, "double_probe") + Runtime.install(events, doubleHooks, {}) + doubleHooks:wrap("battle.field_residual", function(next, context) + local rows = next(context) + for _, side in ipairs(order) do + rows[#rows + 1] = { + side = side, + amount = context.battlers[side].hp, + } + end + return rows + end, 0, "double_probe") + doubleHooks:wrap("battle.exp_award", function(next, context) + awardCalls = awardCalls + 1 + return next(context) + end, 0, "double_probe") + double:endOfTurn() + local queued = drainQueue(double) + T.eq(double.player.mon.hp, 0, + "simultaneous residuals settle the player side") + T.eq(double.enemy.mon.hp, 0, + "simultaneous residuals settle the enemy side") + T.eq(double.result, "lose", + "a simultaneous terminal residual resolves as player blackout") + T.eq(double.afterQueue, "finish", + "the completed simultaneous-faint queue closes the battle") + T.eq(double.player.faintQueued, true, + "the terminal hook batch queues player faint authority") + T.eq(double.enemy.faintQueued, nil, + "the terminal hook batch suppresses only its enemy faint authority") + T.eq(double.player.mon.exp, startingExp, + "a blackout does not award contradictory enemy-faint EXP") + T.eq(awardCalls, 0, + "a blackout never enters the enemy EXP-award policy") + T.eq(expEvents, 0, + "a blackout emits no contradictory EXP event") + T.eq(switches, 0, + "a blackout does not send the trainer's reserve into battle") + T.eq(double.enemyIndex, originalEnemyIndex, + "a blackout leaves the enemy roster position unchanged") + T.check(double.enemy == originalEnemy, + "a blackout queues no contradictory enemy replacement") + for _, row in ipairs(queued) do + T.eq(row.ui, nil, + "a simultaneous terminal residual queues no replacement UI") + end +end + +simultaneousTerminal({ "player", "enemy" }) +simultaneousTerminal({ "enemy", "player" }) + +local timing = newBattle() +local timingOrder = {} +timing.ruleset = require("src.battle.rulesets.modern_clean") +timing.player.mon.status = "PSN" +timing.field.tokens[1] = { id = "expires", turns = 1, + onExpire = function() timingOrder[#timingOrder + 1] = "token_expired" end } +local timingEvents, timingHooks = Events.new(), Hooks.new() +timingEvents:on("battle.turn_ended", function() + timingOrder[#timingOrder + 1] = "turn_ended" +end, 0, "timing_probe") +Runtime.install(timingEvents, timingHooks, {}) +local preStatusHp = timing.player.mon.hp +timingHooks:wrap("battle.field_residual", function(next, context) + timingOrder[#timingOrder + 1] = "field_residual" + T.check(context.battlers.player.hp < preStatusHp, + "the hook snapshot observes completed vanilla status residuals") + return next(context) +end, 0, "timing_probe") +timing:endOfTurn() +T.same(timingOrder, + { "field_residual", "token_expired", "turn_ended" }, + "the hook runs before token expiry and battle.turn_ended") + +local oldGetState, oldSetState = love.math.getRandomState, + love.math.setRandomState +local checkpointRng = "field-residual-rng" +love.math.getRandomState = function() return checkpointRng end +love.math.setRandomState = function(state) checkpointRng = state end + +local function checkpointBattle() + local save = SaveData.newGame() + save.meta.playthroughId = "field-residual-checkpoint" + save.party = { Pokemon.new(data, "FIXMON_A", 30) } + SaveData.validate(save, data) + save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3 + save.player.facing, save.player.surfing = "left", false + local stack = setmetatable({ states = {} }, { __index = StateStack }) + local overworld = { + map = { id = "FIX_TOWN" }, + player = { cellX = 2, cellY = 3, facing = "left", surfing = false }, + runner = { isRunning = function() return false end }, + parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, + scriptMoves = {}, + } + function overworld:captureSave(target) + target.player.map = self.map.id + target.player.x, target.player.y = self.player.cellX, self.player.cellY + target.player.facing = self.player.facing + target.player.surfing = self.player.surfing and true or false + end + function overworld:restoreBattleContinuation(restored, origin) + restored.onFinish = function() end + return origin.kind == "wild_encounter" and origin.map == self.map.id + end + local game = setmetatable({ data = data, save = save, stack = stack, + overworld = overworld }, { __index = GameMethods }) + stack.states[1] = overworld + local battle = BattleState.newWild(game, "FIXMON_C", 30) + battle.phase, battle.queue = "menu", {} + battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" } + battle.musicKind = battle:computeMusicKind() + battle.onFinish = function() end + battle.field.weather = { id = "checkpoint-weather", turns = 5 } + stack.states[2] = battle + return game, battle +end + +local checkpointGame = checkpointBattle() +local checkpointHooks, restoredCalls = Hooks.new(), 0 +Runtime.install(Events.new(), checkpointHooks, {}) +checkpointHooks:wrap("battle.field_residual", function(next, context) + restoredCalls = restoredCalls + 1 + T.same(context.field.weather, + { id = "checkpoint-weather", turns = 5 }, + "the enabled hook observes checkpointed field state after restore") + return next(context) +end, 0, "checkpoint_probe") +local snapshot, captureCode = Checkpoint.capture(checkpointGame) +T.check(snapshot ~= nil, + "an enabled process-local hook does not enter the checkpoint: " + .. tostring(captureCode)) +if snapshot then + checkpointGame.stack:top().field.weather.turns = 1 + local restored, restoreCode, restoreMessage = + Checkpoint.restore(checkpointGame, snapshot) + T.check(restored == true, + "field state reconstructs while the hook remains enabled: " + .. tostring(restoreCode or restoreMessage)) + if restored then checkpointGame.stack:top():applyFieldResiduals() end + if restored then + T.same(Checkpoint.capture(checkpointGame), snapshot, + "enabled-hook field state completes capture/restore/capture round-trip") + end +end +T.eq(restoredCalls, 1, + "the process-local hook still runs after checkpoint reconstruction") +love.math.getRandomState, love.math.setRandomState = oldGetState, oldSetState + +Runtime.install(savedEvents, savedHooks, savedErrors) +T.finish("battle.field_residual validation") diff --git a/tests/engine/crystal_version_test.lua b/tests/engine/crystal_version_test.lua index bac6160a..894a4504 100644 --- a/tests/engine/crystal_version_test.lua +++ b/tests/engine/crystal_version_test.lua @@ -100,8 +100,8 @@ local function probe(version) end return { type = "file" } end - love.filesystem.getRealDirectory = function() return "/nowhere" end - love.filesystem.getSource = function() return "/elsewhere" end + love.filesystem.getRealDirectory = function() return "/source" end + love.filesystem.getSource = function() return "/source" end RomImporter.isReady(version) return seen, order end diff --git a/tests/engine/gen2_chrome_print_through_ttf_test.lua b/tests/engine/gen2_chrome_print_through_ttf_test.lua new file mode 100644 index 00000000..068d0a2f --- /dev/null +++ b/tests/engine/gen2_chrome_print_through_ttf_test.lua @@ -0,0 +1,133 @@ +-- Chrome.printThrough (src/ui/gen2/Chrome.lua) used to run every string +-- through the GbcPalette shade-remap shader whenever a palette was given, +-- with no regard for whether the glyph it was about to draw came from a +-- tile page or from a TTF. The shader recovers a shade by reading the +-- RED CHANNEL of an already-rasterized 2bpp tile pixel (SHADER_SOURCE in +-- src/render/GbcPalette.lua); a TTF glyph is LÖVE's own anti-aliased +-- coverage mask, drawn as plain white with the current tint carrying the +-- ink colour, which that same channel read always reports as shade 0 -- +-- painting every character the SAME colour as the paper rect printThrough +-- had just drawn behind it, i.e. invisible. Reported against a real Gold +-- build running a TTF translation mod: the naming screen's keyboard, +-- Diploma and Pokegear text all vanish, since all three draw through this +-- one routine (gen1recomp#1642). +-- +-- The switch is per GLYPH, not per string: a TTF-mod build still keeps +-- multi-byte charmap sequences (the naming screen's own / cells, +-- the 'd/'l/'s ligatures) and anything a mod names in ttf.tiles on their ROM +-- tiles (src/render/Font.lua's Font.split), so one call can mix both kinds +-- of glyph and each must take its own path. +-- +-- No real shader runs headless (love_stub does not stub newShader), so this +-- cannot check a rendered pixel. Font.encode/drawCode/advanceOf/width are +-- replaced with fakes that hand printThrough a fixed list of glyph codes, +-- so what is checked is the two things that decide the outcome: which +-- glyphs skip the shader, and what colour is active when each one draws. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local Chrome = require("src.ui.gen2.Chrome") +local GbcPalette = require("src.render.GbcPalette") +local Font = require("src.render.Font") + +-- Palette arrays are 1-indexed shade 0..3, same order GbcPalette.useRaw +-- reads (src/render/GbcPalette.lua channel()). +local PALETTE = { { 200, 220, 255 }, { 150, 170, 220 }, { 90, 100, 160 }, { 10, 10, 30 } } + +local TILE_CODE = 0x80 +local TTF_CODE = Font.TTF_BASE + 65 -- 'A', were it decoded + +-- "text" is a plain list of glyph codes for this suite; Font.width/encode +-- pass it straight through, matching how a real caller's string would +-- decode to a list of codes. +Font.width = function(codes) return #codes * 8 end +Font.encode = function(codes) return codes end +Font.advanceOf = function(_code) return 8 end + +local drawn +Font.drawCode = function(code, x, y) + drawn[#drawn + 1] = { code = code, x = x, y = y, color = { love.graphics.getColor() } } +end + +local useRawCalls +local realUseRaw = GbcPalette.useRaw +GbcPalette.useRaw = function(...) + useRawCalls = useRawCalls + 1 + return realUseRaw(...) +end + +GbcPalette.available = function() return true end + +local function colorsEq(a, b) + return math.abs(a[1] - b[1]) < 1e-9 and math.abs(a[2] - b[2]) < 1e-9 + and math.abs(a[3] - b[3]) < 1e-9 +end + +-- ------------------------------------------------------- all tile glyphs +do + useRawCalls = 0 + drawn = {} + Chrome.printThrough({ TILE_CODE, TILE_CODE }, 0, 0, PALETTE) + T.eq(useRawCalls, 1, "one shaded run binds the shader once, not per glyph") + T.eq(#drawn, 2, "both glyphs drew") + for i, d in ipairs(drawn) do + T.check(colorsEq(d.color, { 1, 1, 1, 1 }), + ("tile glyph %d is tinted white, letting the shader pick the colour"):format(i)) + end +end + +-- -------------------------------------------------------- all TTF glyphs +do + useRawCalls = 0 + drawn = {} + Chrome.printThrough({ TTF_CODE, TTF_CODE }, 0, 0, PALETTE) + T.eq(useRawCalls, 0, "a TTF glyph never binds the shade-remap shader") + T.eq(#drawn, 2, "both glyphs drew") + local ink = Chrome.throughPalette(PALETTE, false)[4] + for i, d in ipairs(drawn) do + T.check(colorsEq(d.color, { ink[1] / 255, ink[2] / 255, ink[3] / 255, 1 }), + ("TTF glyph %d is tinted with the palette's own ink colour"):format(i)) + end +end + +-- --------------------------------------------- mixed: tile, TTF, then tile +do + useRawCalls = 0 + drawn = {} + Chrome.printThrough({ TILE_CODE, TTF_CODE, TILE_CODE }, 0, 0, PALETTE) + T.eq(useRawCalls, 2, + "the shader re-binds once per return to a tile glyph, not once for the whole string") + T.eq(#drawn, 3, "all three glyphs drew") + local ink = Chrome.throughPalette(PALETTE, false)[4] + T.check(colorsEq(drawn[1].color, { 1, 1, 1, 1 }), "1st (tile) glyph: white/shaded") + T.check(colorsEq(drawn[2].color, { ink[1] / 255, ink[2] / 255, ink[3] / 255, 1 }), + "2nd (TTF) glyph, mid-string, still gets the ink tint") + T.check(colorsEq(drawn[3].color, { 1, 1, 1, 1 }), "3rd (tile) glyph: shaded again") +end + +-- ---------------------------------------------------- inverted TTF ink +do + drawn = {} + Chrome.printThrough({ TTF_CODE }, 0, 0, PALETTE, true) + local ink = Chrome.throughPalette(PALETTE, true)[4] + T.check(colorsEq(drawn[1].color, { ink[1] / 255, ink[2] / 255, ink[3] / 255, 1 }), + "an inverted call tints TTF ink with the inverted palette's own shade-3 entry") +end + +-- ---------------------------------------------- DMG mode's own TTF ink +do + GbcPalette.setMode("dmg") + drawn = {} + Chrome.printThrough({ TTF_CODE }, 0, 0, PALETTE) + local ink = Chrome.throughPalette(PALETTE, false)[4] + T.check(colorsEq(drawn[1].color, { ink[1] / 255, ink[2] / 255, ink[3] / 255, 1 }), + "DMG mode's own resolved palette (four grey hardware shades) still reaches the TTF ink") + GbcPalette.setMode("gbc") +end + +T.finish("gen2_chrome_print_through_ttf_test") diff --git a/tests/engine/gen2_main_menu_translation_test.lua b/tests/engine/gen2_main_menu_translation_test.lua new file mode 100644 index 00000000..e5c7152a --- /dev/null +++ b/tests/engine/gen2_main_menu_translation_test.lua @@ -0,0 +1,119 @@ +-- Gold's title/main menu (src/ui/gen2/MainMenu.lua) drew every row label +-- (CONTINUE/NEW GAME/OPTION/EXIT GAME), the clock box's AM/PM half, and the +-- CONTINUE save-summary panel's labels (PLAYER /BADGES/POKéDEX/TIME, +-- or NO SAVE FILE) as bare literals, invisible to a translation mod's +-- `strings` registry -- unlike the Gen 1 port's own title menu +-- (src/ui/TitleState.lua/StartMenu.lua), which already routes the same rows +-- through Strings(). Drives MainMenu:drawPanel()/:drawSavePanel() with a +-- mod-loaded Strings catalog and checks the translated text reaches +-- Font.draw, same technique as +-- tests/engine/gen2_naming_screen_translation_test.lua. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local drawn +package.loaded["src.render.Font"] = { + draw = function(text, x, y) + drawn[#drawn + 1] = { text = text, x = x, y = y } + end, + drawCode = function() end, + drawBox = function() end, + width = function() return 0 end, +} + +local MainMenu = require("src.ui.gen2.MainMenu") +local Strings = require("src.core.Strings") + +local function drawnAt(x, y) + for _, d in ipairs(drawn) do + if d.x == x and d.y == y then return d.text end + end + return nil +end + +-- Chrome.print multiplies tile coordinates by 8 (src/ui/gen2/Chrome.lua). +-- List item 1 lands at (self.x, self.y) = (2, 2); the clock box's day name +-- at (1, 14) and the hour:minute half at (4, 16); the save panel's PLAYER +-- row at (5, 2). +local FIRST_ITEM_X, FIRST_ITEM_Y = 2 * 8, 2 * 8 +local CLOCK_HALF_X, CLOCK_HALF_Y = 4 * 8, 16 * 8 +local PANEL_PLAYER_X, PANEL_PLAYER_Y = 5 * 8, 2 * 8 + +local SAVE = { player = { name = "GOLD" } } +local CLOCK = { hour = 13, minute = 5, weekday = 1 } -- 1 PM, SUNDAY + +-- ---------------------------------------------- vanilla: no mod catalog +do + local menu = MainMenu.new({}, { hasSave = true, save = SAVE, clock = CLOCK }) + drawn = {} + menu:drawPanel() + T.eq(drawnAt(FIRST_ITEM_X, FIRST_ITEM_Y), "CONTINUE", + "the title menu's first row draws in English with no mod loaded") + T.eq(drawnAt(CLOCK_HALF_X, CLOCK_HALF_Y), " 1:05 PM", + "and the clock box's AM/PM half") + + drawn = {} + menu:drawSavePanel() + T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "PLAYER GOLD", + "the CONTINUE save-summary panel too") + + local noSaveMenu = MainMenu.new({}, { hasSave = false, save = false, clock = CLOCK }) + drawn = {} + noSaveMenu:drawSavePanel() + T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "NO SAVE FILE", + "and its no-summary fallback") +end + +-- ------------------------------------------------- a translation mod's turn +do + Strings.load({ + strings = { + ["CONTINUE"] = "CONTINUAR", + ["NEW GAME"] = "NUEVA PARTIDA", + ["OPTION"] = "OPCIÓN", + ["EXIT GAME"] = "SALIR", + ["PM"] = "PM_ES", + ["PLAYER %s"] = "JUGADOR %s", + ["BADGES"] = "MEDALLAS", + ["POKéDEX"] = "POKéDEX_ES", + ["TIME"] = "TIEMPO", + ["NO SAVE FILE"] = "SIN PARTIDA", + }, + }) + + local menu = MainMenu.new({}, { hasSave = true, save = SAVE, clock = CLOCK }) + drawn = {} + menu:drawPanel() + T.eq(drawnAt(FIRST_ITEM_X, FIRST_ITEM_Y), "CONTINUAR", + "a mod catalog reaches the title menu's first row") + T.eq(drawnAt(CLOCK_HALF_X, CLOCK_HALF_Y), " 1:05 PM_ES", + "and the clock box's AM/PM half") + + drawn = {} + menu:drawSavePanel() + T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "JUGADOR GOLD", + "the save-summary panel's PLAYER row takes the mod's own word order") + T.eq(drawnAt(5 * 8, 4 * 8), "MEDALLAS", "and BADGES") + T.eq(drawnAt(5 * 8, 6 * 8), "POKéDEX_ES", "and POKéDEX") + T.eq(drawnAt(5 * 8, 8 * 8), "TIEMPO", "and TIME") + + local noSaveMenu = MainMenu.new({}, { hasSave = false, save = false, clock = CLOCK }) + drawn = {} + noSaveMenu:drawSavePanel() + T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "SIN PARTIDA", + "and the no-summary fallback") + + -- Module state is process-global (see tests/gen2_clock_test.lua's own + -- note); this suite gets its own process from tests/tier_runner.lua, but + -- leaving the catalog loaded past this point would still mistranslate + -- every check below it in this file. + Strings.load({}) + T.check(not Strings.active(), "the catalog is unloaded for the checks after this one") +end + +T.finish("gen2_main_menu_translation_test") diff --git a/tests/engine/gen2_naming_screen_translation_test.lua b/tests/engine/gen2_naming_screen_translation_test.lua new file mode 100644 index 00000000..cf285f7e --- /dev/null +++ b/tests/engine/gen2_naming_screen_translation_test.lua @@ -0,0 +1,100 @@ +-- Gold's naming/keyboard screen (src/ui/gen2/NamingScreen.lua) had zero +-- Strings() calls: every prompt (YOUR NAME?/RIVAL'S NAME?/MOTHER'S NAME?/ +-- BOX NAME?/NICKNAME?), the on-screen keyboard's own letters, and the +-- lower/UPPER/DEL/END bottom-row labels were bare literals, invisible to a +-- translation mod's `strings` registry (reported against a real Gold build, +-- gen1recomp#1642). The Gen 1 naming screen (src/ui/NamingScreen.lua) already +-- routes its title and every keyboard cell through Strings(). +-- +-- GbcPalette.available() is false headless (no real shader compiles), so +-- Chrome.printThrough already falls back to the plain, unshaded Chrome.print +-- -- this drives that path directly and checks the translated text reaches +-- Font.draw, the same technique +-- tests/engine/gen2_options_menu_translation_test.lua uses for the OPTION +-- screen. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local drawn +package.loaded["src.render.Font"] = { + draw = function(text, x, y) + drawn[#drawn + 1] = { text = text, x = x, y = y } + end, + drawCode = function() end, + drawBox = function() end, +} + +local NamingScreen = require("src.ui.gen2.NamingScreen") +local Strings = require("src.core.Strings") + +local function drawnAt(x, y) + for _, d in ipairs(drawn) do + if d.x == x and d.y == y then return d.text end + end + return nil +end + +-- Chrome.print multiplies tile coordinates by 8 (src/ui/gen2/Chrome.lua); +-- the prompt lands at tile (5, 2), the keyboard's first cell at (2, 8). +local PROMPT_X, PROMPT_Y = 5 * 8, 2 * 8 +local FIRST_CELL_X, FIRST_CELL_Y = 2 * 8, 8 * 8 + +-- ---------------------------------------------- vanilla: no mod catalog +do + local screen = NamingScreen.new({}, { type = "player" }) + drawn = {} + screen:drawPanel() + T.eq(drawnAt(PROMPT_X, PROMPT_Y), "YOUR NAME?", + "the player-name prompt draws in English with no mod loaded") + T.eq(drawnAt(FIRST_CELL_X, FIRST_CELL_Y), "A", + "and the keyboard's first cell too") +end + +-- ------------------------------------------------- a translation mod's turn +do + Strings.load({ + strings = { + ["YOUR NAME?"] = "TON NOM?", + ["A"] = "À", + ["lower"] = "minusc", + ["END"] = "FIN", + ["%s'S"] = "DE %s", + ["NICKNAME?"] = "SURNOM?", + }, + }) + + local screen = NamingScreen.new({}, { type = "player" }) + drawn = {} + screen:drawPanel() + T.eq(drawnAt(PROMPT_X, PROMPT_Y), "TON NOM?", + "a mod catalog reaches the prompt") + T.eq(drawnAt(FIRST_CELL_X, FIRST_CELL_Y), "À", + "and a keyboard cell") + + -- The bottom row: lower/DEL/END at tile y = keyboardTop + bottom*2. + local bottomY = (screen:keyboardTop() + screen:bottomRow() * 2) * 8 + T.eq(drawnAt(2 * 8, bottomY), "minusc", "the case-switch label is translated") + T.eq(drawnAt(15 * 8, bottomY), "FIN", "and END, the way out of the screen") + + -- The nickname header: two lines, the mon name folded into the first. + local nickScreen = NamingScreen.new({}, { type = "nickname", monName = "BULBASAUR" }) + drawn = {} + nickScreen:drawPanel() + T.eq(drawnAt(PROMPT_X, PROMPT_Y), "DE BULBASAUR", + "the nickname header's first line takes the mod's own word order") + T.eq(drawnAt(PROMPT_X, 4 * 8), "SURNOM?", "and its second line") + + -- Module state is process-global (see tests/gen2_clock_test.lua's own + -- note); this suite gets its own process from tests/tier_runner.lua, but + -- leaving the catalog loaded past this point would still mistranslate + -- every check below it in this file. + Strings.load({}) + T.check(not Strings.active(), "the catalog is unloaded for the checks after this one") +end + +T.finish("gen2_naming_screen_translation_test") diff --git a/tests/engine/gen2_options_menu_translation_test.lua b/tests/engine/gen2_options_menu_translation_test.lua new file mode 100644 index 00000000..2ce171c3 --- /dev/null +++ b/tests/engine/gen2_options_menu_translation_test.lua @@ -0,0 +1,121 @@ +-- Gold's OPTION screen (src/ui/gen2/OptionsMenu.lua) used to draw every row +-- label -- and the cart-original value strings (FAST/MID/SLOW, ON/OFF, +-- SHIFT/SET, ...) -- as bare literals baked into the module-level ROWS +-- table, invisible to a translation mod's `strings` registry (reported +-- against a real Gold build, gen1recomp#1642). This drives +-- OptionsMenu:drawPanel() with a mod-loaded Strings catalog and checks the +-- translated text reaches Font.draw, for both a cart-original row (label + +-- display value) and a port-added row (label only -- its value already +-- comes pre-translated from the shared module it calls, same as the Gen 1 +-- OPTION screen's equivalent rows), plus a vanilla no-mod case proving the +-- fallback is unchanged. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +-- Chrome.print (the only draw call this screen makes) goes straight to +-- Font.draw, so recording that call is enough to see exactly what text +-- reached the screen -- same technique as +-- tests/engine/status_abbreviation_translation_test.lua. Stubbed before +-- OptionsMenu (and the Chrome module it requires) ever loads, so Chrome's +-- own `local Font = require(...)` captures this stub instead of the real +-- module. +local drawn +package.loaded["src.render.Font"] = { + draw = function(text, x, y) + drawn[#drawn + 1] = { text = text, x = x, y = y } + end, + drawCode = function() end, + drawBox = function() end, +} + +local OptionsMenu = require("src.ui.gen2.OptionsMenu") +local Strings = require("src.core.Strings") + +local function drawnAt(x, y) + for _, d in ipairs(drawn) do + if d.x == x and d.y == y then return d.text end + end + return nil +end + +-- Chrome.print multiplies tile coordinates by 8 (src/ui/gen2/Chrome.lua); +-- drawPanel puts labels at tile x=2 and values at tile x=11. +local LABEL_X = 2 * 8 +local VALUE_X = 11 * 8 + +local function rowIndex(rows, id) + for i, row in ipairs(rows) do + if row.id == id then return i end + end +end + +-- ---------------------------------------------- vanilla: no mod catalog +do + local menu = OptionsMenu.new({}) + drawn = {} + menu:drawPanel() + T.eq(drawnAt(LABEL_X, 2 * 8), "TEXT SPEED", + "row 1's label draws in English with no mod loaded") + -- Save.DEFAULT_OPTIONS.textSpeed is "MID", the cart's own default. + T.eq(drawnAt(VALUE_X, 3 * 8), "MID ", + "and its cart-original display value too") +end + +-- ------------------------------------------------- a translation mod's turn +do + Strings.load({ + strings = { + ["TEXT SPEED"] = "VITESSE TEXTE", + ["MID "] = "MOY ", + ["CONTROLS"] = "COMMANDES", + ["CANCEL"] = "ANNULER", + }, + }) + + local menu = OptionsMenu.new({}) + drawn = {} + menu:drawPanel() + T.eq(drawnAt(LABEL_X, 2 * 8), "VITESSE TEXTE", + "a mod catalog reaches a cart-original row's label") + T.eq(drawnAt(VALUE_X, 3 * 8), "MOY ", + "and its cart-original display value") + + -- CONTROLS is the first port-added row; scroll to it so it lands in the + -- VISIBLE_ROWS=7 window drawPanel actually draws. + local index = rowIndex(menu.rows, "controls") + T.check(index ~= nil, "CONTROLS is one of the rows") + menu.index = index + menu:ensureVisible() + drawn = {} + menu:drawPanel() + local slot = index - menu.scroll + T.eq(drawnAt(LABEL_X, (2 + (slot - 1) * 2) * 8), "COMMANDES", + "and a port-added row's label is translated too") + + -- CANCEL is the last row, built into ROWS like any other -- there is no + -- separate hook to fall through if this one row is missed. + local cancelMenu = OptionsMenu.new({}) + local cancelIndex = #cancelMenu.rows + T.check(cancelMenu.rows[cancelIndex].cancel, "the last row is CANCEL") + cancelMenu.index = cancelIndex + cancelMenu:ensureVisible() + drawn = {} + cancelMenu:drawPanel() + local cancelSlot = cancelIndex - cancelMenu.scroll + T.eq(drawnAt(LABEL_X, (2 + (cancelSlot - 1) * 2) * 8), "ANNULER", + "CANCEL, the way out of the menu, is translated too") + + -- Module state is process-global (see tests/gen2_clock_test.lua's own + -- note); this suite gets its own process from tests/tier_runner.lua, but + -- leaving the catalog loaded past this point would still mistranslate + -- every check below it in this file. + Strings.load({}) + T.check(not Strings.active(), "the catalog is unloaded for the checks after this one") +end + +T.finish("gen2_options_menu_translation_test") diff --git a/tests/engine/gen2_save_menu_translation_test.lua b/tests/engine/gen2_save_menu_translation_test.lua new file mode 100644 index 00000000..c560d0bf --- /dev/null +++ b/tests/engine/gen2_save_menu_translation_test.lua @@ -0,0 +1,199 @@ +-- Gold's SAVE screen (src/ui/gen2/SaveMenu.lua) drew every prompt ("Would +-- you like to save the game?", the overwrite/saving/saved messages), the +-- YES/NO choice, and the summary panel's labels (PLAYER /BADGES/ +-- POKéDEX/TIME) as bare literals, invisible to a translation mod's +-- `strings` registry -- unlike the Gen 1 port's own SAVE screen +-- (src/ui/StartMenu.lua), which already routes the same rows through +-- Strings(). Drives SaveMenu:drawPanel() directly at each phase with a +-- mod-loaded Strings catalog and checks the translated text reaches +-- Font.draw, same technique as +-- tests/engine/gen2_naming_screen_translation_test.lua. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local drawn +package.loaded["src.render.Font"] = { + draw = function(text, x, y) + drawn[#drawn + 1] = { text = text, x = x, y = y } + end, + drawCode = function() end, + drawBox = function() end, + width = function() return 0 end, +} + +local SaveMenu = require("src.ui.gen2.SaveMenu") +local Strings = require("src.core.Strings") + +local function drawnAt(x, y) + for _, d in ipairs(drawn) do + if d.x == x and d.y == y then return d.text end + end + return nil +end + +-- Chrome.print multiplies tile coordinates by 8 (src/ui/gen2/Chrome.lua). +-- PLAYER row at (5, 2); the two prompt lines at (1, 14)/(1, 16); YES/NO at +-- (2, 8)/(2, 10) (YESNO_X + 2, YESNO_Y + 1 / + 3). +local PANEL_PLAYER_X, PANEL_PLAYER_Y = 5 * 8, 2 * 8 +local PROMPT1_X, PROMPT1_Y = 1 * 8, 14 * 8 +local PROMPT2_X, PROMPT2_Y = 1 * 8, 16 * 8 +local YES_X, YES_Y = 2 * 8, 8 * 8 +local NO_X, NO_Y = 2 * 8, 10 * 8 + +local SAVE = { player = { name = "GOLD" } } + +-- ---------------------------------------------- vanilla: no mod catalog +do + local menu = SaveMenu.new({}, { save = SAVE, existed = false }) + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "PLAYER GOLD", + "the summary panel draws in English with no mod loaded") + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Would you like to", + "and the confirm prompt's first line") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "save the game?", "and its second line") + T.eq(drawnAt(YES_X, YES_Y), "YES", "and YES") + T.eq(drawnAt(NO_X, NO_Y), "NO", "and NO") + + menu.phase = "overwrite" + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "There is already a", "the overwrite prompt") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "save file. Is it", "its second line") + + menu.phase = "saving" + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "SAVING… DON'T TURN", "the saving message") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "OFF THE POWER.", "its second line") + + menu.phase, menu.saved = "done", true + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "GOLD saved", "the saved message") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "the game.", "its second line") + + menu.phase, menu.saved = "done", false + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Could not save.", "the failed-save message") +end + +-- ------------------------------------------------- a translation mod's turn +do + Strings.load({ + strings = { + ["PLAYER %s"] = "JOUEUR %s", + ["BADGES"] = "BADGES_FR", + ["POKéDEX"] = "POKéDEX_FR", + ["TIME"] = "TEMPS", + ["YES"] = "OUI", + ["NO"] = "NON", + ["Would you like to\nsave the game?"] = "Voulez-vous\nsauvegarder ?", + ["There is already a\nsave file. Is it"] = "Un fichier existe\ndeja. Est-ce", + ["SAVING… DON'T TURN\nOFF THE POWER."] = "SAUVEGARDE...\nN'ETEIGNEZ PAS.", + ["%s saved\nthe game."] = "%s a sauvegarde\nla partie.", + ["Could not save."] = "Echec de sauvegarde.", + }, + }) + + local menu = SaveMenu.new({}, { save = SAVE, existed = false }) + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "JOUEUR GOLD", + "the summary panel's PLAYER row takes the mod's own word order") + T.eq(drawnAt(5 * 8, 4 * 8), "BADGES_FR", "and BADGES") + T.eq(drawnAt(5 * 8, 6 * 8), "POKéDEX_FR", "and POKéDEX") + T.eq(drawnAt(5 * 8, 8 * 8), "TEMPS", "and TIME") + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Voulez-vous", "the confirm prompt") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "sauvegarder ?", "its second line") + T.eq(drawnAt(YES_X, YES_Y), "OUI", "and YES") + T.eq(drawnAt(NO_X, NO_Y), "NON", "and NO") + + menu.phase = "overwrite" + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Un fichier existe", "the overwrite prompt") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "deja. Est-ce", "its second line") + + menu.phase = "saving" + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "SAUVEGARDE...", "the saving message") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "N'ETEIGNEZ PAS.", "its second line") + + menu.phase, menu.saved = "done", true + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "GOLD a sauvegarde", + "the saved message folds the player name into the mod's own word order") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "la partie.", "its second line") + + menu.phase, menu.saved = "done", false + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Echec de sauvegarde.", "the failed-save message") + + -- Module state is process-global (see tests/gen2_clock_test.lua's own + -- note); this suite gets its own process from tests/tier_runner.lua, but + -- leaving the catalog loaded past this point would still mistranslate + -- every check below it in this file. + Strings.load({}) + T.check(not Strings.active(), "the catalog is unloaded for the checks after this one") +end + +-- src/ui/gen2/PcMenu.lua:savePrompt() returns SaveMenu.OVERWRITE_PROMPT/ +-- SAVING_PROMPT straight through to its own `lines[1]`/`lines[2]` +-- Chrome.print calls (the PC's CHANGE BOX save uses the same two prompts). +-- Indexing a plain string with [1]/[2] returns nil, not characters, so this +-- shape is a cross-file contract: it caught a real regression during review, +-- where routing these through a single Strings.source()-wrapped string (to +-- translate SaveMenu's own screen) silently turned them into non-table +-- values and left PcMenu's overwrite/saving prompt blank. +do + T.eq(type(SaveMenu.OVERWRITE_PROMPT), "table", "OVERWRITE_PROMPT stays a table for PcMenu.lua") + T.eq(SaveMenu.OVERWRITE_PROMPT[1], "There is already a", "and its first line stays indexable") + T.eq(SaveMenu.OVERWRITE_PROMPT[2], "save file. Is it", "and its second line") + T.eq(type(SaveMenu.SAVING_PROMPT), "table", "SAVING_PROMPT stays a table for PcMenu.lua") + T.eq(SaveMenu.SAVING_PROMPT[1], "SAVING… DON'T TURN", "and its first line stays indexable") + T.eq(SaveMenu.SAVING_PROMPT[2], "OFF THE POWER.", "and its second line") +end + +-- A translation with a THIRD line (a second embedded "\n") has nowhere on +-- screen to go -- drawPanel's box has room for exactly two Chrome.print +-- calls -- so it must not silently draw the literal newline byte as glyph +-- garbage on the second line, and should warn so a translator notices. +do + Strings.load({ + strings = { + ["Would you like to\nsave the game?"] = "Ligne un\nLigne deux\nLigne trois", + }, + }) + + local warned = {} + require("src.core.Logger").warn = function(fmt, ...) + warned[#warned + 1] = select("#", ...) > 0 and fmt:format(...) or fmt + end + + local menu = SaveMenu.new({}, { save = SAVE, existed = false }) + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Ligne un", "only the first line reaches the box") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "Ligne deux\nLigne trois", + "the rest lands in the second slot rather than vanishing") + T.check(#warned == 1, "and a single warning is logged") + + drawn = {} + menu:drawPanel() + T.check(#warned == 1, "the warning does not repeat for the same text") + + require("src.core.Logger").warn = function() end + Strings.load({}) +end + +T.finish("gen2_save_menu_translation_test") diff --git a/tests/engine/launcher_session_teardown_test.lua b/tests/engine/launcher_session_teardown_test.lua index 6ceee3e2..e63d3fd7 100644 --- a/tests/engine/launcher_session_teardown_test.lua +++ b/tests/engine/launcher_session_teardown_test.lua @@ -1,5 +1,5 @@ -- In-process launcher session teardown: Game:reset, Renderer canvas release, --- Runtime/Assets/LegacyCompat cleanup, and editor package.loaded discovery flush. +-- SessionLifecycle mount/game tiers, and editor package.loaded discovery flush. -- luajit tests/engine/launcher_session_teardown_test.lua package.path = "./?.lua;./?/init.lua;" .. package.path @@ -12,8 +12,12 @@ local Runtime = require("src.mods.Runtime") local Assets = require("src.render.Assets") local LegacyCompat = require("src.mods.LegacyCompat") local Game = require("src.core.Game") +local Game2 = require("src.core.Game2") local Renderer = require("src.render.Renderer") local StateStack = require("src.core.StateStack") +local SessionLifecycle = require("src.core.SessionLifecycle") +local MapLoader = require("src.world.MapLoader") +local World = require("src.world.gen2.World") -- ---- Game:reset drops instance state, keeps methods ---------------------- do @@ -38,6 +42,35 @@ do check(StateStack:top() == nil, "Game:reset cleared the shared StateStack") end +-- ---- Game2:reset releases world GPU and present canvases ------------------ +do + local game2 = Game2.new() + local canvas = love.graphics.newCanvas(4, 4) + game2.world = World.new({}) + game2.world.mapImages = { ["MAP|d|1"] = canvas } + game2._canvases = { love.graphics.newCanvas(8, 8) } + game2:reset() + check(canvas.released == true, "Game2:reset releases World mapImages") + check(game2.world == nil, "Game2:reset clears world reference") + check(game2._canvases == nil, "Game2:reset clears _canvases") +end + +-- ---- World:release frees owned GPU caches -------------------------------- +do + local world = World.new({}) + local bake = love.graphics.newCanvas(16, 16) + local strip = love.graphics.newCanvas(8, 64) + local tilt = love.graphics.newCanvas(160, 144) + world.mapImages = { ["R1|DAY|1"] = bake } + world.scrollStrips = { ["TS|1|0,0"] = strip } + world.tiltCanvas = tilt + world:release() + check(bake.released == true, "World:release frees map bake canvases") + check(strip.released == true, "World:release frees scroll strips") + check(tilt.released == true, "World:release frees tiltCanvas") + eq(next(world.mapImages), nil, "World:release clears mapImages table") +end + -- ---- Renderer:init releases prior canvases before realloc ---------------- do local first = love.graphics.newCanvas(16, 16) @@ -52,14 +85,13 @@ do "Renderer:init allocates a fresh primary canvas") check(Renderer.canvas.released ~= true, "the new primary canvas is not released") - -- second init also releases the one just created local second = Renderer.canvas Renderer:init() check(second.released == true, "a second Renderer:init releases the canvas from the prior init") end --- ---- Shared singleton teardown contract (closeEditor / returnToLauncher) +-- ---- SessionLifecycle.endMountedSession (closeEditor / returnToLauncher) -- do Runtime.install({ emit = function() end }, { call = function() end }, { "e" }) Assets.installLoader({ @@ -68,44 +100,79 @@ do }) LegacyCompat.reports = { some_mod = { order = {} } } - -- Mirrors main.lua teardownMountedSession without mounting CacheFs. - require("src.core.Data"):unloadGenerated() - Runtime.reset() - Assets.installLoader(nil) - LegacyCompat.reset() + SessionLifecycle.endMountedSession(nil) - check(Runtime.errors == nil, "teardown clears Runtime.errors") - check(Assets.loader == nil, "teardown clears Assets.loader") - eq(next(LegacyCompat.reports), nil, "teardown clears LegacyCompat.reports") + check(Runtime.errors == nil, "endMountedSession clears Runtime.errors") + check(Assets.loader == nil, "endMountedSession clears Assets.loader") + eq(next(LegacyCompat.reports), nil, "endMountedSession clears LegacyCompat.reports") end --- ---- Editor package.loaded discovery flush (no panel whitelist) --------- +-- ---- releaseSession empties MapLoader via releaseAll, not flush ------------- +do + local data = { + maps = { T1 = { id = "T1", tileset = "TS", width = 1, height = 1, + blocks = { 0 }, borderBlock = 0, objects = {}, warps = {}, signs = {} } }, + tilesets = { TS = { id = "TS", image = "assets/generated/t.png", + walkable = {}, blocks = { { 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 } }, + tilesPerRow = 1 } }, + } + MapLoader.load(data, "T1") + check(MapLoader.cached("T1") ~= nil, "MapLoader holds a map before release") + Assets.releaseSession() + check(MapLoader.cached("T1") == nil, + "releaseSession evicts MapLoader via releaseAll") +end + +-- ---- Editor package.loaded discovery flush via endEditorSession ----------- do love.filesystem.write("tools/save-editor/App.lua", "return {}") love.filesystem.write("tools/save-editor/panels/NewPanel.lua", "return {}") package.loaded["App"] = { stale = true } package.loaded["NewPanel"] = { stale = true } - package.loaded["src.core.Data"] = package.loaded["src.core.Data"] -- keep + package.loaded["src.core.Data"] = package.loaded["src.core.Data"] - local function isEditorFlat(name) - if name:find("[./]") then return false end - return love.filesystem.getInfo("tools/save-editor/" .. name .. ".lua") ~= nil - or love.filesystem.getInfo("tools/save-editor/panels/" .. name .. ".lua") ~= nil - end - for k in pairs(package.loaded) do - if type(k) == "string" - and (k:find("save%-editor", 1, false) or isEditorFlat(k)) then - package.loaded[k] = nil - end - end + SessionLifecycle.endEditorSession({ version = nil, app = nil }) - check(package.loaded["App"] == nil, "discovery flush drops flat App") + check(package.loaded["App"] == nil, "endEditorSession drops flat App") check(package.loaded["NewPanel"] == nil, - "discovery flush drops a new panel without a hardcoded list") + "endEditorSession drops a new panel without a hardcoded list") check(package.loaded["src.core.Data"] ~= nil, - "discovery flush leaves engine modules alone") + "endEditorSession leaves engine modules alone") love.filesystem.remove("tools/save-editor/App.lua") love.filesystem.remove("tools/save-editor/panels/NewPanel.lua") end +-- ---- Fetch shutdown clears ready so Play-again can respawn workers --------- +do + local Fetch = require("src.net.Fetch") + local spawnAttempts = 0 + love.thread = love.thread or {} + local savedNewThread = love.thread.newThread + local savedGetChannel = love.thread.getChannel + love.thread.getChannel = function() + return { + clear = function() end, + push = function() end, + pop = function() return nil end, + demand = function() end, + } + end + love.thread.newThread = function() + spawnAttempts = spawnAttempts + 1 + return { + start = function() end, + wait = function() end, + getError = function() return nil end, + } + end + Fetch.available() + local afterFirst = spawnAttempts + Fetch.shutdown() + Fetch.available() + check(spawnAttempts > afterFirst, + "Fetch.available retries worker spawn after shutdown (ready=nil)") + love.thread.newThread = savedNewThread + love.thread.getChannel = savedGetChannel +end + T.finish("launcher_session_teardown_test") diff --git a/tests/engine/mew_dock_private_artifact_gate.lua b/tests/engine/mew_dock_private_artifact_gate.lua new file mode 100644 index 00000000..1402aefd --- /dev/null +++ b/tests/engine/mew_dock_private_artifact_gate.lua @@ -0,0 +1,49 @@ +-- Static privacy gate for the narrow Mew dock engine branch. It checks the +-- Git publication set, not ignored local imports: user ROMs and progress +-- saves may exist on a developer machine but must never become tracked files. + +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") + +local pipe = io.popen("git ls-files 2>" .. (package.config:sub(1, 1) == "\\" and "nul" or "/dev/null")) +local paths = {} +if pipe then + for path in pipe:lines() do paths[#paths + 1] = path:gsub("\\", "/") end + pipe:close() +end + +T.check(#paths > 100, + "privacy gate inspects a real Git publication set instead of passing vacuously") + +local forbiddenExtensions = { + gb = true, gbc = true, gba = true, sav = true, srm = true, + rom = true, z64 = true, v64 = true, n64 = true, nds = true, + sfc = true, smc = true, +} +local forbiddenRuntimeRoots = { + ["save.lua"] = true, + ["save_blue.lua"] = true, + ["save_yellow.lua"] = true, + ["save_gold.lua"] = true, + ["options.lua"] = true, +} + +local binaryLeaks, runtimeLeaks = {}, {} +for _, path in ipairs(paths) do + local lower = path:lower() + local ext = lower:match("%.([^./\\]+)$") + if forbiddenExtensions[ext] then binaryLeaks[#binaryLeaks + 1] = path end + if forbiddenRuntimeRoots[lower] + or lower:match("^saves/") + or lower:match("^imports/") + or lower:match("^mods%-data/") then + runtimeLeaks[#runtimeLeaks + 1] = path + end +end + +T.eq(#binaryLeaks, 0, + "tracked publication contains no ROM/save binaries: " .. table.concat(binaryLeaks, ", ")) +T.eq(#runtimeLeaks, 0, + "tracked publication contains no runtime save/import data: " .. table.concat(runtimeLeaks, ", ")) + +T.finish("mew dock private artifact gate") diff --git a/tests/engine/mew_dock_seam_contract.lua b/tests/engine/mew_dock_seam_contract.lua new file mode 100644 index 00000000..627dfca6 --- /dev/null +++ b/tests/engine/mew_dock_seam_contract.lua @@ -0,0 +1,428 @@ +-- Contract gate for the two narrow Gen 1 seams added for a composable +-- post-departure S.S. Anne dock mod. The suite is ROM-free: it drives the +-- real hook bus and WorldAPI against hand-written maps and save snapshots. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness") +local Hooks = require("src.mods.Hooks") +local Runtime = require("src.mods.Runtime") +local WorldAPI = require("src.world.WorldAPI") + +local oldHooks = Runtime.hooks +local oldTextBox = package.loaded["src.render.TextBox"] + +package.loaded["src.render.TextBox"] = { + new = function(_, text, done) return { text = text, done = done } end, +} + +local story = dofile("data/scripts/story3.lua") +local VERSIONS = { "red", "blue", "yellow" } + +local function newDock(version, wrappers) + local blocks, pushes, warps = {}, {}, {} + local rebuilds = 0 + local save = { + version = version, + flags = { EVENT_SS_ANNE_LEFT = true }, + marker = "save-must-not-change", + } + local map = { + id = "VERMILION_DOCK", + setBlock = function(_, bx, by, block) + blocks[bx .. "," .. by] = block + end, + renderer = { rebuild = function() rebuilds = rebuilds + 1 end }, + } + local game = { + save = save, + data = { text = { + _VermilionCitySailor1ShipSetSailText = "The ship set sail.", + } }, + stack = { push = function(_, value) pushes[#pushes + 1] = value end }, + } + local ow = { + map = map, + player = { cellX = 14, cellY = 2, facing = "down" }, + startWarpTo = function(_, ...) + warps[#warps + 1] = { ... } + end, + } + + local hooks = Hooks.new() + Runtime.hooks = hooks + for _, entry in ipairs(wrappers or {}) do + hooks:wrap("map.occupancy_allowed", entry.fn, entry.priority or 0, + entry.owner) + end + + story.VERMILION_DOCK.onEnter(game, ow) + if pushes[1] and pushes[1].done then pushes[1].done() end + return { + blocks = blocks, pushes = pushes, warps = warps, rebuilds = rebuilds, + save = save, map = map, game = game, ow = ow, hooks = hooks, + } +end + +local function allowedWrapper(owner, inspect) + return { + owner = owner, + fn = function(nextFn, game, ctx) + if inspect then inspect(game, ctx) end + local downstream = nextFn(game, ctx) + local ownClaim = true + return downstream == true or ownClaim == true + end, + } +end + +-- With no subscriber, the post-departure branch remains byte-for-byte +-- vanilla in effect: erase the ship, show its line, and eject the player. +do + local run = newDock("red") + T.eq(run.rebuilds, 1, "vanilla re-entry rebuilds the erased dock") + T.eq(run.blocks["5,1"], 1, "vanilla re-entry erases the upper hull") + T.eq(run.blocks["8,2"], 13, "vanilla re-entry erases the lower hull") + T.eq(#run.pushes, 1, "vanilla re-entry shows the ship-set-sail line") + T.eq(run.pushes[1].text, "The ship set sail.", + "vanilla re-entry preserves its dialogue") + T.eq(run.warps[1] and run.warps[1][1], "VERMILION_CITY", + "vanilla re-entry ejects to Vermilion City") +end + +-- The new permission seam is post-departure only. An ordinary HM01 exit +-- must not invoke it or change the existing departure-script path. +do + local hookCalls, queued = 0, nil + local hooks = Hooks.new() + Runtime.hooks = hooks + hooks:wrap("map.occupancy_allowed", function(nextFn, game, ctx) + hookCalls = hookCalls + 1 + return nextFn(game, ctx) + end, 0, "must_not_run") + + local savedMusic = package.loaded["src.core.Music"] + package.loaded["src.core.Music"] = { + stop = function() end, + play = function() end, + } + local game = { + save = { version = "red", flags = { EVENT_GOT_HM01 = true } }, + data = {}, + } + local ow = { + player = { cellX = 14, cellY = 2 }, + startDustAnim = function(_, _, _, done) if done then done() end end, + queueScript = function(_, rows) queued = rows end, + } + story.VERMILION_DOCK.onEnter(game, ow) + package.loaded["src.core.Music"] = savedMusic + + T.eq(hookCalls, 0, "normal HM01 departure never calls the occupancy seam") + T.eq(game.save.flags.EVENT_SS_ANNE_LEFT, true, + "normal HM01 departure still sets the vanilla event flag") + T.check(type(queued) == "table" and #queued > 0, + "normal HM01 departure still queues its sail-away script") +end + +-- One cooperative claimant may permit occupancy. The callback receives a +-- detached data snapshot, not the live overworld, map, player, or save. +do + local seenGame, seenCtx + local run = newDock("red", { allowedWrapper("mew_fixture", function(game, ctx) + seenGame = game + seenCtx = { + mapId = ctx.mapId, reason = ctx.reason, gameVersion = ctx.gameVersion, + x = ctx.x, y = ctx.y, + } + ctx.mapId, ctx.x, ctx.y = "MUTATED", -1, -1 + end) }) + T.eq(seenGame, run.game, "occupancy callback receives the live game explicitly") + T.same(seenCtx, { + mapId = "VERMILION_DOCK", reason = "ss_anne_departed", + gameVersion = "red", x = 14, y = 2, + }, "occupancy context is the exact detached Red dock snapshot") + T.eq(run.ow.map.id, "VERMILION_DOCK", "context mutation cannot change the map") + T.eq(run.ow.player.cellX, 14, "context mutation cannot change player X") + T.eq(run.ow.player.cellY, 2, "context mutation cannot change player Y") + T.eq(run.save.marker, "save-must-not-change", "permission check does not mutate save") + T.same(run.save, { + version = "red", flags = { EVENT_SS_ANNE_LEFT = true }, + marker = "save-must-not-change", + }, "permission check preserves the complete save snapshot") + T.eq(#run.pushes, 0, "an exact true suppresses the vanilla rejection dialog") + T.eq(#run.warps, 0, "an exact true permits post-departure dock occupancy") + T.eq(run.blocks["5,1"], 1, "permitted occupancy still erases the departed ship") + T.eq(run.rebuilds, 1, "permitted occupancy still rebuilds the water layout") +end + +-- Standard hook composition also means a non-cooperative false/no-next +-- wrapper can suppress downstream claims. This remains safe because false +-- is denial; it cannot accidentally grant occupancy. +do + local downstreamCalls = 0 + local run = newDock("red", { + { + owner = "denier", priority = 10, + fn = function() return false end, + }, + { + owner = "unreached_claimant", priority = 0, + fn = function() + downstreamCalls = downstreamCalls + 1 + return true + end, + }, + }) + T.eq(downstreamCalls, 0, "no-next denial suppresses downstream by hook semantics") + T.eq(run.warps[1] and run.warps[1][1], "VERMILION_CITY", + "non-cooperative false remains fail-closed") +end + +-- Cooperative peers all run through next(). A lower-priority peer claim is +-- preserved by a higher-priority peer that has no claim of its own. +do + local calls, contextIdentity = {}, nil + local run = newDock("blue", { + { + owner = "peer_high", priority = 10, + fn = function(nextFn, game, ctx) + calls[#calls + 1] = "high-before" + contextIdentity = ctx + local allowed = nextFn(game, ctx) + calls[#calls + 1] = "high-after" + return allowed == true or false + end, + }, + { + owner = "peer_low", priority = 0, + fn = function(nextFn, game, ctx) + calls[#calls + 1] = "low" + T.eq(ctx, contextIdentity, "peer wrappers share one detached snapshot instance") + local allowed = nextFn(game, ctx) + local ownClaim = true + return allowed == true or ownClaim == true + end, + }, + }) + T.same(calls, { "high-before", "low", "high-after" }, + "multiple peer handlers preserve hook-chain order") + T.eq(#run.warps, 0, "a cooperative peer claim survives the whole chain") +end + +-- Absent, throwing, or malformed callbacks fail closed. Only boolean true +-- can turn off ejection; truthy strings/tables/numbers do not grant access. +do + local malformed = { + { label = "nil", value = nil }, + { label = "false", value = false }, + { label = "string", value = "yes" }, + { label = "number", value = 1 }, + { label = "table", value = {} }, + } + for _, case in ipairs(malformed) do + local run = newDock("red", { { + owner = "malformed_" .. case.label, + fn = function() return case.value end, + } }) + T.eq(run.warps[1] and run.warps[1][1], "VERMILION_CITY", + "malformed " .. case.label .. " permission fails closed") + end + + local beforeNext = newDock("red", { { + owner = "throws_before_next", + fn = function() error("fixture throws before next", 0) end, + } }) + T.eq(beforeNext.warps[1] and beforeNext.warps[1][1], "VERMILION_CITY", + "throwing callback before next fails closed") + + local afterNext = newDock("red", { { + owner = "throws_after_next", + fn = function(nextFn, game, ctx) + nextFn(game, ctx) + error("fixture throws after next", 0) + end, + } }) + T.eq(afterNext.warps[1] and afterNext.warps[1][1], "VERMILION_CITY", + "throwing callback after next keeps the downstream denial") +end + +-- The context carries one version only. A Red-only claimant must not leak +-- access into Blue or Yellow, and each call gets its own snapshot. +do + local contexts = {} + for _, version in ipairs(VERSIONS) do + local run = newDock(version, { { + owner = "red_only", + fn = function(nextFn, game, ctx) + contexts[#contexts + 1] = ctx + local downstream = nextFn(game, ctx) + return downstream == true or ctx.gameVersion == "red" + end, + } }) + T.eq(#run.warps == 0, version == "red", + version .. " occupancy is decided only by its own version context") + end + T.eq(contexts[1].gameVersion, "red", "Red context stays Red") + T.eq(contexts[2].gameVersion, "blue", "Blue context stays Blue") + T.eq(contexts[3].gameVersion, "yellow", "Yellow context stays Yellow") + T.check(contexts[1] ~= contexts[2] and contexts[2] ~= contexts[3], + "Red, Blue, and Yellow calls do not share context tables") +end + +-- Removing the owner is the engine's disable/uninstall path. It restores +-- vanilla denial immediately and leaves no save flag or serialized state. +do + local run = newDock("yellow", { allowedWrapper("removable") }) + T.eq(#run.warps, 0, "installed owner may grant Yellow dock occupancy") + run.hooks:removeOwner("removable") + local pushes, warps = {}, {} + run.game.stack.push = function(_, value) pushes[#pushes + 1] = value end + run.ow.startWarpTo = function(_, ... ) warps[#warps + 1] = { ... } end + story.VERMILION_DOCK.onEnter(run.game, run.ow) + if pushes[1] and pushes[1].done then pushes[1].done() end + T.eq(warps[1] and warps[1][1], "VERMILION_CITY", + "disabling the owner restores vanilla ejection") + T.eq(run.hooks.chains["map.occupancy_allowed"], nil, + "uninstall removes the occupancy chain itself") + T.eq(run.save.marker, "save-must-not-change", + "disable/uninstall writes no persistent permission state") + T.same(run.save, { + version = "yellow", flags = { EVENT_SS_ANNE_LEFT = true }, + marker = "save-must-not-change", + }, "disable/uninstall preserves the complete Yellow save snapshot") +end + +-- activeBlockAt is read-only and fail-closed. A successful call exposes +-- only one scalar from the active runtime layout, never its backing table. +local function blockApi(version, blockAt) + local backing = { 4, 5, 6, 8, 9, 10 } + local map = { + id = "VERMILION_DOCK", + def = { width = 3, height = 2, blocks = backing }, + blockAt = blockAt or function(_, bx, by) + return backing[by * 3 + bx + 1] + end, + } + local world = { isOverworld = true, map = map } + local game = { + save = { version = version }, + stack = { states = { world } }, + overworld = world, + } + return WorldAPI.new(game, "contract_fixture"), backing, game, map +end + +do + for _, version in ipairs(VERSIONS) do + local api, backing = blockApi(version) + local block, err = api:activeBlockAt("VERMILION_DOCK", 1, 0) + T.eq(block, 5, version .. " reads its active dock block") + T.eq(err, nil, version .. " valid active block has no error") + block = 99 + T.eq(backing[2], 5, version .. " scalar result cannot mutate the map") + end + + local api = blockApi("red") + local wrong, wrongErr = api:activeBlockAt("VERMILION_CITY", 1, 0) + T.eq(wrong, nil, "wrong map has no block result") + T.eq(wrongErr, "map is not active", "wrong map fails closed explicitly") + + local invalid = { + { "nil x", nil, 0 }, { "string x", "1", 0 }, { "table x", {}, 0 }, + { "fraction x", 0.5, 0 }, { "negative infinity x", -math.huge, 0 }, + { "infinity y", 0, math.huge }, { "NaN y", 0, 0 / 0 }, + } + for _, case in ipairs(invalid) do + local value, err = api:activeBlockAt("VERMILION_DOCK", case[2], case[3]) + T.eq(value, nil, case[1] .. " returns no block") + T.eq(err, "invalid block coordinates", case[1] .. " is rejected by type") + end + for _, coords in ipairs({ { -1, 0 }, { 0, -1 }, { 3, 0 }, { 0, 2 } }) do + local value, err = api:activeBlockAt("VERMILION_DOCK", coords[1], coords[2]) + T.eq(value, nil, "out-of-bounds coordinate returns no block") + T.eq(err, "block coordinates out of bounds", "bounds fail closed explicitly") + end +end + +do + local malformed = { + { label = "nil", get = function() return nil end }, + { label = "negative", get = function() return -1 end }, + { label = "fractional", get = function() return 1.5 end }, + { label = "infinite", get = function() return math.huge end }, + { label = "NaN", get = function() return 0 / 0 end }, + { label = "string", get = function() return "4" end }, + { label = "table", get = function() return {} end }, + { label = "throwing", get = function() error("bad map", 0) end }, + } + for _, case in ipairs(malformed) do + local api = blockApi("red", case.get) + local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0) + T.eq(block, nil, "malformed active block " .. case.label .. " returns no value") + T.eq(err, "block unavailable", + "malformed active block " .. case.label .. " fails closed") + end +end + + +-- Invalid map shapes are untrusted runtime data too. None may escape as a +-- block or raise through the mod facade. +do + local badDefs = { + { label = "missing def", value = nil }, + { label = "missing width", value = { height = 2, blocks = {} } }, + { label = "string width", value = { width = "3", height = 2, blocks = {} } }, + { label = "fractional width", value = { width = 1.5, height = 2, blocks = {} } }, + { label = "nonpositive width", value = { width = 0, height = 2, blocks = {} } }, + { label = "infinite height", value = { width = 3, height = math.huge, blocks = {} } }, + { label = "missing blocks", value = { width = 3, height = 2 } }, + { label = "scalar blocks", value = { width = 3, height = 2, blocks = 4 } }, + } + for _, case in ipairs(badDefs) do + local api, _, _, map = blockApi("red") + map.def = case.value + local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0) + T.eq(block, nil, case.label .. " returns no block") + T.eq(err, "block unavailable", case.label .. " fails closed") + end + + local api, _, _, map = blockApi("red") + map.blockAt = nil + local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0) + T.eq(block, nil, "missing blockAt returns no block") + T.eq(err, "block unavailable", "missing blockAt fails closed") + map.blockAt = "not a function" + block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0) + T.eq(block, nil, "malformed blockAt returns no block") + T.eq(err, "block unavailable", "malformed blockAt fails closed") + + api, _, _, map = blockApi("red") + map.def.blocks = {} + block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0) + T.eq(block, nil, "sparse stored block slot returns no block") + T.eq(err, "block unavailable", "sparse stored block slot fails closed") + + api, _, _, map = blockApi("red") + map.def.blocks[1] = "4" + block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0) + T.eq(block, nil, "malformed stored block slot returns no block") + T.eq(err, "block unavailable", "malformed stored block slot fails closed") + + api, _, _, map = blockApi("red", function() return 5 end) + block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0) + T.eq(block, nil, "stored/accessor mismatch returns no block") + T.eq(err, "block unavailable", "stored/accessor mismatch fails closed") +end + +do + local api = WorldAPI.new({ stack = { states = {} } }, "contract_fixture") + local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0) + T.eq(block, nil, "no-overworld lookup returns no block") + T.eq(err, "no overworld", "no-overworld lookup reports its state") +end + +Runtime.hooks = oldHooks +package.loaded["src.render.TextBox"] = oldTextBox +T.finish("mew dock seam contract") diff --git a/tests/engine/mod_developer_mode_parity_test.lua b/tests/engine/mod_developer_mode_parity_test.lua new file mode 100644 index 00000000..7876d93c --- /dev/null +++ b/tests/engine/mod_developer_mode_parity_test.lua @@ -0,0 +1,66 @@ +-- No-mod and API-v1 parity for the additive mod.developer surface. +-- +-- The production break this catches is a developer-mode loader path that +-- mutates vanilla data, creates mod state, or changes existing API-v1 +-- behavior merely because the new public signal exists. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +local function pristine() + return { + pokemon = { KEEP = { hp = 7 } }, + moves = {}, + } +end + +for _, dev in ipairs({ false, true }) do + local data = pristine() + local files = {} + local run = T.sdk.loadNone({ + data = data, + fs = T.sdk.memfs(files), + dev = dev, + }) + T.eq(#run.errors, 0, + "no-mod load stays clean with developer=" .. tostring(dev)) + T.eq(next(run.loader.mods), nil, + "no-mod load discovers nothing with developer=" .. tostring(dev)) + T.eq(data.pokemon.KEEP.hp, 7, + "no-mod load preserves vanilla data with developer=" .. tostring(dev)) + T.eq(next(files), nil, + "no-mod load creates no files with developer=" .. tostring(dev)) + run.release() +end + +local V1 = { + ["mods/v1_probe/manifest.json"] = [[{ + "id": "v1_probe", + "name": "V1 Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 1 + }]], + ["mods/v1_probe/main.lua"] = [[ + local mod = ... + mod.exports.identity = mod.id .. "@" .. mod.version + mod.exports.payload = mod:read("payload.txt") + mod.options:define({ { key = "enabled", type = "toggle", default = true } }) + mod.exports.defaultOption = mod.options:get("enabled") + ]], + ["mods/v1_probe/payload.txt"] = "unchanged-v1", +} + +local legacy = T.sdk.loadMods({ "mods/v1_probe" }, { + fs = T.sdk.memfs(V1), + dev = false, +}) +T.eq(#legacy.errors, 0, "existing API-v1 mod loads unchanged") +local out = legacy.loader.exports.v1_probe +T.eq(out.identity, "v1_probe@1.0.0", "API-v1 identity stays unchanged") +T.eq(out.payload, "unchanged-v1", "API-v1 mod:read stays unchanged") +T.eq(out.defaultOption, true, "API-v1 options stay unchanged") +legacy.release() + +T.finish("mod developer mode parity") diff --git a/tests/engine/mod_developer_mode_test.lua b/tests/engine/mod_developer_mode_test.lua new file mode 100644 index 00000000..982f9237 --- /dev/null +++ b/tests/engine/mod_developer_mode_test.lua @@ -0,0 +1,102 @@ +-- Public load-time developer-mode signal for sandboxed mods. +-- +-- The production break this catches is a loader that computes dev mode but +-- does not expose the same fixed answer to the public mod object before the +-- entry chunk runs. It also protects the data-only contract: the public +-- value is a boolean snapshot, not a live loader or environment handle. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +local FILES = { + ["mods/dev_probe/manifest.json"] = [[{ + "id": "dev_probe", + "name": "Developer Mode Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "games": ["all"] + }]], + ["mods/dev_probe/main.lua"] = [[ + local mod = ... + mod.exports.seenAtLoad = mod.developer + mod.exports.kind = type(mod.developer) + if mod.developer then + mod.commands:register("dev_probe:diagnostics", function() return true end) + end + ]], +} + +local function load(dev, generation) + return T.sdk.loadMods({ "mods/dev_probe" }, { + fs = T.sdk.memfs(FILES), + dev = dev, + generation = generation, + }) +end + +do + local run = load(true) + T.eq(#run.errors, 0, "developer-mode public probe loads clean") + local out = run.loader.exports.dev_probe + T.eq(out.seenAtLoad, true, + "sandboxed entry code sees developer mode at load time") + T.eq(out.kind, "boolean", "developer mode is exposed as plain data") + T.check(run.loader.content.commands:get("dev_probe:diagnostics") ~= nil, + "entry code can register diagnostics only in developer mode") + run.release() +end + +do + local run = load(false) + T.eq(#run.errors, 0, "production-mode public probe loads clean") + local out = run.loader.exports.dev_probe + T.eq(out.seenAtLoad, false, + "sandboxed entry code sees production mode at load time") + T.eq(out.kind, "boolean", "production mode is exposed as plain data") + T.eq(run.loader.content.commands:get("dev_probe:diagnostics"), nil, + "production load does not register developer diagnostics") + run.release() +end + +for _, provided in ipairs({ "yes", 1 }) do + local run = load(provided) + T.eq(#run.errors, 0, + "non-boolean developer-mode probe loads clean: " .. tostring(provided)) + local out = run.loader.exports.dev_probe + T.eq(out.seenAtLoad, false, + "non-boolean opts.dev is false in mod.developer: " .. tostring(provided)) + T.eq(out.kind, "boolean", + "non-boolean opts.dev stays a strict public boolean: " .. tostring(provided)) + T.eq(run.loader.dev, false, + "non-boolean opts.dev is false in loader.dev: " .. tostring(provided)) + T.eq(run.loader.content.commands:get("dev_probe:diagnostics"), nil, + "non-boolean opts.dev cannot register developer diagnostics: " .. tostring(provided)) + run.release() +end + +do + local run = load(true, 2) + T.eq(#run.errors, 0, "Gen 2 developer-mode public probe loads clean") + local out = run.loader.exports.dev_probe + T.eq(out.seenAtLoad, true, + "Gen 2 entry code sees the same developer-mode answer") + T.check(run.loader.content.commands:get("dev_probe:diagnostics") ~= nil, + "Gen 2 entry code can gate diagnostics on the same signal") + run.release() +end + +do + local saved = _G.POKEPORT_DEV_MODE + _G.POKEPORT_DEV_MODE = true + local ok, run = pcall(load, nil) + _G.POKEPORT_DEV_MODE = saved + if not ok then error(run, 0) end + T.eq(#run.errors, 0, "command-line developer-mode probe loads clean") + T.eq(run.loader.exports.dev_probe.seenAtLoad, true, + "the --developer boot decision reaches the public signal") + run.release() +end + +T.finish("mod developer mode public API") diff --git a/tests/engine/quit_thread_shutdown.lua b/tests/engine/quit_thread_shutdown.lua index a0dff35b..4f8a3589 100644 --- a/tests/engine/quit_thread_shutdown.lua +++ b/tests/engine/quit_thread_shutdown.lua @@ -182,11 +182,20 @@ check(checkSrc:match('cmd%.cmd == "quit"%s*then%s*\n%s*break') ~= nil, local mainSrc = source("main.lua") local quitHook = mainSrc:match("\nfunction love%.quit%(%).-\nend\n") check(quitHook ~= nil, "love.quit is still a single top-level function") -quitHook = quitHook or "" -check(quitHook:find('package.loaded["src.core.ChipAudio"].shutdown', 1, true) ~= nil, - "love.quit shuts the chip worker down") -check(quitHook:find('package.loaded["src.update.Check"].shutdown', 1, true) ~= nil, - "love.quit shuts the update worker down") +check(mainSrc:find("SessionLifecycle.endProcess()", 1, true) ~= nil, + "love.quit shuts workers down via SessionLifecycle.endProcess") + +local lifecycleSrc = source("src/core/SessionLifecycle.lua") +check(lifecycleSrc:find("registerProcessShutdown", 1, true) ~= nil, + "SessionLifecycle exposes registerProcessShutdown") +check(lifecycleSrc:find("function SessionLifecycle.endProcess()", 1, true) ~= nil, + "SessionLifecycle.endProcess fans out registered hooks") +check(source("src/core/ChipAudio.lua"):find("registerProcessShutdown(ChipAudio.shutdown)", 1, true) ~= nil, + "ChipAudio registers its shutdown hook at load") +check(source("src/update/Check.lua"):find("registerProcessShutdown(Check.shutdown)", 1, true) ~= nil, + "Check registers its shutdown hook at load") +check(source("src/net/Fetch.lua"):find("registerProcessShutdown(Fetch.shutdown)", 1, true) ~= nil, + "Fetch registers its shutdown hook at load") -- The Android half: LOVE keeps the JVM process after the native main returns, -- so the quit event exits the process outright. It has to sit after the diff --git a/tests/engine/rom_cache_contract_test.lua b/tests/engine/rom_cache_contract_test.lua new file mode 100644 index 00000000..f5f5102d --- /dev/null +++ b/tests/engine/rom_cache_contract_test.lua @@ -0,0 +1,176 @@ +-- The cache contract is the shared Lua-side publication boundary. A writer +-- may stage outputs in any order, but readiness is published only after the +-- version-specific required set exists. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +local eq = T.eq +local CacheContract = require("src.import.CacheContract") + +local fs = { prefix = "initial/", files = {} } +local writes = {} +function fs.exists(path) + return fs.files[fs.prefix .. path] ~= nil +end +function fs.read(path) + return fs.files[fs.prefix .. path] +end +function fs.write(path, value) + writes[#writes + 1] = fs.prefix .. path + fs.files[fs.prefix .. path] = value + return true +end +function fs.remove(path) + fs.files[fs.prefix .. path] = nil +end + +local required, isOverride = CacheContract.requiredFilesFor("red") +check(not isOverride, "Red uses the shared required-file list") +check(#required > 0, "Red has required outputs") +eq(CacheContract.markerFor("red"), + CacheContract.FORMAT .. "ea9bcae617fdf159b045185467ae58b2e4a48b9a", + "marker contains format and Red SHA-1") + +for index = 1, #required - 1 do + fs.files["red/" .. required[index]] = true +end +local complete, missing = CacheContract.allRequiredFilesExist("red", fs) +check(not complete, "missing output keeps cache incomplete") +eq(missing, required[#required], "missing output is reported") +local published, publishError = CacheContract.publish("red", fs) +check(not published, "incomplete cache is not published") +check(publishError ~= nil, "incomplete publication explains the missing output") +check(fs.files["red/" .. CacheContract.MARKER_PATH] == nil, + "incomplete cache has no completion marker") + +-- Publication must remove a stale marker left by an interrupted replacement, +-- and must restore the caller prefix on both the success and failure paths. +fs.files["red/" .. CacheContract.MARKER_PATH] = "old-marker" +local removedMarker = CacheContract.publish("red", fs) +check(not removedMarker, "incomplete retry is still rejected") +check(fs.files["red/" .. CacheContract.MARKER_PATH] == nil, + "incomplete retry removes a stale completion marker") +eq(fs.prefix, "initial/", "incomplete publication restores the caller prefix") + +fs.files["red/" .. required[#required]] = true +fs.prefix = "caller/prefix/" +fs.files["red/" .. required[#required]] = true +for _, path in ipairs(required) do fs.files["red/" .. path] = true end +published, publishError = CacheContract.publish("red", fs) +check(published, "complete cache is published") +eq(publishError, nil, "complete publication has no error") +eq(fs.prefix, "caller/prefix/", "publication restores the caller prefix") +eq(fs.files["red/" .. CacheContract.MARKER_PATH], CacheContract.markerFor("red"), + "marker is written under the version prefix") +local marker = CacheContract.readMarker("red", fs) +eq(marker, CacheContract.markerFor("red"), "marker reads through the version prefix") +eq(writes[#writes], "red/" .. CacheContract.MARKER_PATH, + "the marker is the only publication write and comes last") + +-- Every supported version gets its own marker and complete cache semantics; +-- Yellow adds its three outputs, while Gold/Silver replace the Gen 1 set. +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do + local versionFiles, override = CacheContract.requiredFilesFor(version) + for _, path in ipairs(versionFiles) do + fs.files[version .. "/" .. path] = true + end + if not override then + for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES[version] or {}) do + fs.files[version .. "/" .. path] = true + end + end + local ready, missing = CacheContract.allRequiredFilesExist(version, fs) + check(ready, version .. " complete cache is ready (" .. tostring(missing) .. ")") + local didPublish = CacheContract.publish(version, fs) + check(didPublish, version .. " complete cache publishes") + eq(fs.files[version .. "/" .. CacheContract.MARKER_PATH], + CacheContract.markerFor(version), version .. " marker is version-scoped") + eq(fs.prefix, "caller/prefix/", version .. " publication restores prefix") + check(CacheContract.isReady(version, fs), version .. " complete cache is ready") +end + +local gold, goldOverride = CacheContract.requiredFilesFor("gold") +check(goldOverride, "Gold uses a version-specific required set") +local goldSet = {} +for _, path in ipairs(gold) do goldSet[path] = true end +check(goldSet["assets/generated/battle/hud/balls.png"], + "Gold required set includes trainer HUD art") +check(not goldSet["assets/generated/trade/game_boy.png"], + "Gold required set excludes Gen 1 trade art") +check(goldSet["data/generated/rom_text.lua"], + "Gold required set includes the Gen 2 engine text table") +local silver = CacheContract.requiredFilesFor("silver") +local silverSet = {} +for _, path in ipairs(silver) do silverSet[path] = true end +check(silverSet["data/generated/rom_text.lua"], + "Silver required set includes the Gen 2 engine text table") +check(not silverSet["assets/generated/trade/game_boy.png"], + "Silver required set excludes Gen 1 trade art") +check(CacheContract.VERSION_REQUIRED_FILES.yellow ~= nil, + "Yellow has version-specific required outputs") + +-- A throwing adapter must not strand the process in its temporary prefix. +local throwingFs = { prefix = "before/" } +function throwingFs.exists() error("probe failed") end +local probed, probeError = CacheContract.allRequiredFilesExist("blue", throwingFs) +check(not probed and probeError ~= nil, "filesystem probe errors are returned") +eq(throwingFs.prefix, "before/", "probe errors restore the caller prefix") +function throwingFs.write() error("write failed") end +function throwingFs.remove() end +for _, path in ipairs(CacheContract.REQUIRED_FILES) do + throwingFs.files = throwingFs.files or {} + throwingFs.files["blue/" .. path] = true +end +function throwingFs.exists(path) + return throwingFs.files[throwingFs.prefix .. path] ~= nil +end +local wrote = CacheContract.publish("blue", throwingFs) +check(not wrote, "write errors are returned") +eq(throwingFs.prefix, "before/", "write errors restore the caller prefix") + +-- Source-tree readiness must use the same version lists and reject a cache +-- when LÖVE cannot identify a real source directory. +local oldLove = love +love = nil +check(not CacheContract.sourceTreeHasData("red"), + "source-tree check is safe without LÖVE") +local sourceFiles = {} +love = { + filesystem = { + getRealDirectory = function(path) return sourceFiles[path] end, + getSource = function() return "/source" end, + getInfo = function(path) + return sourceFiles[path] and { type = "file" } or nil + end, + }, +} +for _, path in ipairs(CacheContract.REQUIRED_FILES) do sourceFiles[path] = "/source" end +check(CacheContract.sourceTreeHasData("red"), + "Red source tree uses the shared required set") +sourceFiles[CacheContract.REQUIRED_FILES[2]] = "/save" +check(not CacheContract.sourceTreeHasData("red"), + "source-tree readiness rejects a cache-overlaid required file") +sourceFiles = {} +local goldFiles = CacheContract.requiredFilesFor("gold") +for _, path in ipairs(goldFiles) do sourceFiles["gold/" .. path] = "/source" end +check(CacheContract.sourceTreeHasData("gold"), + "Gold source tree uses its override set") +love = oldLove + +-- Both importer completion paths must call the shared publication boundary. +local importerFile = assert(io.open("src/import/RomImporter.lua", "r")) +local importerSource = importerFile:read("*a") +importerFile:close() +local completionCalls = 0 +for _ in importerSource:gmatch("CacheContract%.publish%(%s*version") do + completionCalls = completionCalls + 1 +end +eq(completionCalls, 1, + "both thread and coroutine paths converge on one publishing helper") +check(importerSource:find("self:_completeImport%(version, prefix, displayName%)") + ~= nil, "coroutine completion uses the shared helper") +check(importerSource:find("pcall%(self%._completeImport") ~= nil, + "thread completion uses the shared helper") + +T.finish("rom cache contract") diff --git a/tests/engine/rom_importer_source_tree_test.lua b/tests/engine/rom_importer_source_tree_test.lua index 508ba043..4ac595cb 100644 --- a/tests/engine/rom_importer_source_tree_test.lua +++ b/tests/engine/rom_importer_source_tree_test.lua @@ -1,32 +1,34 @@ --- sourceTreeHasData must use each version's required-file list. Gold's --- cache has no Gen 1 trade art / pikachu.png; validating it against --- REQUIRED_FILES made a Gold source tree look incomplete forever. +-- sourceTreeHasData must use the engine-owned cache contract. Gold's cache has +-- no Gen 1 trade art; validating it against the Gen 1 list made a Gold source +-- tree look incomplete forever. package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.harness") local check = T.check +local CacheContract = require("src.import.CacheContract") local f = assert(io.open("src/import/RomImporter.lua", "r")) local src = f:read("*a") f:close() -local start = src:find("local function sourceTreeHasData", 1, true) -check(start ~= nil, "sourceTreeHasData is defined") -local finish = src:find("\nfunction RomImporter.isReady", start, true) -check(finish ~= nil, "sourceTreeHasData ends before isReady") -local body = src:sub(start, finish) +local readyStart = src:find("function RomImporter.isReady", 1, true) +check(readyStart ~= nil, "isReady is defined") +local readyEnd = src:find("\nfunction RomImporter.syncAndroidShortcuts", readyStart, true) +check(readyEnd ~= nil, "isReady ends before the next importer helper") +local readyBody = src:sub(readyStart, readyEnd) -check(body:find("requiredFilesFor", 1, true) ~= nil, - "sourceTreeHasData uses requiredFilesFor (Gold override, not Gen 1 only)") -check(body:find("ipairs(REQUIRED_FILES)", 1, true) == nil, - "sourceTreeHasData does not iterate the Gen 1 REQUIRED_FILES list raw") +check(readyBody:find("CacheContract.isReady", 1, true) ~= nil, + "isReady delegates source-tree and cache readiness to the contract") +check(readyBody:find("ipairs(REQUIRED_FILES)", 1, true) == nil, + "isReady does not iterate the Gen 1 REQUIRED_FILES list raw") -local helperStart = src:find("local function requiredFilesFor", 1, true) -check(helperStart ~= nil, "requiredFilesFor helper exists") -local helper = src:sub(helperStart, start) -check(helper:find("VERSION_REQUIRED_FILES_OVERRIDE", 1, true) ~= nil, - "requiredFilesFor consults VERSION_REQUIRED_FILES_OVERRIDE") -check(src:find('"assets/generated/battle/hud/balls.png"', 1, true) ~= nil, +local required, isOverride = CacheContract.requiredFilesFor("gold") +check(isOverride, "Gold uses the override required-file list") +local requiredSet = {} +for _, path in ipairs(required) do requiredSet[path] = true end +check(requiredSet["assets/generated/battle/hud/balls.png"], "Gold caches require the trainer HUD ball sheet") +check(not requiredSet["assets/generated/trade/game_boy.png"], + "Gold does not inherit the Gen 1 trade-art requirement") T.finish() diff --git a/tests/engine/trade_art_import.lua b/tests/engine/trade_art_import.lua index 71736adf..28cbed6f 100644 --- a/tests/engine/trade_art_import.lua +++ b/tests/engine/trade_art_import.lua @@ -84,15 +84,15 @@ if extractor then end -- a cache imported before #750 has none of the art; listing one of the --- files in REQUIRED_FILES is what makes it re-import -local importer = readFile("src/import/RomImporter.lua") -T.check(importer ~= nil, "src/import/RomImporter.lua is readable") -if importer then - local required = importer:match("local REQUIRED_FILES = {(.-)\n}") - T.check(required ~= nil, "REQUIRED_FILES parses") +-- files in the engine-owned cache contract is what makes it re-import +local contract = readFile("src/import/CacheContract.lua") +T.check(contract ~= nil, "src/import/CacheContract.lua is readable") +if contract then + local required = contract:match("CacheContract.REQUIRED_FILES = {(.-)\n}") + T.check(required ~= nil, "CacheContract.REQUIRED_FILES parses") T.check(required ~= nil and required:find( '"assets/generated/trade/game_boy.png"', 1, true) ~= nil, - "REQUIRED_FILES makes pre-#750 caches re-import the trade art") + "cache contract makes pre-#750 caches re-import the trade art") end T.finish("trade art import") diff --git a/tests/mod_graphics_tests.lua b/tests/mod_graphics_tests.lua index 1df7cb49..8c56df3d 100644 --- a/tests/mod_graphics_tests.lua +++ b/tests/mod_graphics_tests.lua @@ -216,8 +216,12 @@ for name, module in pairs({ Assets = Assets, TileRenderer = TileRenderer, end check(type(require("src.world.MapLoader").invalidateAll) == "function", "MapLoader keeps its wave-1 invalidateAll") +check(type(require("src.world.MapLoader").releaseAll) == "function", + "MapLoader exposes releaseAll for session end") check(type(require("src.core.Sound").invalidate) == "function", "Sound keeps its wave-1 invalidate") +check(type(Assets.releaseSession) == "function", + "Assets exposes releaseSession for in-process session end") -- the central cache hands back one image per resolved path, and flush -- fans out to every registered downstream cache @@ -238,6 +242,14 @@ Assets.register(function() reached = true end) Assets.flush() check(reached, "a throwing invalidator does not stop the fan-out") +-- flush/invalidate must not run release hooks (HotReload / live overworld safe) +local releaseCalls = 0 +Assets.register({ release = function() releaseCalls = releaseCalls + 1 end }) +Assets.flush() +check(releaseCalls == 0, "flush() does not call release hooks") +Assets.releaseSession() +check(releaseCalls == 1, "releaseSession() calls registered release hooks") + -- ------- animated tiles as tileset data local overworld = TileRenderer.defaultAnimatedTiles( diff --git a/tests/modkit/cases/battle_field_residual.lua b/tests/modkit/cases/battle_field_residual.lua new file mode 100644 index 00000000..72097f19 --- /dev/null +++ b/tests/modkit/cases/battle_field_residual.lua @@ -0,0 +1,145 @@ +-- A sandboxed mod can contribute data-only field residual damage while the +-- engine retains HP, queue, and faint authority. The case also proves that +-- no-mod battles allocate no hook context and remain byte-equivalent. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.modkit") +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local TypeChart = require("src.battle.TypeChart") + +local FIXTURE = { + ["mods/field_residual_probe/manifest.json"] = [[{ + "id": "field_residual_probe", + "name": "Field Residual Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/field_residual_probe/main.lua"] = [[ + local mod = ... + mod.hooks:wrap("battle.field_residual", function(next, context) + mod.exports.calls = (mod.exports.calls or 0) + 1 + mod.exports.context = context + local callback = context.field.tokens[1] + and context.field.tokens[1].onExpire + mod.exports.callbackType = type(callback) + if callback then callback() end + local rows = next(context) + rows[#rows + 1] = { + side = "player", amount = 7, + message = context.battlers.player.name .. " is buffeted!", + } + rows[#rows + 1] = { + side = "enemy", amount = 999, + message = context.battlers.enemy.name .. " is buffeted!", + } + return rows + end) + ]], +} + +local function newBattle(data) + TypeChart.load(data) + local save = SaveData.newGame() + save.party = { Pokemon.new(data, "FIXMON_A", 30) } + local game = { + data = data, + save = save, + stack = { top = function() return nil end, push = function() end }, + } + local battle = BattleState.newWild(game, "FIXMON_C", 30) + battle.phase, battle.queue = "menu", {} + battle.field.weather = { id = "sand", turns = 4, source = "probe" } + return battle +end + +local vanilla = T.sdk.loadNone({}) +local plain = newBattle(vanilla.data) +local plainPlayerHp, plainEnemyHp = plain.player.mon.hp, plain.enemy.mon.hp +plain:endOfTurn() +T.eq(plain.player.mon.hp, plainPlayerHp, + "no-mod end of turn preserves player HP") +T.eq(plain.enemy.mon.hp, plainEnemyHp, + "no-mod end of turn preserves enemy HP") +T.eq(plain.field.weather.turns, 4, + "no-mod path does not reinterpret an unknown data-only field extension") +vanilla.release() + +local run = T.sdk.loadMods({ "mods/field_residual_probe" }, { + fs = T.sdk.memfs(FIXTURE), +}) +T.eq(#run.errors, 0, + "the public field-residual probe loads cleanly") +local battle = newBattle(run.data) +local playerHp, enemyHp = battle.player.mon.hp, battle.enemy.mon.hp +local callbackInvocations = 0 +battle.field.tokens[1] = { + id = "callback-bearing", turns = 2, + state = { intensity = 4 }, + onExpire = function() callbackInvocations = callbackInvocations + 1 end, +} +battle.player.invulnerable = true +battle:endOfTurn() + +local out = run.loader.exports.field_residual_probe or {} +T.eq(out.calls, 1, "the public hook runs exactly once at round end") +T.check(out.context.field ~= battle.field, + "the hook receives a detached checkpoint-shaped field view") +T.same(out.context.field.weather, + { id = "sand", turns = 4, source = "probe" }, + "the detached field view carries checkpointed weather state") +T.eq(out.context.field.sides, nil, + "the detached field view exposes no live battler aliases") +T.eq(out.callbackType, "nil", + "the sandboxed wrapper cannot obtain a live field callback") +T.eq(callbackInvocations, 0, + "the sandboxed wrapper cannot invoke the engine-owned callback") +T.same(out.context.field.tokens[1], { + id = "callback-bearing", turns = 2, state = { intensity = 4 }, +}, "the public field view retains data while omitting the callback") +T.eq(out.context.turn, battle.turnCount or 0, + "the hook receives the current turn counter") +T.eq(out.context.battle, nil, + "the hook does not expose the live engine battle object") +T.eq(out.context.battlers.player.side, "player", + "the detached player snapshot identifies its side") +T.eq(out.context.battlers.enemy.side, "enemy", + "the detached enemy snapshot identifies its side") +T.eq(out.context.battlers.player.vanished, true, + "the detached view reports Gen1 semi-invulnerability") +T.eq(out.context.battlers.player.hp, playerHp, + "the player snapshot carries pre-residual HP") +T.eq(out.context.battlers.enemy.hp, enemyHp, + "the enemy snapshot carries pre-residual HP") +T.check(out.context.battlers.player ~= battle.player, + "the public battler view is detached from the engine wrapper") +T.check(out.context.battlers.player.types ~= battle.player.curTypes, + "the public type list is detached") + +T.eq(battle.player.mon.hp, playerHp - 7, + "the engine applies the validated player residual amount") +T.eq(battle.enemy.mon.hp, 0, + "the engine clamps residual damage to current HP") +T.eq(battle.enemy.faintQueued, true, + "the engine, not the mod, owns residual faint orchestration") + +local sawPlayerMessage, sawEnemyMessage, drains = false, false, 0 +for _, row in ipairs(battle.queue) do + local text = row.text and tostring(row.text) or "" + if text:find("buffeted", 1, true) then + if text:find(battle.player.name, 1, true) then sawPlayerMessage = true end + if text:find(battle.enemy.name, 1, true) then sawEnemyMessage = true end + end + if row.drain then drains = drains + 1 end +end +T.check(sawPlayerMessage and sawEnemyMessage, + "validated public messages enter the normal battle queue") +T.check(drains >= 2, + "residual HP changes use normal engine drain rows") +run.release() + +T.finish("battle.field_residual public seam") diff --git a/tests/modkit/cases/link_battle_ended.lua b/tests/modkit/cases/link_battle_ended.lua new file mode 100644 index 00000000..c27916fe --- /dev/null +++ b/tests/modkit/cases/link_battle_ended.lua @@ -0,0 +1,103 @@ +-- A sandboxed mod can read the outcome of a link battle -- who won, and the +-- lockstep party copies -- through the public event surface. +-- +-- The copies are the point. Cable rules leave the real party untouched, so +-- a mode built on link battles (a tournament ladder, a battle royale) has no +-- other way to learn what the fight cost; by the time the state unwinds the +-- battle object is gone. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.modkit") +local LinkState = require("src.link.LinkState") + +local FIXTURE = { + ["mods/outcome_probe/manifest.json"] = [[{ + "id": "outcome_probe", + "name": "Outcome Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/outcome_probe/main.lua"] = [[ + local mod = ... + mod.exports.seen = 0 + mod.events:on("link.battle_ended", function(ev) + mod.exports.seen = mod.exports.seen + 1 + mod.exports.result = ev.result + mod.exports.role = ev.role + mod.exports.peerName = ev.peerName + mod.exports.myLead = ev.myParty and ev.myParty[1] + mod.exports.theirLead = ev.theirParty and ev.theirParty[1] + end) + ]], +} + +-- a link session parked at the end of a battle: no transport, so update() +-- goes straight to the stage that reports the outcome +local function finishedSession(result, isHost) + local ls + ls = setmetatable({ + stage = "battleRunning", + isHost = isHost, + peerName = "BLUE", + net = nil, + battle = { + result = result, + playerParty = { { species = "RATTATA", hp = 3 } }, + enemyParty = { { species = "PIDGEY", hp = 0 } }, + }, + game = { input = {}, stack = { top = function() return ls end } }, + }, { __index = LinkState }) + -- the real exit unwinds the whole link stack; the event is what is under + -- test, so record the exit rather than run it + ls.exitWith = function() ls.exited = true end + return ls +end + +-- ------- no mod: the battle still ends, nothing observes it + +local Runtime = require("src.mods.Runtime") + +local vanilla = T.sdk.loadNone({}) +T.check(not Runtime.wants("link.battle_ended"), + "with nothing subscribed the event is not wanted, so no payload is built") +local quiet = finishedSession("win", true) +quiet:update(0) +T.check(quiet.exited, "with no mod loaded the finished battle still unwinds") +T.eq(quiet.battle, nil, "and lets go of the battle") +vanilla.release() + +-- ------- a mod reads the outcome and both party copies + +local run = T.sdk.loadMods({ "mods/outcome_probe" }, { fs = T.sdk.memfs(FIXTURE) }) +T.eq(#run.errors, 0, + "the public outcome probe loads clean (" .. tostring(run.errors[1]) .. ")") + +local session = finishedSession("win", true) +session:update(0) +local out = run.loader.exports.outcome_probe or {} +T.eq(out.seen, 1, "a finished link battle raises the event once") +T.eq(out.result, "win", "the outcome is reported") +T.eq(out.role, "host", "so is which side of the cable we were") +T.eq(out.peerName, "BLUE", "and who we played") +T.eq(out.myLead and out.myLead.species, "RATTATA", + "the lockstep copy of our party comes with it") +T.eq(out.myLead and out.myLead.hp, 3, + "carrying the damage the real party never took") +T.eq(out.theirLead and out.theirLead.species, "PIDGEY", + "and the copy of theirs") +T.check(session.exited, "the state unwinds afterwards, as it always did") +T.eq(session.battle, nil, "and the battle is released") + +-- the guest side says so +local guest = finishedSession("lose", false) +guest:update(0) +T.eq(run.loader.exports.outcome_probe.seen, 2, "the guest reports too") +T.eq(run.loader.exports.outcome_probe.role, "guest", "as the guest") +T.eq(run.loader.exports.outcome_probe.result, "lose", "with its own outcome") + +run.release() + +T.finish("link_battle_ended") diff --git a/tests/modkit/cases/world_talk.lua b/tests/modkit/cases/world_talk.lua new file mode 100644 index 00000000..7ae3c73f --- /dev/null +++ b/tests/modkit/cases/world_talk.lua @@ -0,0 +1,100 @@ +-- A sandboxed mod can claim the A press on an object it owns, using only +-- public mod surfaces, and an unhooked build still talks to it as before. +-- +-- The seam exists because a runtime object (WorldAPI:spawnNpc) carries no +-- TEXT_* id: the vanilla talk path has nothing to say for one, so a mod that +-- spawned it has to be able to answer instead. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.modkit") +local OverworldState = require("src.world.OverworldController") + +local FIXTURE = { + ["mods/talk_probe/manifest.json"] = [[{ + "id": "talk_probe", + "name": "Talk Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/talk_probe/main.lua"] = [[ + local mod = ... + mod.hooks:wrap("world.talk", function(next, ow, target) + if target and target.claimedByMod then + mod.exports.claimed = target.id + return -- deliberately not calling next(): the mod answers instead + end + return next(ow, target) + end) + ]], +} + +-- enough of an overworld to reach the A press: a player facing one cell, an +-- object standing on it, and a map with no counter to talk across +local function fixtureOverworld(npc) + local ow + ow = setmetatable({ + npcs = { npc }, + player = { + facing = "up", + facingCell = function() return 4, 5 end, + }, + map = { + id = "FIX_ROUTE", + isCounterCell = function() return false end, + }, + talked = {}, + }, { __index = OverworldState }) + -- the vanilla destination, recorded rather than run: talkTo walks into the + -- map's text tables, which a fixture map does not have + ow.talkTo = function(_, target) ow.talked[#ow.talked + 1] = target.id end + return ow +end + +local function objectAt(id, claimed) + return { id = id, cellX = 4, cellY = 5, targetX = 4, targetY = 5, + moving = false, claimedByMod = claimed or nil, def = {} } +end + +-- ------- no mod: the A press lands in the vanilla talk path + +local vanilla = T.sdk.loadNone({}) +local plain = fixtureOverworld(objectAt("SIGNPOST_MAN")) +plain:interact() +T.eq(#plain.talked, 1, "with no mod loaded the A press reaches talkTo") +T.eq(plain.talked[1], "SIGNPOST_MAN", "and it is handed the object it faced") +vanilla.release() + +-- ------- a mod that owns the object answers for it + +local run = T.sdk.loadMods({ "mods/talk_probe" }, { fs = T.sdk.memfs(FIXTURE) }) +T.eq(#run.errors, 0, + "the public talk probe loads clean (" .. tostring(run.errors[1]) .. ")") + +local owned = fixtureOverworld(objectAt("GHOST_PLAYER", true)) +owned:interact() +local out = run.loader.exports.talk_probe or {} +T.eq(out.claimed, "GHOST_PLAYER", "a public hook sees the object it owns") +T.eq(#owned.talked, 0, + "and a hook that does not call next keeps the vanilla text path out of it") + +-- ...while everything the mod does not own falls straight through +local other = fixtureOverworld(objectAt("NURSE_JOY")) +other:interact() +T.eq(#other.talked, 1, "an object the mod does not claim still reaches talkTo") +T.eq(other.talked[1], "NURSE_JOY", "unchanged") + +-- an object mid-step is not talkable in either build (walk_npc.asm), so the +-- hook must not fire for one either +local walking = fixtureOverworld(objectAt("WALKER", true)) +walking.npcs[1].moving = true +walking:interact() +T.eq(run.loader.exports.talk_probe.claimed, "GHOST_PLAYER", + "an object mid-step raises no talk hook") +T.eq(#walking.talked, 0, "and reaches no talk path at all") + +run.release() + +T.finish("world_talk") diff --git a/tests/parity_double_faint.lua b/tests/parity_double_faint.lua index cb136229..01844892 100644 --- a/tests/parity_double_faint.lua +++ b/tests/parity_double_faint.lua @@ -22,6 +22,7 @@ package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end local BattleState = require("src.battle.BattleState") +local Runtime = require("src.mods.Runtime") local S = require("tests.harness").suite("parity double faint") local check, eq = S.check, S.eq @@ -107,4 +108,21 @@ do check(not saidBlackout(b), "and does not black out") end +-- enemyMonFainted is also a native authority path used by move effects. A +-- field-residual hook must not change what that path does when no hook is +-- installed, even if both active mons are already at zero HP. +do + Runtime.reset() + local b = battleWith({ 0 }, nil) + b.player = { mon = b.game.save.party[1] } + b.enemy = { mon = { hp = 0 } } + b.awards = 0 + b.awardExp = function(self) self.awards = self.awards + 1 end + BattleState.enemyMonFainted(b) + eq(b.awards, 1, + "no-hook simultaneous faint still enters native enemy EXP authority") + eq(b.result, "win", + "no-hook simultaneous faint preserves native enemy-faint resolution") +end + S.finish()