Merge pull request #627 from spiritsnails/parity-fixes

This commit is contained in:
bryanthaboi
2026-08-01 21:18:52 -04:00
committed by GitHub
28 changed files with 2804 additions and 272 deletions
+129
View File
@@ -0,0 +1,129 @@
-- Faithful resolution: lock the window to an exact integer multiple of the
-- Game Boy's 160x144 screen, 1X through 4X.
--
-- At any other window size the renderer picks the largest integer scale that
-- fits and letterboxes the remainder (Renderer:fitScale), so the game is
-- already crisp -- what it is not is *exact*: there are bars, and at a wide
-- window a lot of them. Locking the window to 160*N x 144*N removes the
-- letterbox entirely, so the surface is the Game Boy screen and nothing else.
--
-- Persisted as save.options.faithfulRes (0 = OFF). Applied from OptionsMenu
-- and on boot via Game:applyOptions. No-ops on mobile and in headless stubs
-- that lack love.window.
local FaithfulRes = {}
FaithfulRes.WIDTH, FaithfulRes.HEIGHT = 160, 144
FaithfulRes.LEVELS = { 0, 1, 2, 3, 4 }
FaithfulRes.DEFAULT = 0
-- conf.lua's floor for the resizable desktop window, restored when the lock
-- is released. 1X and 2X are BELOW it, so the lock has to lower the minimum
-- as well as set the size or LOVE clamps the window back up.
FaithfulRes.MIN_W, FaithfulRes.MIN_H = 480, 360
-- whether this module currently owns the window size
FaithfulRes.locked = false
function FaithfulRes.normalize(v)
v = math.floor(tonumber(v) or FaithfulRes.DEFAULT)
if v < 0 then return 0 end
if v > 4 then return 4 end
return v
end
function FaithfulRes.label(v)
v = FaithfulRes.normalize(v)
if v == 0 then return "OFF" end
return tostring(v) .. "X"
end
function FaithfulRes.cycle(v, dir)
local levels = FaithfulRes.LEVELS
local cur = 1
for i, level in ipairs(levels) do
if level == FaithfulRes.normalize(v) then cur = i break end
end
return levels[(cur - 1 + (dir or 1)) % #levels + 1]
end
function FaithfulRes.isMobile()
if not love or not love.system or not love.system.getOS then return false end
local osName = love.system.getOS()
return osName == "Android" or osName == "iOS"
end
-- Physical pixels per LOVE unit for the CURRENT window.
--
-- Deliberately NOT love.window.getDPIScale: that reports the display's
-- scaling factor even when the window is not high-DPI aware, and conf.lua
-- only sets t.window.highdpi on mobile. On a plain desktop window a unit IS
-- a pixel, so dividing by the display scale just shrinks the window -- at
-- 125% scaling a 2X request became 256x230 pixels, which Renderer:fitScale
-- floors to 1, and 4X became 512x461, which floors to 3. That is exactly
-- the "2X renders at 1X, 4X renders at 3X" this shipped with.
--
-- Measuring the ratio the window actually reports is correct in both worlds:
-- 1 on a plain desktop window, the real scale on a high-DPI one.
local function pixelsPerUnit()
local g = love and love.graphics
if not (g and g.getDimensions and g.getPixelDimensions) then return 1 end
local uw = tonumber((g.getDimensions()))
local pw = tonumber((g.getPixelDimensions()))
if not uw or not pw or uw <= 0 or pw <= 0 then return 1 end
return pw / uw
end
-- The window size in LOVE UNITS that puts 160*v x 144*v PHYSICAL pixels on
-- screen.
function FaithfulRes.size(v)
v = FaithfulRes.normalize(v)
if v == 0 then return nil end
local ratio = pixelsPerUnit()
return math.floor(FaithfulRes.WIDTH * v / ratio + 0.5),
math.floor(FaithfulRes.HEIGHT * v / ratio + 0.5)
end
-- Push the lock into the live window. Returns true when the window is
-- locked afterwards.
function FaithfulRes.apply(v)
if FaithfulRes.isMobile() then return false end
if not love or not love.window or not love.window.setMode
or not love.window.getMode then
return false
end
v = FaithfulRes.normalize(v)
local curW, curH, flags = love.window.getMode()
flags = flags or {}
if v == 0 then
-- only touch the window if we were the one holding it: an OFF setting on
-- boot must not resize a window the player sized themselves
if not FaithfulRes.locked then return false end
flags.resizable = true
flags.minwidth, flags.minheight = FaithfulRes.MIN_W, FaithfulRes.MIN_H
love.window.setMode(curW, curH, flags)
FaithfulRes.locked = false
return false
end
local w, h = FaithfulRes.size(v)
-- An exact size and a desktop-fullscreen mode cannot both hold. The lock
-- is the more specific request, so it wins and drops fullscreen; VIDEO MODE
-- reads BORDERLESS until the player changes it, which then releases this.
flags.fullscreen = false
-- resizing by hand would silently break the lock, and nothing re-applies it
-- (there is no love.resize handler -- the renderer re-reads the size every
-- frame), so the window is fixed while locked rather than left draggable.
flags.resizable = false
flags.minwidth, flags.minheight = w, h
love.window.setMode(w, h, flags)
FaithfulRes.locked = true
return true
end
function FaithfulRes.applyOptions(opts)
return FaithfulRes.apply(opts and opts.faithfulRes)
end
return FaithfulRes
+17 -4
View File
@@ -10,6 +10,7 @@ local MAX_ACCUM = 0.25 -- avoid spiral of death after a stall
function FixedStep:init(callback)
self.accum = 0
self.callback = callback
self.suppressCatchup = false
end
-- The anti-spiral clamp doubles as a steps-per-frame ceiling (0.25s = 15
@@ -20,6 +21,16 @@ end
FixedStep.maxAccum = MAX_ACCUM
function FixedStep:update(dt)
-- A hitch's oversized dt lands on the frame AFTER discardCatchup was
-- called (the hitch itself already ran inside the current step); absorb
-- that one frame as a single step instead of the normal accumulator so
-- the burst it would otherwise release doesn't play out as a slide.
if self.suppressCatchup then
self.suppressCatchup = false
self.accum = 0
self.callback(self.STEP)
return
end
self.accum = math.min(self.accum + dt, self.maxAccum or MAX_ACCUM)
while self.accum >= self.STEP do
self.accum = self.accum - self.STEP
@@ -27,12 +38,14 @@ function FixedStep:update(dt)
end
end
-- Drop any pending catch-up steps. A hitch inside one logic step (map
-- seam setMap / song start) makes the next real-time dt huge; without this
-- the while-loop above would advance many walk frames before the next
-- draw, which looks like a slide with no leg animation (issue #93).
-- Drop any pending catch-up steps and arm the one-frame clamp above. A
-- hitch inside one logic step (map seam setMap / song start) makes the
-- next real-time dt huge; without this the while-loop above would advance
-- many walk frames before the next draw, which looks like a slide with no
-- leg animation (issue #93).
function FixedStep:discardCatchup()
self.accum = 0
self.suppressCatchup = true
end
return FixedStep
+43
View File
@@ -265,6 +265,38 @@ end
-- exactly as the owning state computed it
local function sameZones(_, zones) return zones end
-- Dim alpha for a BATTLE BG "world" battle anywhere in the stack, or nil.
-- Same whole-stack rule as fillScaleInStack: a party menu or text box opened
-- during the battle must not drop the dim for a frame.
function Game.worldBgBattleDim(stack)
for i = #(stack and stack.states or {}), 1, -1 do
local state = stack.states[i]
if state and state.bgMode and state:bgMode() == "world" then
return state.BG_WORLD_DIM or 0.55
end
end
return nil
end
-- Does anything on the stack want the surface scaled to FILL the window
-- (aspect preserved, bars on the long axis) rather than sit at the fixed
-- integer scale?
--
-- Asked of the WHOLE stack, not just the top. For BATTLE SIZE "fill" that is
-- because the party menu, bag and text boxes a battle opens must not snap the
-- surface back to the fixed scale for a frame; the title screen and intro want
-- it unconditionally, since neither has a world behind it and neither has any
-- reason to sit in a small box in the middle of a large window.
function Game.fillScaleInStack(stack)
for i = #(stack and stack.states or {}), 1, -1 do
local state = stack.states[i]
if state and state.wantsFillScale and state:wantsFillScale() then
return true
end
end
return false
end
-- A wide battle owns the surface until it leaves the stack. The party,
-- bag, choice and text states it opens still draw their original 160px UI,
-- but the canvas must not snap to 160px between those states.
@@ -319,6 +351,14 @@ function Game:draw()
else
Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT)
end
-- BATTLE SIZE: scale the battle surface to the window instead of the
-- classic integer letterbox. Read from the whole stack, not just the top,
-- so a party menu or text box opened mid-battle keeps the same surface.
Renderer.uiFill = Game.fillScaleInStack(self.stack)
-- BATTLE BG "world": dim the overworld the battle is drawn over. Read off
-- the stack for the same reason as uiFill above -- a prompt opened during
-- the battle must not drop the dim for a frame.
Renderer.battleDim = Game.worldBgBattleDim(self.stack)
Renderer:beginFrame(worldBelow)
for i = self.stack:visibleBase(), #self.stack.states do
local state = self.stack.states[i]
@@ -658,6 +698,9 @@ function Game:applyOptions(opts)
-- returns true when a persisted GBC FX level was cleared on mobile
local gbcCleared = require("src.render.GBCFX").applyOptions(opts)
require("src.core.VideoMode").applyOptions(opts)
-- after VideoMode: a faithful-resolution lock is an exact window size, so
-- it has to be the last word on the window (it drops fullscreen to hold)
require("src.core.FaithfulRes").applyOptions(opts)
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
-- fpsCap key pace at the standard rate (issue #88)
require("src.core.FrameCap").applyOptions(opts)
+12
View File
@@ -219,6 +219,15 @@ function SaveData.defaultOptions()
-- battle screen composition: og (the 160x144 original) | wide
-- (304x144, src/battle/WideBattle.lua)
battleLayout = "og",
-- BATTLE SIZE: "fixed" = the classic integer-scaled letterbox; "fill" =
-- scale the battle surface to the window so it fills vertically. See
-- BattleState:wantsFillScale.
battleFit = "fixed",
-- BATTLE BG: what fills the screen behind and around the battle.
-- "white" = the display mode's paper shade (the classic look),
-- "black" = plain black bars, "world" = the frozen overworld showing
-- through, dimmed. See BattleState:bgMode.
battleBg = "white",
ruleset = "gen1_faithful",
-- 0-7 like the GB's NR50 master volume
musicVol = 7,
@@ -239,6 +248,9 @@ function SaveData.defaultOptions()
voidFill = "trees",
-- windowed | borderless (desktop fullscreen); ignored on mobile
videoMode = "windowed",
-- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see
-- src/core/FaithfulRes.lua. Ignored on mobile.
faithfulRes = 0,
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
fpsCap = 60,
-- graphics performance tier: auto | high | balanced | low. "auto"
+197
View File
@@ -0,0 +1,197 @@
-- 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
-- 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
-- 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.TRAINER_SLIDE_COL = 2 -- core.asm:1267-1268, per column
-- HP bar (engine/gfx/hp_bar.asm) ---------------------------------------------
--
-- UpdateHPBar steps ONE HP point per loop iteration (:81-120). Each
-- iteration pays:
-- * 1 frame in UpdateHPBar_PrintHPNumber's DelayFrame (:234) -- but only
-- when wHPBarType is nonzero (:207-209), i.e. the player's own HUD and
-- the party menu, never the enemy HUD; and
-- * 2 frames per pixel the bar actually moved, from
-- UpdateHPBar_AnimateHPBar's `ld c, 2 / call DelayFrames` (:147-148).
-- The drain closes with one more pixel step and a Delay3 (:133-135).
--
-- So a player-side drain of D HP across P pixels costs D + 2P + 6 frames,
-- while the same drain on the enemy HUD costs only 2P + 5. A 150 HP mon
-- losing everything takes 150 + 96 + 6 = 252 frames on hardware.
Timing.HP_BAR_PIXELS = 48 -- the bar is 48 px wide (GetHPBarLength)
Timing.HP_BAR_PIXEL_STEP = 2 -- frames per pixel of bar movement
Timing.HP_BAR_HP_STEP = 1 -- frames per HP point, player-side HUD only
-- Pixels the bar shows for `hp` out of `maxHP`. GetHPBarLength floors the
-- 48ths and clamps the result to at least 1 for any nonzero HP
-- (engine/gfx/hp_bar.asm:42-45); an empty bar is 0.
function Timing.hpBarPixels(hp, maxHP)
if not maxHP or maxHP <= 0 then return 0 end
if hp <= 0 then return 0 end
local px = math.floor(hp * Timing.HP_BAR_PIXELS / maxHP)
if px < 1 then px = 1 end
return px
end
-- Frames one single-HP step of the drain costs: the per-HP number print
-- (player side only) plus two frames for every pixel that step moved.
function Timing.hpDrainStepFrames(fromHP, toHP, maxHP, playerSide)
local pixels = math.abs(Timing.hpBarPixels(toHP, maxHP)
- Timing.hpBarPixels(fromHP, maxHP))
local frames = pixels * Timing.HP_BAR_PIXEL_STEP
if playerSide then frames = frames + Timing.HP_BAR_HP_STEP end
return frames
end
-- After the loop, .animateHPBarDone prints the number one last time, runs
-- AnimateHPBar for a single pixel and falls into Delay3 (hp_bar.asm:132-135)
-- -- so the tail costs 6 frames on the player's HUD and 5 on the enemy's.
function Timing.hpDrainClosingFrames(playerSide)
local frames = Timing.HP_BAR_PIXEL_STEP + Timing.DELAY3
if playerSide then frames = frames + Timing.HP_BAR_HP_STEP end
return frames
end
-- Total cost of draining `fromHP` to `toHP`, for tests and for anything that
-- needs to budget the whole animation up front.
function Timing.hpDrainFrames(fromHP, toHP, maxHP, playerSide)
local total = 0
local hp = fromHP
local dir = (toHP < fromHP) and -1 or 1
while hp ~= toHP do
local nextHP = hp + dir
total = total + Timing.hpDrainStepFrames(hp, nextHP, maxHP, playerSide)
hp = nextHP
end
return total + Timing.hpDrainClosingFrames(playerSide)
end
return Timing