mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-28 18:06:09 +02:00
49a5408c2d
A Pokémon's level is printed on four Gen 1 surfaces -- both battle healthboxes, the party rows, and both status pages -- and every one of them prints it unconditionally. There is no seam, so a mode that wants the number gone has two options today and both are bad: paint over the engine's own pixels from render.hud (four rectangles, a background shade to match, and the palette flashes and healthbox slide to survive), or monkey-patch the render modules from inside the sandbox, which works and is exactly what CONTRIBUTING-mods.md tells mods not to do. The motivating case is a battle royale that scales every party to a shared rung rising with its fog: the number is the same for everyone, it changes on a clock, and it reads as a threat it is not -- a Lv37 opponent looks dangerous to a player who has not worked out that their own team is Lv37 too. A randomizer keeping an encounter unreadable, a challenge run that forbids level-checking and a blind Nuzlocke want the same switch. New hook `pokemon.level_visible`, taking the shape the presentation predicates on the battle screen already use -- battle.status_hud_visible, battle.bottom_ui_visible, battle.caught_marker_visible: consulted behind Runtime.wantsHook, default visible, only an explicit false suppresses. It is not named battle.* because a level is not a battle-only readout, and it carries the surface that asked (battle.enemy / battle.player / party / summary) so a mode can hide an opponent's level and keep its own. src/ui/LevelDisplay.lua holds the one definition of "visible", so the four call sites are a one-line guard each rather than four copies of the same five lines that can drift apart. No layout moves. Each site keeps its own hand-rolled PrintLevel rule (home/pokemon.asm:335-345), it just asks first. Two details are deliberate: a status condition still replaces the level on a healthbox exactly as in the cart, so hiding a level never hides PSN or BRN (the guard is an elseif on the existing status branch); and on status page 2 the <to> arrow is hidden with the level it points at, because an arrow with nothing after it is half a sentence. Gen 1 only. The Gen 2 screens and the Gen 1 PC box list -- where the level is part of a row label rather than a drawn field -- keep their own readouts and do not consult the hook. Both are stated as follow-ups in the RFC and beside the hook in docs/modding.md, so a mod author reads the limit before depending on it. Verification: tests/modkit/cases/pokemon_level_visible.lua covers the contract through the public mod API; gate_hooks picks the hook up on its own because it walks the live catalog; gate_meta_coverage is satisfied by the change that introduces the seam, so it never enters the DEBT ledger. tests/run_modkit.lua 33/33, tests/run_engine.lua 327/331 -- the same four audio/hostshell suites fail unchanged on dev without this branch. Co-Authored-By: Claude <noreply@anthropic.com>
158 lines
6.8 KiB
Markdown
158 lines
6.8 KiB
Markdown
# RFC 0019: `pokemon.level_visible` — a level a mode can take off the screen
|
|
|
|
## Status
|
|
|
|
Proposed.
|
|
|
|
## Motivation
|
|
|
|
A Pokémon's level is printed on four Gen 1 surfaces — both battle
|
|
healthboxes, the party rows, and page 1 and page 2 of the status screen —
|
|
and every one of them prints it unconditionally. There is no seam. A mode
|
|
that wants the number gone has exactly two options today, and both are bad.
|
|
|
|
It can paint over the text from `render.hud`, which means knowing four pixel
|
|
rectangles, matching the background shade, and surviving the palette flashes
|
|
and the healthbox slide-in. Or it can monkey-patch the drawing modules from
|
|
inside its sandbox, which works — `require` hands a mod the engine's own
|
|
table — and is precisely what `CONTRIBUTING-mods.md` tells mods not to do.
|
|
A mod that took the second road already deleted its patching layer once, for
|
|
the reasons that document gives.
|
|
|
|
The motivating case is a battle royale where every party is scaled to a
|
|
shared rung that rises with the fog. The number on the healthbox is
|
|
therefore never news — it is the same for everyone, it changes on a clock,
|
|
and a player reading `:L37` on an opponent learns nothing except that they
|
|
are playing the same match. Worse, it reads as a threat it is not: a Lv37
|
|
opponent looks dangerous to a player who has not worked out that their own
|
|
team is Lv37 too. The mode wants the level off the HUD and out of the party
|
|
list, and the announcement it replaces it with is one line about everyone
|
|
getting stronger at once.
|
|
|
|
Nothing about that is specific to a battle royale. A randomizer that hides
|
|
levels to keep an encounter unreadable, a challenge run that forbids
|
|
level-checking, a hard mode that withholds an opponent's level, and a
|
|
"blind" Nuzlocke all want the same switch, and none of them should have to
|
|
learn where the `<LV>` glyph lives.
|
|
|
|
## The decision it extends
|
|
|
|
This extends the **additive, guarded seam convention** Route B in
|
|
`CONTRIBUTING-mods.md` documents, and is gated by the parity guarantee
|
|
`tests/engine/gate_meta_coverage.lua` enforces.
|
|
|
|
It sits with the presentation predicates already on the battle screen —
|
|
`battle.status_hud_visible`, `battle.bottom_ui_visible` and
|
|
`battle.caught_marker_visible` — and takes the same shape: a hook consulted
|
|
behind `Runtime.wantsHook`, defaulting to visible, where only an explicit
|
|
`false` suppresses. The difference is that a level is not a battle-only
|
|
readout, so this one is not named `battle.*` and carries the surface that
|
|
asked.
|
|
|
|
There is no in-repo D-number registry to amend.
|
|
|
|
## Exact API delta
|
|
|
|
### New hook: `pokemon.level_visible`
|
|
|
|
```lua
|
|
mod.hooks:wrap("pokemon.level_visible", function(next, mon, ctx)
|
|
-- ctx = { where = "battle.enemy" | "battle.player" | "party" | "summary",
|
|
-- game = <Game> }
|
|
if myMode.active and ctx.where ~= "party" then
|
|
return false -- the level is not printed on that surface
|
|
end
|
|
return next(mon, ctx) -- true: printed, as today
|
|
end)
|
|
```
|
|
|
|
Default `true`. Only an explicit `false` suppresses; `nil` and every other
|
|
value print, so a wrapper that forgets a branch cannot blank a screen by
|
|
accident.
|
|
|
|
`ctx.where` names the surface rather than the widget, because the number
|
|
means different things on different screens: on a battle HUD an opponent's
|
|
level is information about *them*, on the party and status screens your own
|
|
level is information about *you*. A mode that wants to hide the first and
|
|
keep the second can, and the motivating mode does exactly that.
|
|
|
|
### New module: `src/ui/LevelDisplay.lua`
|
|
|
|
One function, `LevelDisplay.visible(mon, where, game)`, wrapping the
|
|
`wantsHook`/`call` pair. It exists so the four call sites are a one-line
|
|
guard each instead of four copies of the same five lines, and so the hook
|
|
has one definition of "visible" rather than four that can drift.
|
|
|
|
### Call sites
|
|
|
|
| Surface | File | `where` |
|
|
| --- | --- | --- |
|
|
| Enemy healthbox | `src/battle/BattleState.lua` | `battle.enemy` |
|
|
| Player healthbox | `src/battle/BattleState.lua` | `battle.player` |
|
|
| Party rows | `src/ui/PartyMenu.lua` | `party` |
|
|
| Status page 1 and 2 | `src/ui/SummaryMenu.lua` | `summary` |
|
|
|
|
No layout moves. Each site keeps its own hand-rolled PrintLevel rule
|
|
(`home/pokemon.asm:335-345` — the `<LV>` tile, then the digits, with a level
|
|
of 100 writing its third digit back over the tile); it just asks first.
|
|
|
|
Two details are deliberate:
|
|
|
|
- **A status condition still replaces the level on a healthbox**, exactly as
|
|
it does in the cart, so hiding the level never hides `PSN` or `BRN`. The
|
|
guard is an `elseif` on the existing status branch, not a wrapper around
|
|
it.
|
|
- **On status page 2 the `<to>` arrow is hidden with the level it points
|
|
at.** The arrow introduces the next level; on its own it is half a
|
|
sentence. The EXP-to-next-level figure beside it is a different field and
|
|
still prints.
|
|
|
|
### No other surface changes
|
|
|
|
No event, no registry, no save field, no manifest key.
|
|
|
|
## Migration
|
|
|
|
None. The hook is additive and defaults to current behaviour.
|
|
|
|
## Verification
|
|
|
|
- `tests/modkit/cases/pokemon_level_visible.lua` — the contract through the
|
|
public mod API: default true with no mod, `false` suppresses, the surface
|
|
and the mon reach the hook, falling through prints, and a `nil` mon
|
|
answers rather than throwing.
|
|
- `tests/engine/gate_hooks.lua` — the structural parity gate picks the hook
|
|
up automatically, because it walks the live catalog rather than a list.
|
|
- `tests/engine/gate_meta_coverage.lua` — the coverage ratchet; the seam is
|
|
covered by name from the change that introduces it, so it never enters the
|
|
DEBT ledger.
|
|
|
|
## Backward compatibility
|
|
|
|
A build with no mod wrapping the hook never reaches `Runtime.call`:
|
|
`wantsHook` is checked first, which matters because two of the four sites
|
|
are on a per-frame draw path. Pixels are unchanged, and the parity gates
|
|
assert it.
|
|
|
|
## Scope, and what is deliberately not in this change
|
|
|
|
**Gen 1 only.** The Gen 2 screens keep their own level readouts
|
|
(`src/ui/gen2/PartyMenu.lua`, `SummaryMenu.lua`, `BoxMenu.lua`,
|
|
`HallOfFame.lua`) and do not consult the hook. So does the Gen 1 PC box list
|
|
(`src/ui/BoxMenu.lua`), where the level is baked into a row label string
|
|
rather than drawn as a field, and the box-to-PNG print path beside it.
|
|
|
|
Those are mechanical follow-ups, held back so this change stays reviewable
|
|
against screens that can actually be exercised here. The limitation is
|
|
stated in `docs/modding.md` beside the hook, so a mod author reads it before
|
|
depending on it rather than after.
|
|
|
|
## Compatibility seam for older engines
|
|
|
|
There is none, and none is possible without patching: the call sites are
|
|
mid-draw, so a mod on a stock engine cannot reach them. That is the argument
|
|
for the seam rather than a gap around it — the alternatives available to a
|
|
mod today are painting over the engine's own pixels or reaching into its
|
|
render modules, and the second is the thing the mod contract exists to
|
|
prevent.
|