Files
gen1recomp/docs/timing-parity.md
T
spiritsnails aedc63c40d Port timing/parity fixes, seamless battle transitions, faithful-res lock, and zoom-aware UI anchoring
Ports from a downstream fork, hand-surgered hunk-by-hunk to exclude the
fork's randomizer/pokescript work and to skip a FixedStep jitter-tolerance
attempt that never fixed the stutter it targeted.

- src/core/Timing.lua: hardware-accurate frame-delay catalog ported from
  pret/pokered, feeding BattleState:waitNext, EffectRegistry's miss/crit
  beats, TextBox/ChoiceBox scroll and prompt holds, and the battle
  silhouette slide/shake/blink/faint timings.
- Seamless battle transitions: Renderer:drawBattleWipe replaces the old
  160x144-only cascade with one wipe drawn over the whole surface at any
  zoom or window size; BattleTransition's per-style frame lengths are
  corrected against pokered-c's derivation; Transition.battleReturn adds
  the post-battle GBFadeInFromWhite the port never had.
- BATTLE SIZE / BATTLE BG options (BattleState:wantsFillScale/bgMode,
  Game.fillScaleInStack/worldBgBattleDim): battle surface can fill the
  window instead of the fixed integer letterbox, and the area around it
  can show white/black/the dimmed overworld instead of only white.
- src/core/FaithfulRes.lua: locks the window to an exact 160x144 multiple.
- Zoom-aware UI anchoring: Renderer:uiScale steps the UI down with survey
  zoom (gated to worldActive so the title/intro never shrink);
  Renderer:setUIAnchor lets TextBox, ChoiceBox, and an opted-in Menu
  (the START menu) pin themselves to a screen edge instead of the
  zoomed-out letterbox.
2026-08-01 15:52:34 -06:00

23 KiB

Timing parity with the Game Boy

The port's clock is faithful: src/core/FixedStep.lua advances game logic in whole 1/60 s steps off wall-clock dt, the default speed multiplier is 1x (src/core/GameSpeed.lua:22), and audio runs on its own real-time accumulator so fast-forward cannot pitch it. Almost nothing in src/ is seconds-based.

What diverges is the frame budget of composed sequences. The original spends a large fraction of its running time inside DelayFrames calls that produce no visible change - the pause after a page break, the beat before a status move resolves, the drain of an HP bar one point at a time. Those are invisible in a screenshot and easy to drop when porting behavior rather than timing. Dropping them is why the port reads as faster and snappier than hardware even though every individual animation is correct.

This document is the specification: what each sequence costs on hardware, and where that number comes from.

Method

tools/scan_pokered_delays.ps1 walks the disassembly and reports every frame-consuming wait with its enclosing routine label:

Kind Meaning Frames
DelayFrames ld c, N + call DelayFrames (home/delay.asm:1) N
DelayFrame one vblank wait (home/vblank.asm:92) 1
Delay3 home/palettes.asm:14, three frames for a full bg-map update 3
Fade the GBFade* helpers, expanded to their totals below 24 or 32

Current inventory against pokered-master: 450 sites - 181 DelayFrames, 164 Delay3, 65 DelayFrame, 31 GBFade*, and 9 DelayFrames calls whose count is computed at runtime.

The four fades all live in home/fade.asm and are loops of ld c, 8 / call DelayFrames:

Routine Iterations Frames Source
GBFadeInFromBlack 4 32 home/fade.asm:21
GBFadeOutToBlack 4 32 home/fade.asm:43
GBFadeOutToWhite 3 24 home/fade.asm:26
GBFadeInFromWhite 3 24 home/fade.asm:48

The metric

For each catalog entry, delta = |port - truth| / truth; the entry passes at delta <= 0.05. The headline number is the exposure-weighted pass rate, weighting each sequence by how often it occurs in ordinary play - a 30-frame error on every page of dialogue matters more than a 30-frame error in the Hall of Fame. Weights are in the tier column: T1 sequences recur constantly, T2 are frequent, T3 are set pieces seen once or twice per playthrough.

