Merge pull request #1835 from campavao/rfc-0019-level-visible

engine: pokemon.level_visible, a level a mode can take off the screen (RFC 0019)
This commit is contained in:
bryanthaboi
2026-08-27 13:38:59 -04:00
committed by GitHub
7 changed files with 318 additions and 6 deletions
+31
View File
@@ -1111,6 +1111,37 @@ own state, preserving selective control outside a battle; a wrapper that only
owns battle presentation should return `false` only for its active battle or owns battle presentation should return `false` only for its active battle or
text-box state. text-box state.
`pokemon.level_visible` (RFC 0019) takes a Pokémon's level off the screens
that print it, without moving anything else on them:
```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 end
return next(mon, ctx)
end)
```
It receives `(next, mon, ctx)` and defaults to `true`, so vanilla rendering
is unchanged. Only an explicit `false` suppresses; `nil` and anything else
print, so a wrapper that forgets a branch cannot blank a screen by accident.
The `<LV>` glyph goes with the digits, and on status page 2 so does the
`<to>` arrow that points at the next level, because an arrow with nothing
after it is half a sentence. A status condition still replaces the level on
a battle healthbox exactly as it does in the cart, so hiding a level never
hides `PSN` or `BRN`.
`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 can hide one and keep the other.
**Gen 1 only for now.** The Gen 2 screens keep their own level readouts and
do not consult this hook, and neither does the Gen 1 PC box list, where the
level is part of a row label rather than a drawn field. Both are noted in
RFC 0019 as follow-ups.
`core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call `core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call
(once per frame). Vanilla behavior resolves the per-category GAME SPEED (once per frame). Vanilla behavior resolves the per-category GAME SPEED
option (`GameSpeed.CATEGORIES`: overworld/battle/menu) for whichever option (`GameSpeed.CATEGORIES`: overworld/battle/menu) for whichever
+157
View File
@@ -0,0 +1,157 @@
# 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.
+3 -2
View File
@@ -16,6 +16,7 @@ local Damage = require("src.battle.Damage")
local EffectRegistry = require("src.battle.EffectRegistry") local EffectRegistry = require("src.battle.EffectRegistry")
local Experience = require("src.battle.Experience") local Experience = require("src.battle.Experience")
local Font = require("src.render.Font") local Font = require("src.render.Font")
local LevelDisplay = require("src.ui.LevelDisplay")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local MoveEffects = require("src.battle.MoveEffects") local MoveEffects = require("src.battle.MoveEffects")
local Party = require("src.pokemon.Party") local Party = require("src.pokemon.Party")
@@ -6337,7 +6338,7 @@ function BattleState:drawHUDs(slide)
end end
if self.enemy.shownStatus then if self.enemy.shownStatus then
Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 40, 8) Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 40, 8)
else elseif LevelDisplay.visible(self.enemy.mon, "battle.enemy", self.game) then
hudTile(0x6E, 32, 8) -- <LV> hudTile(0x6E, 32, 8) -- <LV>
Font.draw(tostring(self.enemy.mon.level), 40, 8) Font.draw(tostring(self.enemy.mon.level), 40, 8)
end end
@@ -6421,7 +6422,7 @@ function BattleState:drawHUDs(slide)
Font.draw(self.player.name, nameX(10, self.player.name), 56) Font.draw(self.player.name, nameX(10, self.player.name), 56)
if self.player.shownStatus then if self.player.shownStatus then
Font.draw(self:statusLabel({ status = self.player.shownStatus }), 120, 64) Font.draw(self:statusLabel({ status = self.player.shownStatus }), 120, 64)
else elseif LevelDisplay.visible(self.player.mon, "battle.player", self.game) then
hudTile(0x6E, 112, 64) -- <LV> hudTile(0x6E, 112, 64) -- <LV>
Font.draw(tostring(self.player.mon.level), 120, 64) Font.draw(tostring(self.player.mon.level), 120, 64)
end end
+33
View File
@@ -0,0 +1,33 @@
-- Whether a Pokémon's level is printed on a screen that would normally
-- print it (RFC 0019).
--
-- Every Gen 1 screen that shows a level does it with the same pokered rule
-- -- home/pokemon.asm:335-345 PrintLevel: the <LV> tile, then the digits
-- left-aligned after it, with a level of 100 writing its third digit back
-- over the tile -- and each screen hand-rolls that rule against its own
-- coordinates. This module does not touch any of that. It answers one
-- question, in one place, so that a mode which wants the number off does
-- not have to know four call sites and a glyph code.
--
-- Default is true, and `Runtime.wantsHook` is checked first, so a build
-- with no mod wrapping the hook prints exactly what it always did and pays
-- nothing for the seam on a per-frame draw path.
--
-- `where` names the surface rather than the widget, because the number
-- means different things on different screens: on the 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 can hide one and
-- keep the other.
local Runtime = require("src.mods.Runtime")
local LevelDisplay = {}
-- where: "battle.enemy" | "battle.player" | "party" | "summary"
function LevelDisplay.visible(mon, where, game)
if not Runtime.wantsHook("pokemon.level_visible") then return true end
return Runtime.call("pokemon.level_visible", function() return true end,
mon, { where = where, game = game }) ~= false
end
return LevelDisplay
+6 -1
View File
@@ -11,6 +11,7 @@
local Assets = require("src.render.Assets") local Assets = require("src.render.Assets")
local Font = require("src.render.Font") local Font = require("src.render.Font")
local LevelDisplay = require("src.ui.LevelDisplay")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens") local Screens = require("src.ui.Screens")
@@ -794,7 +795,11 @@ function PartyMenu:draw()
-- level at column 13 (<LV> tile + digits, PrintLevel) AND the -- level at column 13 (<LV> tile + digits, PrintLevel) AND the
-- status/FNT text at column 17 (PrintStatusCondition), like the -- status/FNT text at column 17 (PrintStatusCondition), like the
-- original rows -- statused mons keep their level display -- original rows -- statused mons keep their level display
if mon.level < 100 then if not LevelDisplay.visible(mon, "party", self.game) then -- RFC 0019
-- the level column is simply empty; the status/FNT column at 17 is a
-- separate field and still prints, exactly as it does for a mon whose
-- level is on screen
elseif mon.level < 100 then
HudTiles.tile(0x6E, 104, y) -- <LV> HudTiles.tile(0x6E, 104, y) -- <LV>
Font.draw(tostring(mon.level), 112, y) Font.draw(tostring(mon.level), 112, y)
else else
+10 -3
View File
@@ -12,6 +12,7 @@ local Font = require("src.render.Font")
-- TypeChart.displayName maps it back to "PSYCHIC", like HallOfFame and the -- TypeChart.displayName maps it back to "PSYCHIC", like HallOfFame and the
-- battle move-type box already do (#214). -- battle move-type box already do (#214).
local TypeChart = require("src.battle.TypeChart") local TypeChart = require("src.battle.TypeChart")
local LevelDisplay = require("src.ui.LevelDisplay")
local Strings = require("src.core.Strings") local Strings = require("src.core.Strings")
local Stats = require("src.pokemon.Stats") local Stats = require("src.pokemon.Stats")
local Status = require("src.battle.Status") local Status = require("src.battle.Status")
@@ -137,7 +138,9 @@ function SummaryMenu:draw()
if self.page == 1 then if self.page == 1 then
-- level is page 1 only: StatusScreen2 opens with ClearScreenArea over -- level is page 1 only: StatusScreen2 opens with ClearScreenArea over
-- (9,2) 5x10 (status_screen.asm:303-305). #280 -- (9,2) 5x10 (status_screen.asm:303-305). #280
printLevel(14, 2, mon.level) if LevelDisplay.visible(mon, "summary", self.game) then -- RFC 0019
printLevel(14, 2, mon.level)
end
drawLineBox(19, 1, 6, 10) drawLineBox(19, 1, 6, 10)
-- engine/pokemon/status_screen.asm:120-125 -- engine/pokemon/status_screen.asm:120-125
local PaletteFX = require("src.render.PaletteFX") local PaletteFX = require("src.render.PaletteFX")
@@ -196,8 +199,12 @@ function SummaryMenu:draw()
local nextExp = mon.level < 100 local nextExp = mon.level < 100
and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0 and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0
Font.draw(("%7d"):format(math.max(0, nextExp)), 56, 48) Font.draw(("%7d"):format(math.max(0, nextExp)), 56, 48)
HudTiles.statusTile(0x70, 112, 48) -- '<to>' at (14,6), was missing (#280) if LevelDisplay.visible(mon, "summary", self.game) then -- RFC 0019
printLevel(16, 6, math.min(100, mon.level + 1)) -- the '<to>' arrow is half a sentence without the level it points at,
-- so the pair is hidden together
HudTiles.statusTile(0x70, 112, 48) -- '<to>' at (14,6), was missing (#280)
printLevel(16, 6, math.min(100, mon.level + 1))
end
Font.drawBox(0, 8, 20, 10) Font.drawBox(0, 8, 20, 10)
for i = 1, 4 do for i = 1, 4 do
local mv = mon.moves[i] local mv = mon.moves[i]
@@ -0,0 +1,78 @@
-- A sandboxed mod can take a Pokémon's level off the screens that print it
-- (pokemon.level_visible): the readout goes, the layout does not, and a
-- build with no mod wrapping the hook prints exactly what it always did.
--
-- The predicate is tested rather than the pixels: every Gen 1 level readout
-- goes through LevelDisplay.visible, and the four call sites are the same
-- one-line guard. What matters here is the contract -- default true, false
-- suppresses, the surface is named, and no-mod costs nothing.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.modkit")
local LevelDisplay = require("src.ui.LevelDisplay")
local FIXTURE = {
["mods/level_probe/manifest.json"] = [[{
"id": "level_probe",
"name": "Level Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/level_probe/main.lua"] = [[
local mod = ...
mod.exports.answer = nil
mod.exports.seen = nil
mod.hooks:wrap("pokemon.level_visible", function(next, mon, ctx)
mod.exports.seen = { mon = mon, where = ctx and ctx.where,
game = ctx and ctx.game }
if mod.exports.answer == nil then return next(mon, ctx) end
return mod.exports.answer
end)
]],
}
local MON = { species = "RATTATA", level = 42, moves = {} }
local GAME = { save = {} }
-- ------- no mod: the level is always printed
local vanilla = T.sdk.loadNone({})
T.eq(LevelDisplay.visible(MON, "battle.enemy", GAME), true,
"no mod: the enemy healthbox prints a level")
T.eq(LevelDisplay.visible(MON, "party", GAME), true,
"no mod: the party rows print a level")
T.eq(LevelDisplay.visible(nil, "summary", nil), true,
"no mod: even a nil mon answers true rather than throwing")
vanilla.release()
-- ------- a mod hides it
local run = T.sdk.loadMods({ "mods/level_probe" }, { fs = T.sdk.memfs(FIXTURE) })
T.eq(#run.errors, 0, "the level probe loads clean (" .. tostring(run.errors[1]) .. ")")
local probe = run.loader.exports.level_probe
probe.answer = false
T.eq(LevelDisplay.visible(MON, "battle.enemy", GAME), false,
"hidden: the enemy healthbox prints no level")
T.eq(probe.seen and probe.seen.where, "battle.enemy",
"the hook is told which surface asked")
T.check(probe.seen and probe.seen.mon == MON, "and which Pokémon")
T.check(probe.seen and probe.seen.game == GAME, "and the game")
-- the surface is what lets a mode hide an opponent's level and keep its own
probe.answer = nil
T.eq(LevelDisplay.visible(MON, "party", GAME), true,
"falling through prints, as today")
T.eq(probe.seen and probe.seen.where, "party", "and still names the surface")
-- only an explicit false suppresses: a mod returning nothing must not blank
-- a screen by accident
probe.answer = true
T.eq(LevelDisplay.visible(MON, "summary", GAME), true,
"an explicit true prints")
run.release()
T.finish("pokemon level visible")