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.
This commit is contained in:
spiritsnails
2026-08-01 15:52:34 -06:00
parent 5e89e35e02
commit aedc63c40d
23 changed files with 2515 additions and 114 deletions
+199 -31
View File
@@ -23,6 +23,7 @@ local Pokemon = require("src.pokemon.Pokemon")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local Status = require("src.battle.Status")
local Timing = require("src.core.Timing")
local TrainerAI = require("src.battle.TrainerAI")
local TurnOrder = require("src.battle.TurnOrder")
local TypeChart = require("src.battle.TypeChart")
@@ -50,6 +51,41 @@ function BattleState:wideLayout()
return self:isWideBattleLayout()
end
-- BATTLE SIZE: "fixed" keeps the classic integer-scaled letterbox (a GB pixel
-- is a whole number of screen pixels, and the battle is the same size at any
-- zoom); "fill" scales the battle surface to the window instead, so it fills
-- vertically. Filling means a fractional scale, so pixels stop being evenly
-- sized -- that is the trade, which is why it is a setting rather than a
-- change. Only the battle surface is affected; the overworld is unchanged.
function BattleState:wantsFillScale()
local options = self.game and self.game.save and self.game.save.options
return options and options.battleFit == "fill" or false
end
-- BATTLE BG: what fills the screen AROUND the battle -- the letterbox voids
-- that grow as the window gets bigger or the view is zoomed out. The battle
-- screen itself is untouched: it keeps its white paper field in every mode.
--
-- "white" the display mode's paper shade (the classic look)
-- "black" plain black bars
-- "world" the frozen overworld, dimmed
--
-- "world" works by making the battle NON-opaque: StateStack:visibleBase then
-- finds the overworld below it and Game:draw keeps drawing the map, so the
-- voids show it instead of a flat clear. The battle still paints its own
-- opaque 160x144 field over the top, so only the surround changes.
function BattleState:bgMode()
local options = self.game and self.game.save and self.game.save.options
local mode = options and options.battleBg
if mode == "black" or mode == "world" then return mode end
return "white"
end
-- How far to dim the overworld behind a "world" background, 0..1. Enough
-- that the battle reads as the foreground rather than competing with a fully
-- lit map behind it.
BattleState.BG_WORLD_DIM = 0.55
-- Renderer:setUISize asks the top state for its surface before anything draws
function BattleState:uiSize()
if self:wideLayout() then return WideBattle.WIDTH, WideBattle.HEIGHT end
@@ -844,9 +880,30 @@ function BattleState:drainNext(battler, stopAt)
{ drain = true, battler = battler, stopAt = stopAt })
end
-- One frame of the HP-bar drain (engine/gfx/hp_bar.asm UpdateHPBar):
-- the bar animates a pixel per two frames, so displayed HP moves at
-- maxHP/96 per frame (48-pixel bar). Returns true while animating.
-- Queue a pure frame hold at the current insert point, the way the original
-- spends DelayFrames between the beats of a turn. Mirrors sayNext/drainNext
-- so a caller can interleave holds with messages in source order.
function BattleState:waitNext(frames)
if not frames or frames <= 0 then return end
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = frames })
end
-- One frame of the HP-bar drain (engine/gfx/hp_bar.asm UpdateHPBar).
--
-- The original walks the bar ONE HP POINT per loop iteration (:81-120), and
-- what each iteration costs depends on the side:
-- * UpdateHPBar_PrintHPNumber spends a DelayFrame (:234) reprinting the
-- number, but only when wHPBarType is nonzero (:207-209) -- the player's
-- own HUD and the party menu, never the enemy's;
-- * UpdateHPBar_AnimateHPBar spends 2 frames for each pixel the bar
-- actually moved (:147-148), and most single-HP steps move none.
-- So the player's bar drains at 1 HP per frame plus 2 frames per pixel,
-- while the enemy's costs nothing until it crosses a pixel boundary. The
-- old flat maxHP/96 rate was the enemy-side formula applied to both, which
-- ran a 150 HP mon's full drain in 96 frames against hardware's 249.
--
-- Returns true while animating.
function BattleState:stepHPDrain()
local busy = false
for _, b in ipairs({ self.player, self.enemy }) do
@@ -857,14 +914,30 @@ function BattleState:stepHPDrain()
and b.shownHP >= b.drainFloor then
goal = b.drainFloor
end
if b.shownHP ~= goal then
local step = math.max(1, b.mon.stats.hp) / 96
if b.shownHP > goal then
b.shownHP = math.max(goal, b.shownHP - step)
else
b.shownHP = math.min(goal, b.shownHP + step)
if (b.drainHold or 0) > 0 then
b.drainHold = b.drainHold - 1
busy = true
elseif b.shownHP ~= goal then
local maxHP = math.max(1, b.mon.stats.hp)
local playerSide = (b == self.player)
local cost = 0
-- consume whole HP steps until this frame's budget is spent; on the
-- enemy HUD several free steps can land in the same frame
while b.shownHP ~= goal and cost < 1 do
local nextHP = b.shownHP + ((b.shownHP > goal) and -1 or 1)
cost = cost + Timing.hpDrainStepFrames(b.shownHP, nextHP,
maxHP, playerSide)
b.shownHP = nextHP
end
busy = busy or b.shownHP ~= goal
b.drainHold = math.max(0, cost - 1)
b.draining = true
busy = true
elseif b.draining then
-- .animateHPBarDone's final number print, one more pixel step and
-- Delay3 (hp_bar.asm:132-135); this frame is the first of them
b.draining = nil
b.drainHold = Timing.hpDrainClosingFrames(b == self.player) - 1
busy = true
end
end
end
@@ -939,6 +1012,14 @@ function BattleState:updateQueue()
self.waitFrames = self.waitFrames - 1
return true
end
-- WaitForSoundToFinish (home/delay.asm:15-20) blocks until the sfx has
-- actually stopped sounding, which is how the original gives a sound its
-- own clear window instead of letting the next beat play over it
if self.waitingSound then
local src = self.waitingSound
if src and src.isPlaying and src:isPlaying() then return true end
self.waitingSound = nil
end
-- an HP-bar drain holds the queue until the bar catches up
if self.draining then
if self:stepHPDrain() then return true end
@@ -990,6 +1071,12 @@ function BattleState:updateQueue()
self.waitFrames = item.wait
return true
end
if item.waitSound then
-- the source is fetched now, not when the row was queued, so the
-- act() that started the sound has already run
self.waitingSound = item.waitSound()
return true
end
if item.mimicSelect then
-- pause the queue on Mimic's copy menu (MoveSelectionMenu with
-- wMoveMenuType = 1 lists the enemy's moves; cursor starts on 1)
@@ -1010,6 +1097,16 @@ function BattleState:updateQueue()
-- the animation ends (hitRow rows carry a hit with no animation --
-- thrash/rage continuation turns that skip the announcement).
if item.anim or item.hitRow then
-- PlayMoveAnimation writes wAnimationID, calls Delay3, and only then
-- jumps to MoveAnimation (core.asm:6635-6640), so three frames pass
-- between the move's announcement and the first frame of its
-- animation. Put the row back and pay that first.
if item.anim and not item.animDelayed then
item.animDelayed = true
table.insert(self.queue, 1, item)
self.waitFrames = Timing.MOVE_ANIM_PRE
return true
end
local mdef = item.anim and self.data.moves[item.anim]
local anim = mdef and mdef.anim
if item.anim == "POOF_ANIM" then
@@ -1081,17 +1178,38 @@ function BattleState:updateQueue()
-- a \v CONT wait holds the box until A/B, then scrolls the next line in
-- (home/text.asm ContText); this keeps a 3rd line on-screen (#216)
if self.msgWaiting then
-- _ContText prints the â–¼ and runs ProtectedDelay3 BEFORE ManualTextScroll
-- starts watching the joypad (home/text.asm:263-267), so three frames
-- pass with the arrow up and the button ignored
if (self.msgPreWait or 0) > 0 then
self.msgPreWait = self.msgPreWait - 1
return true
end
if input:wasPressed("a") or input:wasPressed("b") then
self.msgWaiting = nil
self:beginMsgLine()
-- then the two ScrollTextUpOneLine calls block for 5 frames each
-- (home/text.asm:280-305) before the next line starts typing
self.waitFrames = Timing.TEXT_SCROLL_PAIR
end
return true
end
local cur = self.shown[#self.shown]
if #cur < #self.codes then
-- battle typewriter cadence: two glyphs per fixed step (as before)
for _ = 1, 2 do
if #cur >= #self.codes then break end
-- Battle text prints through the same PrintText path as everything
-- else, so it pays PrintLetterDelay per character (home/print_text.asm:
-- 4-45): hFrameCounter is loaded from wOptions & $f -- the OPTION text
-- speed, 1/3/5, default 3 -- and the loop spins until it drains, unless
-- A or B is held, which collapses the wait to a single DelayFrame.
-- This used to run two glyphs per frame flat, six times hardware speed
-- at the default setting, and ignored the text-speed option entirely.
local delay = (self.game.save.options and self.game.save.options.textSpeed)
or 3
if delay ~= 1 and delay ~= 3 and delay ~= 5 then delay = 3 end
if input:isDown("a") or input:isDown("b") then delay = 1 end
self.charTimer = (self.charTimer or 0) + 1
while self.charTimer >= delay and #cur < #self.codes do
self.charTimer = self.charTimer - delay
cur[#cur + 1] = self.codes[#cur + 1]
self.charIndex = self.charIndex + 1
end
@@ -1100,6 +1218,7 @@ function BattleState:updateQueue()
-- scrolling, \n advances now (beginMsgLine scrolls if the box is full)
if self.lines[self.lineIndex + 1].cont then
self.msgWaiting = true
self.msgPreWait = Timing.TEXT_PRE_ADVANCE
else
self:beginMsgLine()
end
@@ -1126,8 +1245,18 @@ function BattleState:updateQueue()
-- only on a \v CONT hold (#317). A flag of its own, not msgWaiting:
-- that branch above scrolls the NEXT line in, which this page has not
-- got, so reusing it would call beginMsgLine on a drained message.
self.msgPrompt = true
if input:wasPressed("a") or input:wasPressed("b") then
if not self.msgPrompt then
self.msgPrompt = true
-- PromptText runs ProtectedDelay3 between writing the arrow and
-- ManualTextScroll (home/text.asm:213-217), so the page holds for
-- three frames with the button ignored before it can be dismissed.
-- Without it a queued A press could clear a page the same frame its
-- last glyph landed, which is most of "it doesn't hold sometimes".
self.msgPromptWait = Timing.TEXT_PRE_ADVANCE
end
if (self.msgPromptWait or 0) > 0 then
self.msgPromptWait = self.msgPromptWait - 1
elseif input:wasPressed("a") or input:wasPressed("b") then
self.msgPrompt = nil
self.current = nil
end
@@ -1248,11 +1377,12 @@ function BattleState:enter()
-- without a transition (link battles, scripted pushes)
Music.playBattle(self.data, self.musicKind)
-- intro presentation (SlidePlayerAndEnemySilhouettesOnScreen): both
-- sides slide in as black silhouettes. The original scrolls SCX from
-- $90 to 0 two pixels per frame (72 frames); the port covers the full
-- 160px screen width, so 2px/frame is an 80-frame slide (slide offset is
-- introSlide*2 below). The trainer pics stay up until the send-outs.
self.introSlide = 80
-- sides slide in; the trainer pics stay up until the send-outs
-- BATTLE BG "world" drops this battle's opacity so StateStack keeps drawing
-- the overworld underneath it (see bgMode). Per instance, so the class
-- default stays opaque for every other battle and for older saves.
self.isOpaque = self:bgMode() ~= "world"
self.introSlide = Timing.BATTLE_SLIDE_IN_FRAMES
self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil
-- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the
-- HUD corner/underline tiles under them (PlacePlayerHUDTiles /
@@ -1298,6 +1428,25 @@ function BattleState:enter()
and not self.ghost and not self.scopeReveal then
queueEnemyCry()
end
-- PrintBeginningBattleText .trainerBattle (common_text.asm): a trainer
-- battle gives SFX_SILPH_SCOPE a clear window -- PlaySound, then
-- WaitForSoundToFinish, which blocks -- and only after `ld c, 20 /
-- DelayFrames` do DrawAllPokeballs and the "wants to fight!" text run.
-- The balls and the text used to appear on the same frame the silhouettes
-- landed, so the sound had to share its whole duration with the ball draw
-- and the text scroll instead of landing on its own.
--
-- The sfx is extracted as "Trainer_Appeared" (tools/rom_manifest.json
-- sfxHeaders, bank 8 / $42bb -- the same header pokered names
-- SFX_Silph_Scope); nothing had ever played it.
if self.kind == "trainer" then
self:act(function()
self.introSfx = require("src.core.Sound").play(self.data,
"Trainer_Appeared")
end)
table.insert(self.queue, { waitSound = function() return self.introSfx end })
table.insert(self.queue, { wait = Timing.TRAINER_INTRO_SFX_GAP })
end
self:say(self.introText)
-- the unveil rides on that same box, before _InitBattleCommon clears the
-- intro chrome below (#492)
@@ -1339,6 +1488,14 @@ function BattleState:enter()
end)
queueEnemyCry()
end
-- StartBattle .foundFirstAliveEnemyMon (core.asm:152-156): the `call nz`
-- gates only EnemySendOutFirstMon -- the `ld c, 40 / call DelayFrames`
-- after it is unconditional, so a wild battle pays it too, between
-- "Wild X appeared!" and "Go! Y!". It lands before .playerSendOutFirstMon
-- (:166), not at the end of the intro. Appended, not waitNext'd: the
-- intro is built linearly, and waitNext's insert point is for rows added
-- while the queue is already running.
table.insert(self.queue, { wait = Timing.BATTLE_START_SENDOUT })
if not self.safari and not self.demo then
-- StartBattle .playerSendOutFirstMon (core.asm:236-240): the back pic
-- walks off the LEFT edge (SlideTrainerPicOffScreen, hlcoord 1,5,
@@ -2510,24 +2667,27 @@ function BattleState:applyHitFx(hit)
prog[#prog + 1] = { dy = 0, frames = 3 }
end
self.fx.shakeProg = prog
self.waitFrames = 48 -- the predef blocks until the shake settles
self.waitFrames = Timing.SHAKE_VERTICAL -- the predef blocks until it settles
elseif t == 2 then
self.fx.shakeProg = fastShakeProg(8)
self.waitFrames = 72
self.waitFrames = Timing.SHAKE_HORIZ_HEAVY
elseif t == 3 then
self.fx.shakeProg = slowShakeProg(6, 2)
self.waitFrames = 48
self.waitFrames = Timing.SHAKE_HORIZ_SLOW
elseif t == 4 then
if hit.blink then
self.fx.blink = { target = hit.blink, frames = 20 }
self.waitFrames = 20
-- AnimationBlinkMon: 6 iterations of hide/5 frames/show/5 frames.
-- This is the animation for every plain damaging move the player
-- uses, and it ran at a third of its length.
self.fx.blink = { target = hit.blink, frames = Timing.BLINK_MON }
self.waitFrames = Timing.BLINK_MON
end
elseif t == 5 then
self.fx.shakeProg = fastShakeProg(2)
self.waitFrames = 18
self.waitFrames = Timing.SHAKE_HORIZ_LIGHT
elseif t == 6 then
self.fx.shakeProg = slowShakeProg(3, 2)
self.waitFrames = 24
self.waitFrames = Timing.SHAKE_HORIZ_SLOW2
end
end
@@ -3305,10 +3465,13 @@ function BattleState:onFaint(battler)
Sound.playCry(self.data, battler.mon.species)
Sound.play(self.data, "Faint_Fall")
self.fx = self.fx or {}
self.fx.faint = { battler = battler, frames = 30 }
-- SlideDownFaintedMonPic: PIC_HEIGHT (7) slide steps, each closing with
-- DelayFrames 2 (core.asm:1186-1222). The port held this one twice as
-- long as hardware.
self.fx.faint = { battler = battler, frames = Timing.FAINT_SLIDE }
end)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = 30 })
table.insert(self.queue, self.nextInsert, { wait = Timing.FAINT_SLIDE })
if not battler.isPlayer and self.kind == "wild" then
-- FaintEnemyPokemon .wild_win (core.asm:792-795): beating a wild
-- mon calls EndLowHealthAlarm and starts MUSIC_DEFEATED_WILD_MON
@@ -4323,10 +4486,14 @@ function BattleState:growInScale(battler)
end
-- battler hidden this frame? (damage blink)
--
-- AnimationBlinkMon hides the pic, waits DelayFrames 5, shows it, waits
-- DelayFrames 5, six times over (animations.asm:1360-1376) -- a 10-frame
-- period, not 8. With Timing.BLINK_MON that is exactly six blinks.
function BattleState:fxHidden(battler)
local fx = self.fx
if fx and fx.blink and fx.blink.target == battler and fx.blink.frames > 0 then
return self.frame % 8 < 4
return self.frame % 10 < 5
end
return false
end
@@ -5230,7 +5397,8 @@ function BattleState:drawClassic()
if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then
sx = self.frame % 4 < 2 and 2 or -2
end
local slide = (self.introSlide or 0) * 2 -- intro slide-in offset (2px/frame)
-- intro slide-in offset: 2 px per frame, so 144 px over 72 frames
local slide = (self.introSlide or 0) * Timing.BATTLE_SLIDE_PX_PER_FRAME
if self:colorMode() then
-- SGB pipeline: gray BG canvas -> (wavy) -> zone recolor with the