Tier 1 - constant exposure

These recur every few seconds of play and dominate perceived pacing. Both sides are verified.

Overworld and transitions

Sequence Hardware Source Port Port source Delta
Overworld loop iteration 2 frames (two DelayFrame) home/overworld.asm:41-44 1 step src/core/Game.lua:174 see note
Warp / door: fade out 32 home/overworld.asm:703 -> GBFadeOutToBlack 32 src/render/Transition.lua fixed
Warp / door: fade in 0 (map is drawn under blacked palettes, no fade) home/overworld.asm:690-703 0 src/render/Transition.lua fixed
Return to overworld after a battle 10 hold, then GBFadeInFromWhite 24 home/overworld.asm:351-352, :22, :749-753 10 + 24 Transition.battleReturn fixed
Special warp entry (fly / teleport / dungeon) Delay3 + GBFadeInFromWhite = 27 engine/overworld/player_animations.asm:5-7 - - unmeasured
Dungeon-warp arrival hold 50 engine/overworld/player_animations.asm:43 - - unmeasured
Player step (walk) 16 8 loop iterations x 2 frames 16 src/world/Player.lua:14 ok
Turn in place 2 one extra loop pass 2 src/world/Player.lua:18 ok

Note on the overworld loop: OverworldLoop calls DelayFrame and then falls through to OverworldLoopLessDelay, which calls it again - so a full pass costs 2 frames, and input is sampled every other frame. The port steps logic and samples input every frame. This does not change walking speed (the 2-frame loop moves 2 px, giving the same 16 frames per tile) but it does halve input latency versus hardware. Flagged rather than "wrong": matching it exactly would make the port feel less responsive than the original does on a modern display, and it is the one place where a deliberate divergence is defensible.

Text

The typewriter cadence itself is already correct - 1/3/5 frames per character from TextSpeedOptionData, implemented at src/render/TextBox.lua:267. The gaps around it are missing.

Sequence Hardware Source Port Port source Delta
<CONT> line scroll (after the A press) ProtectedDelay3 3 + 2x ScrollTextUpOneLine 5 = 13 home/text.asm:262-277, :283-307 13 src/render/TextBox.lua fixed
<PARA> paragraph break ProtectedDelay3 3 + clear + 20 = 23 home/text.asm:230-243 23 src/render/TextBox.lua fixed
Page break (PageChar) 3 + 20 = 23 home/text.asm:245-260 23 src/render/TextBox.lua fixed
TextCommand_PAUSE 30 home/text.asm:500 - - unmeasured
TextCommand_DOTS 10 per dot home/text.asm:576 - - unmeasured

The three ProtectedDelay3 frames are a pre-input hold: the arrow is already up and the button is ignored, because ManualTextScroll only starts watching the joypad after the delay returns. Mashing A through a long conversation therefore cannot go faster than 3 frames per line on hardware, and now cannot here either. PromptText (home/text.asm:209-217) has the same shape, so a finished page holds three frames before it can be dismissed too.

Battle text is a second, separate engine. BattleState types its own messages rather than going through src/render/TextBox.lua, so none of the fixes above reached it and it had drifted further than the overworld box:

Sequence Hardware Source Was Now
Per character 1 glyph per wOptions & $f frames (1/3/5, default 3) home/print_text.asm:4-45 2 glyphs per frame, option ignored 3
Per character, A or B held 1 frame print_text.asm:27-36 2 glyphs per frame 1
<CONT> pre-input hold 3 home/text.asm:263-267 0 3
<CONT> scroll after the press 10 home/text.asm:280-305 0 10
Finished page, pre-input hold 3 home/text.asm:213-217 0 3

At the default text speed the battle typewriter was running six times hardware speed, which is most of why battle text read as a blur, and it ignored the OPTION text-speed setting entirely.

