Merge branch 'dev' into spidercar2

This commit is contained in:
bryanthaboi
2026-08-24 08:48:24 -04:00
61 changed files with 4280 additions and 563 deletions
+26 -7
View File
@@ -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)
+8 -2
View File
@@ -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
+202 -4
View File
@@ -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
+2 -1
View File
@@ -768,7 +768,8 @@ the same:
So: read the log for coverage problems, and the manager for load problems.
`POKEPORT_IDENTITY=<name>` 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
@@ -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.
@@ -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.
+123
View File
@@ -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.
+102
View File
@@ -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.
+16 -97
View File
@@ -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)
+1 -1
View File
@@ -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.
Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -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",
+106
View File
@@ -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
+3 -1
View File
@@ -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
-- ---------------------------------------------------------------------------
+43 -17
View File
@@ -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
+16
View File
@@ -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
+101
View File
@@ -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
+247
View File
@@ -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
+14 -247
View File
@@ -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))
+54 -15
View File
@@ -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
+53 -1
View File
@@ -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
+5 -1
View File
@@ -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 = {},
+3 -1
View File
@@ -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
+25 -2
View File
@@ -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/<id>/<rel>.
@@ -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.
+2
View File
@@ -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
+8
View File
@@ -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
+80 -4
View File
@@ -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
+32 -4
View File
@@ -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 <PK>/<MN>, 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
+11 -10
View File
@@ -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
+30 -14
View File
@@ -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: "<MON>'S" then "NICKNAME?".
Chrome.printThrough(self.monName .. "'S", 5, 2, pal)
Chrome.printThrough("NICKNAME?", 5, 4, pal)
-- Nickname header is two lines: "<MON>'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
+84 -52
View File
@@ -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
+3 -2
View File
@@ -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
+54 -12
View File
@@ -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)
+3 -1
View File
@@ -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
+15 -2
View File
@@ -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
+15 -1
View File
@@ -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
+108
View File
@@ -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
+31
View File
@@ -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.
+7 -2
View File
@@ -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")
@@ -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")
+2 -2
View File
@@ -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
@@ -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 <PK>/<MN> 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")
@@ -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 <name>/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")
@@ -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")
@@ -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")
@@ -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 <name>/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")
+94 -27
View File
@@ -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")
@@ -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")
+428
View File
@@ -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")
@@ -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")
+102
View File
@@ -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")
+14 -5
View File
@@ -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
+176
View File
@@ -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")
+20 -18
View File
@@ -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()
+7 -7
View File
@@ -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")
+12
View File
@@ -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(
@@ -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")
+103
View File
@@ -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")
+100
View File
@@ -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")
+18
View File
@@ -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()