Files
gen1recomp/src/core/Timing.lua
T
bryanthaboi 93cd84d232 follow up
2026-10-06 19:45:14 -04:00

216 lines
9.0 KiB
Lua

-- Hardware frame budgets, in fixed 60Hz logic steps.
--
-- The original spends a large share 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 one-HP-at-a-time drain of an HP
-- bar. Porting the visible half of a sequence and dropping the wait is what
-- makes a port read as snappier than hardware, so every one of those waits
-- lives here with its asm citation instead of as a file-local constant.
--
-- See docs/timing-parity.md for the full catalog and the measurement method;
-- tools/scan_pokered_delays.ps1 regenerates the hardware side from a
-- disassembly checkout.
local Timing = {}
-- home/palettes.asm:14 -- three frames to let the bg map fully update
Timing.DELAY3 = 3
-- home/fade.asm: each fade is a loop of `ld c, 8 / call DelayFrames`
Timing.FADE_IN_FROM_BLACK = 32 -- fade.asm:21, b = 4
Timing.FADE_OUT_TO_BLACK = 32 -- fade.asm:43, b = 4
Timing.FADE_OUT_TO_WHITE = 24 -- fade.asm:26, b = 3
Timing.FADE_IN_FROM_WHITE = 24 -- fade.asm:48, b = 3
-- Overworld -----------------------------------------------------------------
-- home/overworld.asm:703 PlayMapChangeSound tail-calls GBFadeOutToBlack on
-- every map change. There is no matching fade in: the new map is drawn while
-- the palettes are still blacked out and LoadGBPal restores them in one write,
-- so the map appears instantly.
Timing.WARP_FADE_OUT = Timing.FADE_OUT_TO_BLACK
Timing.WARP_FADE_IN = 0
-- home/overworld.asm:351-352 -- after a battle, before EnterMap
Timing.POST_BATTLE_RETURN = 10
-- engine/overworld/player_animations.asm:5-7 -- EnterMapAnim, the fly /
-- teleport / dungeon-warp arrival: Delay3 then GBFadeInFromWhite
Timing.SPECIAL_WARP_ENTRY = Timing.DELAY3 + Timing.FADE_IN_FROM_WHITE
-- player_animations.asm:43 -- dungeon warp holds before handing back control
Timing.DUNGEON_WARP_ARRIVAL = 50
-- Text ----------------------------------------------------------------------
-- home/text.asm:283-307 ScrollTextUpOneLine is `ld b, 5` of DelayFrame, and
-- its own comment notes it is "always called twice in a row"
Timing.TEXT_SCROLL_LINE = 5
Timing.TEXT_SCROLL_PAIR = Timing.TEXT_SCROLL_LINE * 2
-- Both _ContText (home/text.asm:262-277) and Paragraph (:230-243) print the
-- â–¼ and call ProtectedDelay3 *before* ManualTextScroll starts watching the
-- joypad, so three frames pass with the arrow up and the button ignored.
Timing.TEXT_PRE_ADVANCE = Timing.DELAY3
-- Paragraph / PageChar clear the box and then hold (home/text.asm:239-240,
-- :254-255) before the next page starts typing.
Timing.TEXT_PAGE_CLEAR = 20
-- Totals, for the catalog and the parity tests.
Timing.TEXT_CONT = Timing.TEXT_PRE_ADVANCE + Timing.TEXT_SCROLL_PAIR
Timing.TEXT_PARAGRAPH = Timing.TEXT_PRE_ADVANCE + Timing.TEXT_PAGE_CLEAR
Timing.TEXT_PAGE = Timing.TEXT_PARAGRAPH
Timing.TEXT_PAUSE = 30 -- home/text.asm:500 TextCommand_PAUSE
Timing.TEXT_DOT = 10 -- home/text.asm:576 TextCommand_DOTS, per dot
-- Menus ---------------------------------------------------------------------
-- engine/menus/text_box.asm:322-323 / :333-334 -- both branches of a
-- two-option (yes/no) menu hold before restoring the screen tiles
Timing.YES_NO_ANSWER = 15
Timing.LIST_MENU_OPEN = 10 -- home/list_menu.asm:55-56
Timing.LIST_MENU_REDRAW = Timing.DELAY3 -- home/list_menu.asm:64
-- engine/menus/start_sub_menus.asm:224-225
Timing.FIELD_TELEPORT = 60 + Timing.DELAY3
-- Battle --------------------------------------------------------------------
-- SlidePlayerAndEnemySilhouettesOnScreen (engine/battle/core.asm:9-49):
-- the enemy comes in on BG SCX $90 -> $00 and the player's back pic on
-- decrementing OAM x, both 2 px per frame -- so 144 px over 72 frames. The
-- port ran 160 px at 4 px/frame (40 frames), a little under twice too fast.
Timing.BATTLE_SLIDE_IN_FRAMES = 72
Timing.BATTLE_SLIDE_PX_PER_FRAME = 2
-- PrintBeginningBattleText .trainerBattle (engine/battle/common_text.asm):
-- SFX_SILPH_SCOPE plays into a clear window (PlaySound then
-- WaitForSoundToFinish, which blocks), and only after `ld c, 20 /
-- DelayFrames` do DrawAllPokeballs and the "wants to fight!" text run.
Timing.TRAINER_INTRO_SFX_GAP = 20
Timing.BATTLE_START_SENDOUT = 40 -- engine/battle/core.asm:155-156
Timing.MOVE_ANIM_PRE = Timing.DELAY3 -- core.asm:6638 PlayMoveAnimation
-- animations.asm:431-433 (pokeyellow animations.asm:445-446)
Timing.MOVE_ANIM_OFF = 30
-- core.asm:3185-3186 (player) / :5587-5588 (enemy). Reached when the move
-- has 0 BP (core.asm:3145 -- every status move) or missed (:3158), so this
-- beat is paid on a large fraction of all turns.
Timing.MOVE_STATUS_OR_MISS = 30
-- ../pokered/engine/battle/move_effects/substitute.asm:2-3
Timing.SUBSTITUTE_ENTRY = 50
-- PlayApplyingAttackAnimation's six types (AnimationTypePointerTable,
-- engine/battle/animations.asm:490-524). The two shake families are
-- `AnimationShakeScreenHorizontallySlow`, whose double push/pop makes each
-- outer pass cost 4b frames and run c times -- so c * 4b.
Timing.SHAKE_VERTICAL = 48 -- type 1, b=8: 8 x 6
Timing.SHAKE_HORIZ_HEAVY = 72 -- type 2, b=8: 8 x 9
Timing.SHAKE_HORIZ_SLOW = 48 -- type 3, lb bc, 6, 2: 2 x 4x6
Timing.SHAKE_HORIZ_LIGHT = 18 -- type 5, b=2: 2 x 9
Timing.SHAKE_HORIZ_SLOW2 = 24 -- type 6, lb bc, 3, 2: 2 x 4x3
-- Type 4 -- the player's damaging move with no added effect, and so the
-- single most common animation in the game -- is AnimationBlinkMon
-- (animations.asm:1360-1376): `ld c, 6` iterations of hide + DelayFrames 5
-- + show + DelayFrames 5. The asm's own comment calls it "a second or
-- two"; the port ran it in 20 frames, three times too fast, which is a
-- large part of why trading blows felt hurried.
Timing.BLINK_MON = 60
-- SlideDownFaintedMonPic (engine/battle/core.asm:1181-1222): b = PIC_HEIGHT
-- (7) outer iterations, each closing with `ld c, 2 / call DelayFrames`.
-- This one the port ran SLOWER than hardware, at 30.
Timing.FAINT_SLIDE = 14
Timing.RESIDUAL_TICK = 20 -- core.asm:529-530 poison/burn/leech seed
Timing.CRIT_OHKO_TEXT = 20 -- core.asm:3813-3814
Timing.SWITCH_PLAYER_MON = 50 -- core.asm:2421-2422
Timing.NO_MOVES_LEFT = 60 -- core.asm:2753-2754
Timing.TRAINER_VICTORY = 40 -- core.asm:940-941
Timing.PLAYER_BLACKOUT = 40 -- core.asm:1143-1144
Timing.FAINT_SLIDE_ROW = 2 -- core.asm:1216-1217, per row
Timing.FAINT_SLIDE_STEP = 8 / Timing.FAINT_SLIDE_ROW -- 4px per frame at 1x
Timing.TRAINER_SLIDE_COL = 2 -- core.asm:1267-1268, per column
-- HP bar (engine/gfx/hp_bar.asm) ---------------------------------------------
Timing.HP_BAR_PIXELS = 48 -- the bar is 48 px wide (GetHPBarLength)
Timing.HP_BAR_PIXEL_STEP = 2 -- engine/gfx/hp_bar.asm:147-148
Timing.HP_BAR_HP_STEP = 1 -- engine/gfx/hp_bar.asm:207-209, 234
Timing.HP_BAR_STEP_CYCLES = 6144 -- engine/gfx/hp_bar.asm:81-120, 244-269
Timing.VBLANK_CYCLES = 2196 -- home/vblank.asm:1
Timing.FRAME_CYCLES = 17556
-- engine/gfx/hp_bar.asm:6-45
function Timing.hpBarLength(hp, maxHP)
if not maxHP or maxHP <= 0 then return 0 end
local px
if maxHP >= 256 then
-- engine/gfx/hp_bar.asm:19-34
px = math.floor(math.floor(hp * Timing.HP_BAR_PIXELS / 4)
/ math.floor(maxHP / 4))
else
px = math.floor(hp * Timing.HP_BAR_PIXELS / maxHP)
end
if px < 1 then px = 1 end
return px
end
function Timing.hpBarPixels(hp, maxHP)
if not maxHP or maxHP <= 0 then return 0 end
if hp <= 0 then return 0 end
return Timing.hpBarLength(hp, maxHP)
end
function Timing.hpBarCpuLag(cycles, roundUp)
if not cycles or cycles <= 0 then return 0, false end
local budget = Timing.FRAME_CYCLES - Timing.VBLANK_CYCLES
local whole = math.floor(cycles / budget)
if cycles % budget ~= 0 then return whole, false end
if roundUp == false then return whole - 1, true end
return whole, true
end
-- engine/gfx/hp_bar.asm:121-135
function Timing.hpDrainClosingFrames(playerSide, cycles, roundUp)
local frames = Timing.HP_BAR_PIXEL_STEP + Timing.DELAY3
if playerSide then return frames + Timing.HP_BAR_HP_STEP end
return frames + Timing.hpBarCpuLag(cycles, roundUp)
end
-- engine/gfx/hp_bar.asm:81-120
function Timing.hpDrainFrames(fromHP, toHP, maxHP, playerSide)
local total, cycles, roundUp = 0, 0, true
local hp = fromHP
local dir = (toHP < fromHP) and -1 or 1
while hp ~= toHP do
local nextHP = hp + dir
local pixels = math.abs(Timing.hpBarLength(nextHP, maxHP)
- Timing.hpBarLength(hp, maxHP))
if playerSide then
total = total + Timing.HP_BAR_HP_STEP + pixels * Timing.HP_BAR_PIXEL_STEP
else
cycles = cycles + Timing.HP_BAR_STEP_CYCLES
if pixels > 0 then
local lag, tie = Timing.hpBarCpuLag(cycles, roundUp)
if tie then roundUp = not roundUp end
total = total + pixels * Timing.HP_BAR_PIXEL_STEP + lag
cycles = 0
end
end
hp = nextHP
end
-- engine/gfx/hp_bar.asm:127-129
if not playerSide and toHP ~= 0 then
cycles = cycles + Timing.HP_BAR_STEP_CYCLES
end
return total + Timing.hpDrainClosingFrames(playerSide, cycles, roundUp)
end
return Timing