ScrollTextUpOneLine is ld b, 5 of DelayFrame (home/text.asm:301-305) and its own comment notes it is "always called twice in a row", so a CONT scroll blocks for 10 frames. The port's scrollPx slide at src/render/TextBox.lua:305-307 is a cosmetic 8 px at 2 px/frame running in draw(), not on the logic step, and it does not gate the typewriter.

Menus

Sequence Hardware Source Port Port source Delta
Yes/no answer (either option) 15 engine/menus/text_box.asm:322-323, :333-334 15 src/ui/ChoiceBox.lua fixed
List menu open (bag, PC, party-as-list) 10 home/list_menu.asm:55-56 0 src/ui/ListMenu.lua -100%, open
List menu redraw per input Delay3 = 3 home/list_menu.asm:64 0 - -100%, open
Field move from the party menu Delay3 = 3 engine/menus/start_sub_menus.asm:4,27,175-203 7 (white flash) src/render/Transition.lua:8 see note
Teleport from the party menu 60 + Delay3 engine/menus/start_sub_menus.asm:224-225 - - unmeasured

The port's 7-frame white_flash models GBPalWhiteOutWithDelay3 plus the screen-tile restore, which is a defensible reading of the same sequence; it is listed here to be reconciled against the exact path rather than treated as a bug.

Battle entry

The wipe into a battle and the silhouette slide behind it. Numbers here come from pokered-c (C:\Users\Anthony\pokered), whose battle_transition.c derives each wipe from battle_transitions.asm and then corrects it against a live side-by-side with the ROM. Where that project's measured value and a naive reading of the asm disagree, the measured value wins - see the inward spiral below.

Sequence Hardware Was Now
DoubleCircle wipe (wild, weak) 10 x 3 = 30 40 30
Circle wipe (wild, strong) 20 x 3 = 60 40 60
Spiral outward (trainer, strong) 360 fills / 3 per frame = 120 40 120
Spiral inward (trainer, weak) 7 tiles per Delay3 = ~150 40 156
HStripes (dungeon wild, weak) 20 x 3 = 60 24 60
VStripes (dungeon wild, strong) 18 x 3 = 54 24 54
Shrink (dungeon trainer, weak) 9 x 6 = 54 24 54
Split (dungeon trainer, strong) 9 x 6 = 54 24 54
Flash before the circle wipes 12 x 2 x 3 = 72 72 72
Black hold before the battle draws not stated by pokered; ~30 floor, calibrated 60 30 60
Silhouette slide in 144 px at 2 px/frame = 72 40 (160 px at 4 px/frame) 72
Trainer intro, before balls + text WaitForSoundToFinish + DelayFrames 20 0 sfx wait + 20

The trainer intro's sound is SFX_Silph_Scope, extracted here as Trainer_Appeared (tools/rom_manifest.json audio.sfxHeaders, bank 8 / $42bb). It was being extracted and never played by anything. It now plays into a clear window: PrintBeginningBattleText .trainerBattle does PlaySound then WaitForSoundToFinish, which blocks, and only then DelayFrames 20 before DrawAllPokeballs and the text. BattleState's message queue grew a waitSound row for that, since WaitForSoundToFinish waits on the sound actually stopping rather than on a fixed frame count.

Scripted battles were skipping the transition entirely. BattleTransition runs from DoBattleTransitionAndInitBattleVariables, which both InitBattleCommon (core.asm:6680, trainers) and InitWildBattle (:6699) call unconditionally - every battle on hardware enters through a wipe. In the port only OverworldState:pushBattle built one, and Commands.start_battle pushed the BattleState straight onto the stack. The trainer-sight path went through pushBattle, but every script-driven battle did not: gym leaders, the rival, Giovanni, and every scripted wild encounter cut straight to the battle screen with no transition at all. The catch tutorial (old_man_demo) had the same gap; InitWildBattle has no BATTLE_TYPE_OLD_MAN special case, so it gets a wipe too.

