Migration and Backward compatibility were two separate sections
saying the same thing about encounter.roll/encounter.species being
untouched, on top of a third mention in "The decision it extends" --
RFC 0014 and 0015 both fold this into one "Migration and
compatibility" section, so this does the same and drops the
redundant third restatement.
Also fixes the chance bullet's "the same reasoning that scoped
fishing out of this RFC" -- that reasoning was never actually stated
anywhere in the doc, just referenced as if it had been. Reworded to
stand on its own: encounter.fishing is a real, separate, existing
hook this RFC doesn't touch, which is reason enough on its own.
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>
Lets a mod ask what a map's wild-encounter odds look like right now,
composed with any live encounter.table wrapper, without rolling the RNG.
encounter.roll/encounter.species can only transform one draw; this is the
missing read side. Guarded query method (mod.world:effectiveEncounters),
no changes to the real roll path, docs and a public-API modkit case.
Two decisions the OPTION screen and the cart make for the player, made
hookable so a game mode can make them instead: whether a faint offers the
SHIFT free switch, and whether a catch asks for a nickname. Guarded call
sites, file-local vanilla links, docs and a public-API modkit case.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1DVpYXGabigHqMwviDoKV
A capture the party cannot hold silently falls through to the box; the
new partyFullDestination seam lets a mode claim custody at that moment
instead, and pokemon.caught reports destination "mod" so the mode can
find the mon again. Guarded call site, file-local vanilla, docs and a
public-API modkit case.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1DVpYXGabigHqMwviDoKV
Route B in CONTRIBUTING-mods.md asks an event/hook change for five things.
This adds the two that were missing and fixes what the third turned up.
link.battle_ended built its payload unconditionally. Route B is explicit
that a new event must not allocate when nothing wants it, and every other
emit in the engine already guards -- Runtime.wants now gates this one too,
so an unsubscribed build runs the branch exactly as it did.
world.talk was handing Runtime.call a closure built fresh on every A press,
purely to have a fallthrough to pass. It is a file-local now, so an unhooked
press allocates nothing it did not allocate before.
The RFC covers motivation, the API delta with call sites, migration (nothing
changes for existing mods), and verification. The backward-compatibility
statement is in it: every item is a new name or a new optional argument, and
example_mew_starter -- api 1, category = "GAMEPLAY", whole-species copy --
still loads, which run_modkit proves on every run.
No registry or schema field is added, so gen_registry_docs has nothing to
emit for this change. Running it does show pre-existing drift in
docs/modding/reference/registries.md (timeFishGroups, an objects refinement)
from earlier Schemas.lua edits that were never regenerated; that is not this
branch's to carry, so it is left alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
battle.exp_award hands a mod ctx.applyShare(mon, split, announce) on both
generations, and on Gen 1 the third argument decides whether the mon's
GainedText box is printed -- which is how a mod paying the whole party
prints ONE summary line instead of a box per recipient. Gold accepted
the argument and ignored it, so the same mod source printed one line on
Red and one per party member on Gold.
The Exp Share mod is the live case: it declares games gen1+gen2 and its
description promises "a single shared-exp line instead of one message per
Pokemon", passing true for the fighters and nil for the bench exactly as
the Gen 1 seam asks. On Gold every nil call announced anyway, so a
five-mon party turned every KO into six boxes. There was no mod-side
fix: the emit sits behind no hook, and the argument meaning "quietly" was
discarded.
Gold now reads it, and ONLY when it is actually passed -- by argument
count, not by value. select("#", ...) counts an explicit nil, so
applyShare(mon, split) is distinguishable from applyShare(mon, split,
nil); the first is a Gen 2-era call written against a seam that always
announced and keeps announcing, the second is a deliberate "pay this one
quietly" and is now silent on both games. No mod that exists today
changes behaviour, and a mod that passes the argument gets parity.
Only the { kind = "experience" } event is affected. A silent award is
still a whole award: exp, stat exp, battle.exp_gained, "grew to level",
learned moves and the forget prompt are untouched, in the same order.
giveExperiencePass takes a sixth `silent` parameter that defaults to
announcing, so both of the cart's own passes are unchanged.
RFC: docs/rfcs/0012-gen2-exp-award-announce.md
Docs: docs/mod-api-gen2-compat.md gains the reading and the residual
omitted-argument difference beside the existing payload note.
Tests: tests/gen2_exp_share_test.lua grows the two Route B tests -- the
no-mod parity case and the seam driven through hooks:wrap -- and
its 23 existing checks are unchanged.
The sandbox blocks love.system and love.filesystem, which orphans the
native step bridge (#452, #489): its one consumer can no longer call
syncHealthSteps or read steps_pending.json (#1186).
Adds a "steps" manifest permission (shown to the player like the
others) gating a mod.steps facade: available() probes the bridge
quietly, sync() forwards the async refresh, poll() hands the mod its
copy of a delivery. The engine owns the pending file -- mods never name
a path and receive only { steps, from, to }. Without the permission the
acting calls name it, following the network gate. No new events, hooks
or registries; nothing removed.
RFC 0009. Tests: tests/modkit/cases/steps_bridge.lua (no-mod cold
bridge, permissioned sync/poll, per-mod copies, contract-field
filtering, malformed-delivery drop, unpermissioned refusal, bridgeless
build).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GameSpeed is a single fast-forward multiplier applied uniformly to the
whole logic clock -- overworld walking, menu navigation and battle turns
all scale together. A player who wants 4X battles but 1X overworld (so a
cutscene or NPC dialogue doesn't blur past) has no way to get both.
Splits save.options.speed into speedOverworld/speedBattle/speedMenu, each
cycling independently, with an automatic migration so an existing save's
speed choice carries over. Game.speedCategoryInStack resolves which
category is active by walking the state stack (the same idiom
wideBattleInStack/fillScaleInStack already use), so a menu opened mid-
battle inherits battle speed rather than resetting to whatever "menu"
defaults to. Adds a new core.logic_speed hook so a mod can read or
override the resolved multiplier for the current frame regardless of
which category produced it, sitting after the link-play and run-argument
overrides so neither is a seam a mod can defeat.
RFC 0007 status: Proposed.
A platform-specific launcher wrapper (a native shell embedding this engine,
owning its own UI around the game window) needs to pause the simulation
while its own UI is on top, live-reload options it wrote outside any Lua
UI, and veto main.lua's "closing the window returns to the Lua launcher"
behavior when it owns that job itself. Implementing this by hand-patching
main.lua's love.update/love.quit directly ties every such integration to
editing the one file every other engine change also touches, guaranteeing
merge conflicts. No existing hook covers "should the per-frame simulation
step run" or "should closing the window return to the Lua launcher."
Adds two generic, additive hooks (src/core/PlatformHooks.lua): core.update
and core.quit_to_launcher, replacing what would otherwise be inline
main.lua special-casing. Also adds Manifest.force_enable_env, letting a
mod that cannot function disabled on the one build where its env var is
set (a platform-bridge mod bundled only with that build) re-enable itself
regardless of a saved disable.
RFC 0006 status: Proposed.