Files
gen1recomp/docs/rfcs/0013-map-occupancy-and-active-block.md
T
2026-08-23 03:20:43 -06:00

6.0 KiB

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:

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:

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.