Beyond 160x144. Both halves of the transition used to stop at the classic letterbox. The flash filled the 160x144 UI canvas, and the wipe handed the surrounding window a generic centre-out square cascade (Renderer:drawBattleCascade) regardless of which of the eight styles was running - so at any zoom a spiral read as "a spiral in a box, with something else happening around it".

  • The flash is a palette write (rBGP), and a palette register tints every pixel the LCD shows; there is no "outside the screen" for it to miss. It is now published to the renderer as a screen-space veil and painted over the finished composite, so it covers the whole surface at any zoom.
  • The spiral and circle walks are now generated for whatever grid the window works out to (BattleTransition.gridOrder), and the area outside the letterbox is filled in that order instead of the square cascade. The authentic 20x18 builders still own the letterbox itself, overrun and all, so nothing changes at 1x. The generic builders are deliberately not the ROM's walk - out there the hardware has no behaviour to be faithful to, only a shape to continue.
  • shrink, split and the two stripe styles are plain geometry rather than a tile order, so they keep the cascade for now. Extending them is rectangles, not a walk, and has not been done.

Note that the flash is wild-only: BattleTransition_FlashScreen is called from BattleTransition_Circle (:585) and BattleTransition_DoubleCircle (:628) and nowhere else. A trainer battle's transition is the spiral - inward against a weaker foe, outward against a stronger one (wBattleTransitionSpiralDirection, :119-126) - with no flash in front of it. The port's flash mapping was already correct; the transition simply never ran for those battles.

Two of these deserve their reasoning recorded:

The inward spiral writes one tile per iteration and calls BattleTransition_TransferDelay3 every seventh tile. That helper is not a one-frame transfer - it is ld a,1 / ldh [hAutoBGTransferEnabled] / call Delay3 / xor a / ldh [...] (battle_transitions.asm:619), so the cadence is 7 tiles per three frames. Reading it as one frame runs the whole wipe 3x too fast, ~46 frames against the ROM's ~150. pokered-c hit that exact bug and caught it on a live comparison.

The black hold is not a number pokered states anywhere; it is incidental load cost that a modern port does not pay. The derivable floor is ~13 frames (LoadHpBarAndStatusTilePatterns 4, LoadHudTilePatterns 2, ClearScreen's Delay3 3, the DisableLCD LY wait 1, Delay3 after EnableLCD 3), but the two sprite decompressors (UncompressSpriteFromDE for the 7x7 front pic and LoadPlayerBackPic's uncompress + ScaleSpriteByTwo) are bit-level RLE/delta decoders that cannot be cycle-counted from the asm at all. So the derivation bottoms out around 25-30 with an unbounded remainder. pokered-c set 60 by ear against the real ROM and marked it ~95% right rather than frame-matched. The credible range is 30-60; it should not be "corrected" down toward the floor on the strength of the derivation, because the omitted decompressors are exactly the unbounded part. Pinning it exactly wants a frame-by-frame capture.

Battle turns

Sequence Hardware Source Port Port source Delta
Player HP bar drain of D HP over P pixels D + 2P + 6 engine/gfx/hp_bar.asm:81-135, :140-159, :234 D + 2P + 6 src/battle/BattleState.lua stepHPDrain fixed
Enemy HP bar drain over P pixels 2P + 5 (no per-HP frame) same, gated at :207-209 2P + 5 same fixed
Status move / missed move beat 30 engine/battle/core.asm:3145,3158,3185-3186; enemy :5588 30 EffectRegistry missBeat, BattleState:performMove fixed
Applying anim type 1, vertical shake b=8 48 animations.asm:500-503 48 BattleState:applyHitFx ok
Applying anim type 2, fast horizontal b=8 72 animations.asm:505-508 72 same ok
Applying anim type 3, slow horizontal 6,2 48 animations.asm:510-512,526-549 48 same ok
Applying anim type 4, AnimationBlinkMon 60 animations.asm:514-516, :1360-1376 60 same fixed
Applying anim type 5, fast horizontal b=2 18 animations.asm:518-521 18 same ok
Applying anim type 6, slow horizontal 3,2 24 animations.asm:523-525 24 same ok
Faint slide-down 14 (PIC_HEIGHT x DelayFrames 2) engine/battle/core.asm:1186-1222 14 BattleState:onFaint fixed
Before every move animation Delay3 = 3 engine/battle/core.asm:6635-6640 3 BattleState:updateQueue fixed
Battle start, after enemy send-out 40 engine/battle/core.asm:152-156 40 BattleState:enter fixed
Poison / burn / leech-seed tick 20 engine/battle/core.asm:529-530 20 src/battle/BattleState.lua:1817 ok
Switch player mon 50 engine/battle/core.asm:2421-2422 50 src/battle/BattleState.lua:1666-1668 ok
Post-hit hold (crit text or not) 20 engine/battle/core.asm:3798-3814 20 EffectRegistry.runDamaging fixed
Fainted mon slide-down 2 per row engine/battle/core.asm:1216-1217 - - unmeasured
Trainer pic slide off 2 per column engine/battle/core.asm:1267-1268 - - unmeasured
Trainer battle victory 40 engine/battle/core.asm:940-941 - - unmeasured
Player blackout 40 engine/battle/core.asm:1143-1144 - - unmeasured
No moves left (Struggle) 60 engine/battle/core.asm:2753-2754 - - unmeasured
Send-out animation Delay3 + 4 + 5 engine/battle/core.asm:6814-6830 - - unmeasured

The HP bar was the single largest battle divergence. UpdateHPBar steps one HP point per loop iteration; each iteration pays 1 frame in UpdateHPBar_PrintHPNumber whenever wHPBarType != 0 - the player's own HUD and the party menu, but not the enemy HUD - plus 2 frames for each pixel the bar actually moves. The tail (.animateHPBarDone, :132-135) prints the number once more, animates one last pixel and falls into Delay3, so it costs 6 frames player-side and 5 enemy-side.

A 150 HP mon losing everything therefore costs 150 + 96 + 6 = 252 frames (4.2 s) on the player's HUD, against 96 + 5 = 101 on the enemy's. The port used a flat maxHP/96 - the enemy-side rate applied to both sides - and ran that same drain in 96 frames (1.6 s).

Tier 2 - frequent

Status-effect failures (engine/battle/effects.asm:161,1162,1205) hold 50 frames each. SwitchAndTeleportEffect uses 50 and 20 (:834-901). Evolution holds 50 then 40 (engine/pokemon/evos_moves.asm:123,155). The healing machine (engine/overworld/healing_machine.asm), item effects (engine/items/item_effects.asm, 259 frames across 8 sites), and the fishing animation (10 then 100, engine/overworld/player_animations.asm:380,399) are all in this tier.

Tier 3 - set pieces

Highest total budgets in the scan, all seen rarely: engine/movie/hall_of_fame.asm (529 frames / 7 sites), engine/link/cable_club.asm (507 / 12), engine/movie/credits.asm (502 / 7), engine/movie/trade.asm (494 / 19), engine/movie/intro.asm (317 / 7), engine/menus/main_menu.asm (271 / 15), engine/menus/save.asm (250 / 3), engine/battle/end_of_battle.asm (200 / 1, the link-battle win/lose string). Several of these already have faithful implementations - see src/ui/IntroMovie.lua, src/ui/Credits.lua, src/ui/HallOfFame.lua, whose constants cite their asm sources directly.

Status

Closed, and locked by tests/engine/timing_parity.lua:

  1. Text page and CONT breaks - were 0 frames where hardware spends 13-23, on every page of every dialogue in the game. Now exact, including the three-frame pre-input hold that swallows a mashed A.

  2. Player HP bar drain - was ~2.5x too fast on a typical mon. Now steps one HP point at a time at the hardware rate, with the enemy HUD correctly cheaper than the player's.

  3. Warp fade - was a symmetric 12/12; now 32 out and no fade in, which is both the right duration and the right shape.

  4. Yes/no answer - was 0 frames where hardware spends 15, with the cursor snapping to NO on B for the duration as .choseSecondMenuItem does.

  5. Battle entry - every wipe ran at a flat 40/24 against budgets of 30-156, the silhouette slide was 40 frames against 72, the black hold was half what pokered-c calibrated, and the trainer intro's 20-frame gap before the balls and text was missing entirely. This was the single most compressed stretch in the game.

  6. Battle turns - the type-4 blink ran at 20 frames against 60. That is the applying animation for every plain damaging move the player uses, so it was the single most-repeated timing error in the game. The 30-frame beat that hardware spends on every status move and every miss was missing entirely. The faint slide, unusually, ran slower than hardware (30 against 14).

Two things worth recording about that last batch, because both contradict a plausible reading:

  • PrintCriticalOHKOText's 20-frame hold is not conditional. The "no critical hit" early-out at core.asm:3799 jumps to .done, and .done is the ld c, 20 / jp DelayFrames. Every landed hit pays it, which is where the beat before "It's super effective!" comes from.
  • StartBattle's 40-frame hold is not conditional either. The call nz at core.asm:154 gates only EnemySendOutFirstMon; the DelayFrames 40 under it runs for wild battles too, and it lands between the enemy's send-out and .playerSendOutFirstMon (:166) rather than at the end of the intro.

Trainer victory (24-frame scroll-in + 40-frame hold) and the ball shake (SFX_TINK + DelayFrames 40 per rock) were already correct - BattleState.lua's wait = 64 and AnimPlayer.lua's emit(40). Note that pokered-c's BUI_TRAINER_VICTORY_SLIDE comment calls the scroll-in 14 frames; _ScrollTrainerPicAfterBattle is 6 loop passes of DelayFrames 4, so 24 is right and this port already had it.

Leaving a battle was a cut, not a fade. EnterMap checks BIT_BATTLE_OVER_OR_BLACKOUT and calls MapEntryAfterBattle (home/overworld.asm:22), which is GBFadeInFromWhite - so the map fades up from white over 24 frames, behind the 10-frame hold at :351-352. The port popped the battle and the overworld was simply there. Transition.battleReturn supplies both halves, and steps the veil in three palette stages of 8 frames rather than tweening it, because GBFadeIncCommon writes a palette and holds it with ld c, 8 / call DelayFrames (home/fade.asm:30-41) three times over.

It is wrapped around battle.onFinish in OverworldState:pushBattle - the one funnel every battle goes through - rather than living in afterBattle. That placement matters: a script-driven win defers afterBattle into ctx.afterScript so an evolution screen cannot be buried under the trainer's follow-up text (Commands.start_battle), and a fade inside afterBattle inherited that deferral. On a rival battle it fired after the post-battle dialogue and the rival's walk-off, rather than when the battle ended.

The rest of onFinish runs as the fade's onDone, which is also the hardware order: MapEntryAfterBattle fades the map back in and only then does the map script run. The overworld is frozen meanwhile - StateStack updates the top state only - so nothing moves under the white.

Hardware skips the fade on a dark map (wMapPalOffset nonzero takes the LoadGBPal branch at :754). This port has no wMapPalOffset equivalent - no map needs FLASH to be lit - so that branch is unreachable here; battleReturn accepts opts.instant for it if that ever changes.

Still open, hardware number confirmed but not yet wired:

  • List menu open (10) and per-input redraw (Delay3).
  • Every battle-table row marked "unmeasured" above - the status/miss beat (30) is the highest-exposure of them, since it is paid on every status move and every miss.

Entries marked "unmeasured" have confirmed hardware numbers but the port side has not been traced; they are remaining work, not known-good.