From aedc63c40d9623b26c142318dae834c959840ba5 Mon Sep 17 00:00:00 2001 From: spiritsnails <307422241+spiritsnails@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:52:34 -0600 Subject: [PATCH 1/5] 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. --- docs/timing-parity.md | 382 ++++++++++++++++++ src/battle/BattleState.lua | 230 +++++++++-- src/battle/EffectRegistry.lua | 25 ++ src/battle/WideBattle.lua | 4 +- src/core/FaithfulRes.lua | 129 +++++++ src/core/Game.lua | 43 +++ src/core/SaveData.lua | 12 + src/core/Timing.lua | 197 ++++++++++ src/render/BattleTransition.lua | 182 +++++++-- src/render/Renderer.lua | 295 +++++++++++--- src/render/TextBox.lua | 32 ++ src/render/Transition.lua | 99 ++++- src/ui/ChoiceBox.lua | 31 +- src/ui/IntroMovie.lua | 4 + src/ui/Menu.lua | 11 + src/ui/OptionsMenu.lua | 49 +++ src/ui/StartMenu.lua | 7 +- src/ui/TitleState.lua | 6 + src/world/OverworldController.lua | 27 ++ tests/engine/battle_fit_option.lua | 81 ++++ tests/engine/faithful_res.lua | 135 +++++++ tests/engine/timing_parity.lua | 598 +++++++++++++++++++++++++++++ tests/engine/title_fill_scale.lua | 50 +++ 23 files changed, 2515 insertions(+), 114 deletions(-) create mode 100644 docs/timing-parity.md create mode 100644 src/core/FaithfulRes.lua create mode 100644 src/core/Timing.lua create mode 100644 tests/engine/battle_fit_option.lua create mode 100644 tests/engine/faithful_res.lua create mode 100644 tests/engine/timing_parity.lua create mode 100644 tests/engine/title_fill_scale.lua diff --git a/docs/timing-parity.md b/docs/timing-parity.md new file mode 100644 index 00000000..c8bd0fb5 --- /dev/null +++ b/docs/timing-parity.md @@ -0,0 +1,382 @@ +# 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 | +| --- | --- | --- | --- | --- | --- | +| `` 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** | +| `` 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 | +| `` pre-input hold | **3** | `home/text.asm:263-267` | 0 | 3 | +| `` 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. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index dfa915e7..2830497f 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -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 diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index bc1544d5..e9d978e4 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -9,9 +9,22 @@ local MoveEffects = require("src.battle.MoveEffects") local Runtime = require("src.mods.Runtime") local StatusRegistry = require("src.battle.StatusRegistry") local Strings = require("src.core.Strings") +local Timing = require("src.core.Timing") local EffectRegistry = {} +-- A move that misses -- or that registers as missed, which in Gen 1 includes +-- a type immunity and damage floored to zero -- skips its animation and +-- falls into PlayerCheckIfFlyOrChargeEffect's `ld c, 30 / call DelayFrames` +-- (engine/battle/core.asm:3155-3158 and :3185; enemy twin at :5588) before +-- anything is printed. EXPLODE_EFFECT is the exception: core.asm:3157 +-- branches it to PlayPlayerMoveAnimation instead, so it pays the animation +-- rather than the hold -- the same condition that gates cancelMoveAnim. +local function missBeat(battle, record) + if record and record.explode then return end + battle:waitNext(Timing.MOVE_STATUS_OR_MISS) +end + -- pokered's / text macros print "Enemy " before the enemy -- mon's nickname (home/text.asm PlaceMoveUsersName) local function displayName(b) @@ -92,6 +105,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) if target.invulnerable and not neverMiss then -- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed) if not (record and record.explode) then battle:cancelMoveAnim() end + missBeat(battle, record) battle:sayNext(Strings("%s's\nattack missed!", displayName(user))) -- MoveHitTest's INVULNERABLE branch sets the same wMoveMissed as a -- failed accuracy roll (core.asm:5260), and the miss handler still @@ -120,6 +134,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) if not battle:accuracyRoll(move, user, target) then -- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed) if not (record and record.explode) then battle:cancelMoveAnim() end + missBeat(battle, record) battle:sayNext(Strings("%s's\nattack missed!", displayName(user))) -- Jump Kick crash, Explode self-destruct if record and record.onMiss then record.onMiss(ctx, "accuracy") end @@ -147,6 +162,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) end if not counterable or (battle.lastDamage or 0) == 0 then battle:cancelMoveAnim() + missBeat(battle, record) battle:sayNext(Strings("%s's\nattack missed!", displayName(user))) return end @@ -170,6 +186,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) if info.typeMult == 0 then -- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim if not (record and record.explode) then battle:cancelMoveAnim() end + missBeat(battle, record) battle:sayNext(Strings("It doesn't affect\n%s!", displayName(target))) if record and record.onMiss then record.onMiss(ctx, "immune") end return @@ -177,6 +194,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) if info.missed then -- 0.25x floored the damage to zero: the original registers a miss if not (record and record.explode) then battle:cancelMoveAnim() end + missBeat(battle, record) battle:sayNext(Strings("%s's\nattack missed!", displayName(user))) if record and record.onMiss then record.onMiss(ctx, "floored") end return @@ -231,6 +249,13 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- every strike -- damage was only rolled once if info.crit then battle:sayNext(Strings("Critical hit!")) end if info.ohko then battle:sayNext(Strings("One-hit KO!")) end + -- PrintCriticalOHKOText closes with `ld c, 20 / jp DelayFrames` at its + -- .done label (core.asm:3812-3814) -- and the no-crit path jumps to that + -- same label (:3799), so this hold is paid on EVERY landed hit, not just + -- critical ones. It sits between the crit text and DisplayEffectiveness + -- (:3228-3229), which is where the beat before "It's super effective!" + -- comes from. + battle:waitNext(Timing.CRIT_OHKO_TEXT) if info.typeMult > 10 then battle:sayNext(Strings("It's super\neffective!")) elseif info.typeMult < 10 then diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index fa455ae6..81f8e476 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -320,7 +320,9 @@ function WideBattle.draw(battle) if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then sx = battle.frame % 4 < 2 and 2 or -2 end - local slide = (battle.introSlide or 0) * 2 + -- same 2 px/frame silhouette slide the 160px layout uses + local slide = (battle.introSlide or 0) + * require("src.core.Timing").BATTLE_SLIDE_PX_PER_FRAME -- Each side keeps its original sprite pixels and placement math: the two -- 160x144 OAM regions are translated apart and clipped into the wider diff --git a/src/core/FaithfulRes.lua b/src/core/FaithfulRes.lua new file mode 100644 index 00000000..c7fc4946 --- /dev/null +++ b/src/core/FaithfulRes.lua @@ -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 diff --git a/src/core/Game.lua b/src/core/Game.lua index 101a91ed..064974ed 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -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) diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index cc730b58..caf35205 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -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" diff --git a/src/core/Timing.lua b/src/core/Timing.lua new file mode 100644 index 00000000..f00d3d43 --- /dev/null +++ b/src/core/Timing.lua @@ -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 diff --git a/src/render/BattleTransition.lua b/src/render/BattleTransition.lua index ab5d8e29..aa214344 100644 --- a/src/render/BattleTransition.lua +++ b/src/render/BattleTransition.lua @@ -32,10 +32,23 @@ local FLASH_CYCLES = 3 -- InitBattleCommon decompresses the front pic (core.asm:6694-6730), and -- SlidePlayerAndEnemySilhouettesOnScreen rebuilds the whole tilemap between -- DisableLCD and EnableLCD (core.asm:9-49) before anything moves. This port --- has no load to hide behind, so the hold has to be explicit (#315). 30 is a --- frame budget for that work, not a number pokered states; retune this one --- constant against a reference recording if it reads long or short. -local BLACK_HOLD = 30 +-- has no load to hide behind, so the hold has to be explicit (#315). This is +-- a frame budget for that work, not a number pokered states. +-- +-- 60 comes from pokered-c (BTRANS_BLACK_HOLD_FRAMES), which itemized the +-- derivable floor at ~13 frames -- LoadHpBarAndStatusTilePatterns 4, +-- LoadHudTilePatterns 2, ClearScreen's Delay3 3, the DisableLCD LY wait 1, +-- Delay3 after EnableLCD 3 -- and then noted that the two sprite +-- decompressors (UncompressSpriteFromDE for the 7x7 front pic, +-- LoadPlayerBackPic's uncompress + ScaleSpriteByTwo) are bit-level RLE/delta +-- decoders whose cost cannot be cycle-counted from the asm at all. So the +-- derivation bottoms out around 25-30 with an unbounded remainder, and 60 +-- was set by ear against the real ROM ("a solid second") and confirmed as +-- ~95% right rather than frame-matched. The credible range is 30-60; do NOT +-- "correct" this down toward the floor on the strength of the derivation, +-- because the omitted decompressors are exactly the unbounded part. It +-- wants a frame-by-frame capture against hardware to pin exactly. +local BLACK_HOLD = 60 local TILE = 8 local COLS, ROWS = 160 / TILE, 144 / TILE -- 20 x 18 tiles @@ -106,11 +119,12 @@ end -- HalfCircle2 continues (1,11) down under the bottom back to (18,11)). -- arms = 1 (Circle, halves in sequence) or 2 (DoubleCircle, both halves -- at once, so opposite arms) -local function sweepOrder(arms) - local cx, cy = COLS / 2, ROWS / 2 +local function sweepOrder(arms, cols, rows) + cols, rows = cols or COLS, rows or ROWS + local cx, cy = cols / 2, rows / 2 local tiles = {} - for y = 0, ROWS - 1 do - for x = 0, COLS - 1 do + for y = 0, rows - 1 do + for x = 0, cols - 1 do local a = math.atan2(cy - (y + 0.5), x + 0.5 - cx) if a < 0 then a = a + 2 * math.pi end if arms == 2 then a = a % math.pi end @@ -121,18 +135,116 @@ local function sweepOrder(arms) return tiles end +-- --------------------------------------------------------------------- +-- Arbitrary-grid wipes, for the surface OUTSIDE the classic letterbox +-- --------------------------------------------------------------------- +-- +-- The builders above reproduce the ROM's exact walks on its 20x18 tilemap, +-- overrun and all, and stay the authority inside the 160x144 box. A zoomed +-- or windowed surface has more grid than the Game Boy ever had, and no +-- hardware behaviour to be faithful to out there -- but filling it with a +-- generic square cascade made a spiral read as "a spiral in a box, with +-- something else happening around it". These generalise the same shapes to +-- whatever grid the window works out to so the whole surface wipes as one +-- figure. + +-- perimeter inward, counterclockwise, starting down the left edge -- the +-- direction BattleTransition_InwardSpiral walks +local function spiralInGrid(cols, rows) + local order = {} + local x0, y0, x1, y1 = 0, 0, cols - 1, rows - 1 + while x0 <= x1 and y0 <= y1 do + for y = y0, y1 do order[#order + 1] = { x0, y } end + x0 = x0 + 1 + if x0 > x1 then break end + for x = x0, x1 do order[#order + 1] = { x, y1 } end + y1 = y1 - 1 + if y0 > y1 then break end + for y = y1, y0, -1 do order[#order + 1] = { x1, y } end + x1 = x1 - 1 + if x0 > x1 then break end + for x = x1, x0, -1 do order[#order + 1] = { x, y0 } end + y0 = y0 + 1 + end + return order +end + +-- the outward spiral is the same walk read from the middle out +local function spiralOutGrid(cols, rows) + local inward = spiralInGrid(cols, rows) + local order = {} + for i = #inward, 1, -1 do order[#order + 1] = inward[i] end + return order +end + +local GRID_BUILDERS = { + spiralin = spiralInGrid, + spiralout = spiralOutGrid, + circle = function(c, r) return sweepOrder(1, c, r) end, + doublecircle = function(c, r) return sweepOrder(2, c, r) end, +} + +-- Tile order for `style` on an arbitrary cols x rows grid, or nil for the +-- styles whose shape is plain geometry (stripes / shrink / split) and which +-- the caller extends with rectangles instead. Cached per style+size: the +-- window grid only changes on a resize or a zoom step. +local orderFor -- defined below; the authentic 20x18 builders + +local gridCache = {} +function BattleTransition.gridOrder(style, cols, rows) + local build = GRID_BUILDERS[style] + if not build or cols < 1 or rows < 1 then return nil end + -- At exactly the Game Boy's grid the ROM's own walk is the answer, overrun + -- and all -- so an unzoomed window is the classic wipe, not a lookalike. + if cols == COLS and rows == ROWS then return orderFor(style, nil) end + local key = style .. ":" .. cols .. "x" .. rows + local hit = gridCache[key] + if hit == nil then + hit = build(cols, rows) or false + gridCache[key] = hit + end + return hit or nil +end + +-- Wipe lengths, taken from pokered-c's battle_transition.c frame budget -- +-- derived from battle_transitions.asm and then checked on a live +-- side-by-side against the ROM. Each wipe is `steps x frames-per-step`: +-- +-- DoubleCircle 10 x 3 = 30 SpiralOut 360 fills / 3 per frame = 120 +-- Circle 20 x 3 = 60 HStripes 20 x 3 = 60 +-- Shrink 9 x 6 = 54 VStripes 18 x 3 = 54 +-- Split 9 x 6 = 54 (asm:386-392 and :418-424) +-- +-- The port used a flat 40/24 for all eight, which ran every wipe between +-- 1.5x and 3x too fast -- the single biggest reason a battle used to open +-- so much more abruptly here than on hardware. +-- +-- The inward spiral is the one that bites. It writes one tile per +-- iteration and calls BattleTransition_TransferDelay3 every seventh tile +-- (wInwardSpiralUpdateScreenCounter counts 7 down to 0), and that helper is +-- `ld a,1 / ldh [hAutoBGTransferEnabled] / call Delay3 / xor a / ldh [...]` +-- (battle_transitions.asm:619) -- THREE frames, not a one-frame transfer. +-- Reading it as one frame runs the whole spiral 3x too fast; pokered-c +-- caught that against the real ROM. Deriving the length from the path we +-- actually walk keeps the cadence right if the order ever changes. +local SPIRAL_IN_TILES_PER_STEP = 7 +local SPIRAL_IN_STEP_FRAMES = 3 -- TransferDelay3 +local SPIRAL_IN_FRAMES = math.ceil(#inwardSpiralOrder() + / SPIRAL_IN_TILES_PER_STEP) + * SPIRAL_IN_STEP_FRAMES + -- The eight wipes as records: frames is the wipe length, flash marks the -- two circle wipes that call BattleTransition_FlashScreen first. new() -- reads them, and the transitions registry serves the same table. BattleTransition.STYLES = { - doublecircle = { kind = "wipe", frames = 40, flash = true }, - spiralin = { kind = "wipe", frames = 40 }, - circle = { kind = "wipe", frames = 40, flash = true }, - spiralout = { kind = "wipe", frames = 40 }, - hstripes = { kind = "wipe", frames = 24 }, - shrink = { kind = "wipe", frames = 24 }, - vstripes = { kind = "wipe", frames = 24 }, - split = { kind = "wipe", frames = 24 }, + doublecircle = { kind = "wipe", frames = 30, flash = true }, + spiralin = { kind = "wipe", frames = SPIRAL_IN_FRAMES }, + circle = { kind = "wipe", frames = 60, flash = true }, + spiralout = { kind = "wipe", frames = 120 }, + hstripes = { kind = "wipe", frames = 60 }, + shrink = { kind = "wipe", frames = 54 }, + vstripes = { kind = "wipe", frames = 54 }, + split = { kind = "wipe", frames = 54 }, } -- the eight wipes plus Transition's two warp fades: one registrant owns @@ -164,7 +276,7 @@ local BUILTIN_ORDERS = { -- A registered style may bring its own tile order (a list of {x, y}, or a -- function returning one); the four built-in orders are the defaults for -- the styles that have always had them. -local function orderFor(style, def) +function orderFor(style, def) if ORDERS[style] == nil then local order = def and def.order if type(order) == "function" then @@ -235,6 +347,15 @@ function BattleTransition:draw() local v = FLASH_STEPS[step] if v ~= 0 then local shade = v > 0 and 0 or 1 + -- The flash is a palette write (rBGP), so on hardware it tints every + -- pixel the LCD shows. Hand it to the renderer as a screen-space veil + -- so it covers the whole surface at any zoom; only the headless and + -- no-renderer paths fall back to filling the 160x144 box. + local r = self.game and self.game.renderer + if r then + r.screenVeil = { shade, math.abs(v) } + return + end love.graphics.setColor(shade, shade, shade, math.abs(v)) love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.setColor(1, 1, 1, 1) @@ -242,22 +363,35 @@ function BattleTransition:draw() return end - love.graphics.setColor(0, 0, 0, 1) local prog = math.min(1, self.t / self.wipeLen) - -- Cascade black 8x8 blocks across the window area *outside* the classic - -- 160x144 wipe square, in lockstep with the OG wipe progress. Renderer - -- paints them in screen space after the world blit (see endFrame). - local renderer = self.game and self.game.renderer - if renderer then renderer.battleCascadeProg = prog end local style = self.style - -- a registered style may draw itself; the eight built-ins do not + -- a registered style may draw itself; the eight built-ins do not. A custom + -- draw owns the 160x144 UI canvas as it always has. if self.def and self.def.draw then + love.graphics.setColor(0, 0, 0, 1) self.def.draw(self, prog) love.graphics.setColor(1, 1, 1, 1) return end + -- With a renderer the wipe is drawn ONCE over the whole surface in screen + -- space (Renderer:drawBattleWipe), on a tile grid anchored to the letterbox + -- and extended outward at the same tile size. That makes it a single + -- continuous figure: the spiral starts at the outermost edge of the window + -- and works inward, instead of one spiral inside the letterbox running + -- alongside a second one outside it. Nothing is stretched -- the pattern is + -- continued with more tiles, not scaled-up pixels -- so at 1x the grid works + -- out to exactly 20x18 and this is the classic wipe unchanged. + local renderer = self.game and self.game.renderer + if renderer then + renderer.battleWipe = { style = style, prog = prog } + return + end + + -- headless / no renderer: the classic 160x144 path + love.graphics.setColor(0, 0, 0, 1) + local order = orderFor(style, self.def) if order then -- tile-order wipes: spiral / circle sweeps diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index e3513c1d..0a47fc86 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -127,6 +127,35 @@ function Renderer:fitScale() return math.max(1, math.floor(math.min(pw / w, ph / h))) end +-- Integer framebuffer pixels per GB pixel for the UI pass. +-- +-- Survey zoom only ever scaled the world: the UI kept blitting at fitScale, +-- so zooming out left a full-size dialogue box over a shrunken world, which +-- reads as the UI growing. Stepping the UI down with the zoom keeps the two +-- in proportion. Whole integers only, so a UI pixel stays a whole number of +-- screen pixels and the font does not resample; and never below half of +-- fitScale, because past that the text stops being readable. +-- +-- Zooming IN does not scale the UI up -- a dialogue box larger than the +-- classic one has no reference to be faithful to, and the letterbox it sits +-- in does not grow either. +function Renderer:uiScale() + local S = self:fitScale() + local off = Zoom.offset or 0 + -- Only follow the zoom when a world is actually on screen. Survey zoom is + -- an OVERWORLD control; the title screen, the intro and the credits show no + -- world at all, and shrinking them to match a zoom level the player set for + -- the map is meaningless. worldActive is this frame's answer -- beginFrame + -- clears it and beginWorldPass sets it -- so a state that draws no world + -- keeps the full fit scale. + if not self.worldActive then return S end + if off >= 0 then return S end + local floorS = math.ceil(S / 2) -- at most a 50% reduction + local s = S + off -- one integer step per zoom-out step + if s < floorS then s = floorS end + return math.max(1, s) +end + -- the native-pixel UI surface in use right now function Renderer:uiSize() return self.uiWidth or self.WIDTH, self.uiHeight or self.HEIGHT @@ -200,8 +229,14 @@ function Renderer:beginFrame(transparent) -- warp-fade overlay from Transition (issue #121); cleared each frame so -- a popped transition cannot leave a sticky black veil self.worldFadeAlpha = nil - -- battle-transition cascade outside the 160x144 wipe (BattleTransition) - self.battleCascadeProg = nil + -- battle-transition wipe, drawn over the whole surface (BattleTransition) + self.battleWipe = nil + -- whole-surface veil in screen space (battle-transition flash, the + -- fade in from white after a battle) -- covers the window, not just the + -- 160x144 letterbox + self.screenVeil = nil + -- edge-anchored UI regions, re-declared by their elements each frame + self.uiAnchors = nil -- last frame's trueColor rects and sprite redraws go before anything -- draws this one PaletteFX.clearTrueColor() @@ -218,44 +253,94 @@ function Renderer:beginFrame(transparent) end end --- Black 8x8 (scaled) blocks cascading outward from the classic GB letterbox --- into the surrounding window, matching BattleTransition wipe progress. --- Tiles that sit entirely inside the 160x144 square are left to the OG wipe. +-- The battle-transition wipe, drawn once over the WHOLE surface in screen +-- space rather than as a 160x144 wipe plus a separate fill around it. +-- +-- The tile grid is anchored on the letterbox and extended outward at the same +-- tile size, so the figure is continuous: a spiral begins at the outermost +-- edge of the window and works inward through the letterbox to the middle. +-- Nothing is stretched -- the pattern is continued with more tiles, not +-- scaled-up pixels -- and at 1x the grid works out to exactly 20x18, where +-- BattleTransition.gridOrder hands back the ROM's own walk, so an unzoomed +-- window is the classic wipe unchanged. +-- -- Sx/Sy are LOVE-unit scales (Sy defaults to Sx on uniform surfaces). -function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, Sx, Sy) - if not prog or prog <= 0 then return end +function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy) + if not wipe or not wipe.prog or wipe.prog <= 0 then return end Sy = Sy or Sx - local TILE_W, TILE_H = 8 * Sx, 8 * Sy - if TILE_W < 1 then TILE_W = 1 end - if TILE_H < 1 then TILE_H = 1 end - local cols = math.ceil(ww / TILE_W) - local rows = math.ceil(wh / TILE_H) - local cx, cy = ox + vpw / 2, oy + vph / 2 - local order = {} - for row = 0, rows - 1 do - for col = 0, cols - 1 do - local x, y = col * TILE_W, row * TILE_H - -- any tile with area outside the letterbox participates - if x < ox or y < oy or x + TILE_W > ox + vpw or y + TILE_H > oy + vph then - local dist = math.max(math.abs(x + TILE_W / 2 - cx), - math.abs(y + TILE_H / 2 - cy)) - order[#order + 1] = { x, y, dist } - end - end - end - if #order == 0 then return end - table.sort(order, function(a, b) - if a[3] ~= b[3] then return a[3] < b[3] end - if a[2] ~= b[2] then return a[2] < b[2] end - return a[1] < b[1] - end) - local n = math.floor(#order * math.min(1, prog) + 1e-6) - if prog >= 1 then n = #order end + local TW, TH = 8 * Sx, 8 * Sy + if TW < 1 then TW = 1 end + if TH < 1 then TH = 1 end + local prog = math.min(1, wipe.prog) + love.graphics.setColor(0, 0, 0, 1) love.graphics.setScissor(0, 0, ww, wh) - for i = 1, n do - local t = order[i] - love.graphics.rectangle("fill", t[1], t[2], TILE_W, TILE_H) + + if prog >= 1 then + love.graphics.rectangle("fill", 0, 0, ww, wh) + love.graphics.setScissor() + love.graphics.setColor(1, 1, 1, 1) + return + end + + -- whole-tile padding out to each window edge, keeping the grid in phase + -- with the letterbox's tiles + local padL = math.max(0, math.ceil(ox / TW)) + local padT = math.max(0, math.ceil(oy / TH)) + local padR = math.max(0, math.ceil((ww - ox - vpw) / TW)) + local padB = math.max(0, math.ceil((wh - oy - vph) / TH)) + local lbCols = math.max(1, math.floor(vpw / TW + 0.5)) + local lbRows = math.max(1, math.floor(vph / TH + 0.5)) + local cols, rows = padL + lbCols + padR, padT + lbRows + padB + local x0, y0 = ox - padL * TW, oy - padT * TH + + -- required here rather than at the top: BattleTransition reaches the + -- renderer through game.renderer at draw time, and a module-level require + -- would put the two files in a load cycle + local BattleTransition = require("src.render.BattleTransition") + local order = BattleTransition.gridOrder(wipe.style, cols, rows) + + if order then + local n = math.floor(#order * prog + 1e-6) + for i = 1, n do + local t = order[i] + love.graphics.rectangle("fill", x0 + t[1] * TW, y0 + t[2] * TH, TW, TH) + end + else + -- shrink / split / the stripes are geometry, not a walk: the same shapes + -- measured against the window instead of the letterbox + local style = wipe.style + if style == "hstripes" then + local w = ww * prog + for row = 0, rows - 1 do + local y = y0 + row * TH + if row % 2 == 0 then + love.graphics.rectangle("fill", 0, y, w, TH) + else + love.graphics.rectangle("fill", ww - w, y, w, TH) + end + end + elseif style == "vstripes" then + local h = wh * prog + for col = 0, cols - 1 do + local x = x0 + col * TW + if col % 2 == 0 then + love.graphics.rectangle("fill", x, 0, TW, h) + else + love.graphics.rectangle("fill", x, wh - h, TW, h) + end + end + elseif style == "shrink" then + local h, w = wh / 2 * prog, ww / 2 * prog + love.graphics.rectangle("fill", 0, 0, ww, h) + love.graphics.rectangle("fill", 0, wh - h, ww, h) + love.graphics.rectangle("fill", 0, 0, w, wh) + love.graphics.rectangle("fill", ww - w, 0, w, wh) + else -- split: a black cross growing out of the centre in both axes + local h, w = wh / 2 * prog, ww / 2 * prog + love.graphics.rectangle("fill", 0, wh / 2 - h, ww, h * 2) + love.graphics.rectangle("fill", ww / 2 - w, 0, w * 2, wh) + end end love.graphics.setScissor() love.graphics.setColor(1, 1, 1, 1) @@ -509,6 +594,38 @@ function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy, love.graphics.setShader() end +-- Take a rect out of a list of rects, splitting each overlapped one into up +-- to four pieces. Used to vacate an anchored UI region from the letterbox +-- blit, so the element is drawn at its anchor and not also in place. +local function subtractRect(list, x, y, w, h) + local out = {} + local x2, y2 = x + w, y + h + for _, r in ipairs(list) do + local rx, ry, rw, rh = r[1], r[2], r[3], r[4] + local rx2, ry2 = rx + rw, ry + rh + if x2 <= rx or x >= rx2 or y2 <= ry or y >= ry2 then + out[#out + 1] = r -- disjoint + else + if ry < y then out[#out + 1] = { rx, ry, rw, y - ry } end + if y2 < ry2 then out[#out + 1] = { rx, y2, rw, ry2 - y2 } end + local ty, ty2 = math.max(ry, y), math.min(ry2, y2) + if rx < x then out[#out + 1] = { rx, ty, x - rx, ty2 - ty } end + if x2 < rx2 then out[#out + 1] = { x2, ty, rx2 - x2, ty2 - ty } end + end + end + return out +end + +-- A UI element that should sit against a screen edge rather than inside the +-- centred letterbox. Declared during the element's own draw, in UI-canvas +-- pixels, and consumed by endFrame this frame only. +-- anchor: "bottom" | "topright" | "topleft" | "bottomright" +function Renderer:setUIAnchor(x, y, w, h, anchor) + self.uiAnchors = self.uiAnchors or {} + self.uiAnchors[#self.uiAnchors + 1] = + { x = x, y = y, w = w, h = h, anchor = anchor } +end + -- zones: optional list of SGB palette regions (see PaletteFX) in -- 160x144 UI space, applied to the UI pass. worldZones: optional -- regions in world-canvas pixels (overworld survey zoom colors each @@ -529,6 +646,23 @@ function Renderer:endFrame(zones, worldZones) -- Snap the letterbox origin to a framebuffer pixel, then convert to units. local ox = math.floor((pw - uiw * Sp) / 2) / dpiX local oy = math.floor((ph - uih * Sp) / 2) / dpiY + -- The UI has its own scale: it steps down as the survey zoom goes out (see + -- uiScale), so it can be smaller than the world letterbox. Un-zoomed these + -- are identical to Sp/ox/oy and every rect below is what it always was. + local Up = self:uiScale() + -- BATTLE SIZE "fill" (Renderer.uiFill, set per frame by Game:draw): scale + -- the surface to the window rather than to whole GB pixels, so the battle + -- fills vertically at any zoom or window size. Fractional by nature -- a + -- GB pixel stops being a whole number of screen pixels, which is the trade + -- the setting exists to offer. Clamped on the horizontal too, so a narrow + -- window scales to fit instead of overflowing off both sides. + if self.uiFill then + Up = math.min(ph / uih, pw / uiw) + end + local Ux, Uy = Up / dpiX, Up / dpiY + local uvpw, uvph = uiw * Ux, uih * Uy + local uox = math.floor((pw - uiw * Up) / 2) / dpiX + local uoy = math.floor((ph - uih * Up) / 2) / dpiY local GBCFX = require("src.render.GBCFX") -- Forced mono/Classic modes still need a whole-screen zone when a state -- exposes no SGB packets (raw DMG canvas), so sendColors can remap. @@ -606,7 +740,12 @@ function Renderer:endFrame(zones, worldZones) local stack = ok and Game and Game.stack local base = stack and stack.visibleBase and stack:visibleBase() local state = base and stack.states and stack.states[base] - if state and state.letterboxWhite then + -- BATTLE BG "black" keeps the default black clear; "white" (and any + -- non-battle state that opts in) uses the paper shade. "world" never + -- reaches here -- it makes the battle non-opaque, so the world pass is + -- active and this whole branch is skipped. + if state and state.letterboxWhite + and not (state.bgMode and state:bgMode() == "black") then clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data) end end @@ -655,9 +794,6 @@ function Renderer:endFrame(zones, worldZones) love.graphics.rectangle("fill", 0, 0, ww, wh) love.graphics.setColor(1, 1, 1, 1) end - if self.battleCascadeProg then - self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, Sx, Sy) - end elseif self.worldActive then local sp = Zoom.scale(Sp) local sx, sy = sp / dpiX, sp / dpiY @@ -732,14 +868,81 @@ function Renderer:endFrame(zones, worldZones) love.graphics.rectangle("fill", 0, 0, ww, wh) love.graphics.setColor(1, 1, 1, 1) end - -- Battle transition: cascade black blocks into the area outside the - -- classic 160x144 wipe square (world still shows through until filled). - if self.battleCascadeProg then - self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, Sx, Sy) + end + -- BATTLE BG "world": the frozen overworld has just been composited and the + -- battle is about to blit over it. Dim the world first, so the battle + -- reads as the foreground instead of competing with a fully lit map. Goes + -- here rather than in the letterbox clear because with the world pass + -- active there is no clear -- the world already covers the surface. + if self.battleDim and self.battleDim > 0 then + love.graphics.setColor(0, 0, 0, self.battleDim) + love.graphics.rectangle("fill", 0, 0, ww, wh) + love.graphics.setColor(1, 1, 1, 1) + end + + -- UI: anchored regions against their screen edges, the rest in the classic + -- centred letterbox. With nothing anchored this is the single blit it has + -- always been. + local anchors = self.uiAnchors + if not anchors or #anchors == 0 then + blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, uox, uoy, uvpw, uvph) + else + local rest = { { uox, uoy, uvpw, uvph } } + local placed = {} + for _, a in ipairs(anchors) do + local dw, dh = a.w * Ux, a.h * Uy + -- Anchors are edge-RELATIVE: an element keeps its distance from the + -- canvas edge, measured against the screen edge instead. That is what + -- keeps a stack together -- the yes/no box sits 48px above the canvas + -- bottom, so bottom-anchoring lands it 48px above the screen bottom, + -- still directly over the dialogue box, rather than on top of it. + local gapR = (uiw - (a.x + a.w)) * Ux + local gapB = (uih - (a.y + a.h)) * Uy + local dx, dy + if a.anchor == "bottom" then + dx = uox + a.x * Ux -- horizontally it stays with the letterbox + dy = wh - gapB - dh + elseif a.anchor == "topright" then + dx = ww - gapR - dw + dy = a.y * Uy + else -- unknown anchor: leave it where it is + dx, dy = uox + a.x * Ux, uoy + a.y * Uy + end + placed[#placed + 1] = { a = a, dx = dx, dy = dy, dw = dw, dh = dh } + rest = subtractRect(rest, uox + a.x * Ux, uoy + a.y * Uy, dw, dh) + end + for _, r in ipairs(rest) do + blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, r[1], r[2], r[3], r[4]) + end + for _, p in ipairs(placed) do + -- shift the draw origin so canvas pixel (a.x, a.y) lands on (dx, dy). + -- The zone scissors are computed from the same origin, so an SGB + -- region travels with the element instead of staying in the letterbox. + blit(self.canvas, Ux, Uy, zones, Ux, Uy, + p.dx - p.a.x * Ux, p.dy - p.a.y * Uy, p.dx, p.dy, p.dw, p.dh) end end - -- UI stays in the classic centered GB letterbox - blit(self.canvas, Sx, Sy, zones, Sx, Sy, ox, oy, ox, oy, vpw, vph) + + -- The battle wipe covers the whole surface, letterbox included, so it goes + -- over the finished composite rather than under the UI blit. On hardware + -- it is the tilemap being overwritten -- there is nothing it does not cover. + if self.battleWipe then + self:drawBattleWipe(self.battleWipe, ww, wh, ox, oy, vpw, vph, Sx, Sy) + end + + -- Palette-register effects (BattleTransition_FlashScreen's rBGP writes, the + -- GBFadeInFromWhite after a battle) tint every pixel the LCD shows -- there + -- is no "outside the screen" on hardware for them to miss. So they are + -- painted here, over the finished composite, rather than into the 160x144 + -- UI canvas: at any zoom above 1x a letterbox-only veil left the + -- surrounding window untouched, which read as the effect happening inside + -- a window rather than to the whole screen. { shade, alpha }. + local veil = self.screenVeil + if veil and veil[2] > 0 then + love.graphics.setColor(veil[1], veil[1], veil[1], veil[2]) + love.graphics.rectangle("fill", 0, 0, ww, wh) + love.graphics.setColor(1, 1, 1, 1) + end if present then love.graphics.setCanvas() diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index e311d30b..85e457b3 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -8,6 +8,7 @@ local Font = require("src.render.Font") local Theme = require("src.ui.Theme") +local Timing = require("src.core.Timing") local TextBox = {} TextBox.__index = TextBox @@ -177,6 +178,13 @@ end function TextBox:update(dt) local input = self.game.input self.blink = (self.blink + 1) % 60 + -- A page or CONT advance blocks the whole box while the original's scroll + -- and clear run (src/core/Timing.lua TEXT_SCROLL_PAIR / TEXT_PAGE_CLEAR). + -- Nothing types and no input is read until it drains. + if (self.holdFrames or 0) > 0 then + self.holdFrames = self.holdFrames - 1 + return + end if self.done then if self.auto then if not self.autoStarted then @@ -244,6 +252,13 @@ function TextBox:update(dt) return end if self.waiting then + -- _ContText and Paragraph both print the â–¼ and run ProtectedDelay3 + -- before ManualTextScroll starts watching the joypad (home/text.asm:265, + -- :234), so the arrow is up for three frames that swallow the button. + if (self.preWait or 0) > 0 then + self.preWait = self.preWait - 1 + return + end if input:wasPressed("a") or input:wasPressed("b") then require("src.core.Sound").play(self.game.data, "Press_AB") self.waiting = false @@ -252,11 +267,17 @@ function TextBox:update(dt) self.contAdvance = false self.lineIndex = self.lineIndex + 1 self:beginLine() + -- ScrollTextUpOneLine is 5 blocking frames and, as its own comment + -- says, is "always called twice in a row" (home/text.asm:280-305) + self.holdFrames = Timing.TEXT_SCROLL_PAIR else self.shown = {} self.pageIndex = self.pageIndex + 1 self.lineIndex = 1 self:beginLine() + -- ClearScreenArea then DelayFrames 20: the box sits empty before the + -- next page starts typing (home/text.asm:236-240) + self.holdFrames = Timing.TEXT_PAGE_CLEAR end end return @@ -283,6 +304,7 @@ function TextBox:update(dt) if conts and conts[nextIdx] then -- pokered : ▼ + WaitForTextScrollButtonPress before scroll self.waiting = true + self.preWait = Timing.TEXT_PRE_ADVANCE self.contAdvance = true else self.lineIndex = nextIdx @@ -290,6 +312,7 @@ function TextBox:update(dt) end elseif self.pageIndex < #self.pages then self.waiting = true + self.preWait = Timing.TEXT_PRE_ADVANCE self.contAdvance = false else self.done = true @@ -300,6 +323,15 @@ function TextBox:update(dt) end function TextBox:draw() + -- The dialogue box belongs against the bottom of the screen, not floating + -- in the middle of a zoomed-out letterbox. Declared per frame; the + -- renderer blits this region to the screen edge and the rest of the UI + -- where it always was (Renderer:setUIAnchor). + local r = self.game and self.game.renderer + if r and r.setUIAnchor then + r:setUIAnchor(self.boxTx * 8, self.boxTy * 8, + self.boxTw * 8, self.boxTh * 8, "bottom") + end Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh) love.graphics.setColor(0, 0, 0, 1) if self.scrollPx and self.scrollPx > 0 then diff --git a/src/render/Transition.lua b/src/render/Transition.lua index 9d1b7406..1163322d 100644 --- a/src/render/Transition.lua +++ b/src/render/Transition.lua @@ -1,17 +1,27 @@ -- Screen fade used for warps: fade out, run a callback (map switch), fade in. -- Pushed on the state stack above the overworld. +local Timing = require("src.core.Timing") + local Transition = {} Transition.__index = Transition -local FRAMES = 12 +-- PlayMapChangeSound tail-calls GBFadeOutToBlack on every map change +-- (home/overworld.asm:703), which is four palette steps of DelayFrames 8 -- +-- 32 frames. There is no matching fade in: the new map is built while the +-- palettes are still blacked out and OverworldLoop's LoadGBPal +-- (home/overworld.asm:45) restores them in a single write, so the map pops +-- in. The old symmetric 12/12 fade was both too fast and a shape the +-- hardware never had. +local FRAMES = Timing.WARP_FADE_OUT +local FRAMES_IN = Timing.WARP_FADE_IN local FLASH_FRAMES = 7 -- The two fades as transitions records, so a mod retimes a warp fade the -- same way it retimes a battle wipe. BattleTransition.registerInto pulls -- these in with its eight wipes -- one registrant owns the registry. Transition.STYLES = { - warp_fade = { kind = "fade", frames = FRAMES }, + warp_fade = { kind = "fade", frames = FRAMES, framesIn = FRAMES_IN }, white_flash = { kind = "fade", frames = FLASH_FRAMES }, } @@ -36,26 +46,39 @@ function Transition.new(game, onMidpoint, onDone) self.onDone = onDone self.t = 0 self.phase = "out" - self.frames = styleOf(game, "warp_fade").frames or FRAMES + local style = styleOf(game, "warp_fade") + self.frames = style.frames or FRAMES + -- a style may still ask for a fade in (mods, and the record is data-driven); + -- the built-in warp is 0, matching hardware + self.framesIn = style.framesIn or FRAMES_IN return self end +function Transition:finish() + self.game.stack:pop() + if self.onDone then self.onDone() end +end + function Transition:update(dt) self.t = self.t + 1 - if self.t >= self.frames then + local len = (self.phase == "out") and self.frames or self.framesIn + if self.t >= len then self.t = 0 if self.phase == "out" then self.phase = "in" if self.onMidpoint then self.onMidpoint() end + -- LoadGBPal restores the palettes in one write, so with no fade in the + -- map is simply there on the next frame + if (self.framesIn or 0) <= 0 then self:finish() end else - self.game.stack:pop() - if self.onDone then self.onDone() end + self:finish() end end end function Transition:draw() - local alpha = self.t / self.frames + local len = (self.phase == "out") and self.frames or self.framesIn + local alpha = (len and len > 0) and (self.t / len) or 1 if self.phase == "in" then alpha = 1 - alpha end -- Survey zoom draws the overworld into a window-filling world canvas -- while the UI pass stays the classic 160x144 letterbox. A rect on the @@ -101,4 +124,66 @@ function WhiteFlash:draw() love.graphics.rectangle("fill", 0, 0, 160, 144) end +-- Coming back to the overworld after a battle. +-- +-- The battle screen is torn down with the palettes still whited out, the +-- caller spends `ld c, 10 / call DelayFrames` (home/overworld.asm:351-352), +-- and then EnterMap sees BIT_BATTLE_OVER_OR_BLACKOUT set and runs +-- MapEntryAfterBattle (:22, :749-753), which is GBFadeInFromWhite -- three +-- palette steps of DelayFrames 8, so 24 frames. The port had none of it and +-- simply cut from the battle to the map. +-- +-- A dark map takes the other branch: wMapPalOffset is nonzero there, so +-- MapEntryAfterBattle does a plain LoadGBPal and the map is just there. Pass +-- opts.instant for that case. +local BattleReturn = {} +BattleReturn.__index = BattleReturn +BattleReturn.isOpaque = false -- the overworld draws underneath + +function Transition.battleReturn(game, onDone, opts) + opts = opts or {} + return setmetatable({ + game = game, onDone = onDone, t = 0, + hold = opts.hold or Timing.POST_BATTLE_RETURN, + frames = opts.instant and 0 or (opts.frames or Timing.FADE_IN_FROM_WHITE), + }, BattleReturn) +end + +function BattleReturn:update(dt) + self.t = self.t + 1 + if self.t >= self.hold + self.frames then + self.game.stack:pop() + if self.onDone then self.onDone() end + end +end + +-- GBFadeInFromWhite is a palette staircase, not a smooth ramp: GBFadeIncCommon +-- writes one palette then holds it with `ld c, 8 / call DelayFrames` +-- (home/fade.asm:30-41), three times over. Stepping the veil the same way +-- keeps the fade reading like a Game Boy palette fade rather than a tween. +local FADE_STEP_FRAMES = 8 + +function BattleReturn:alpha() + if self.t < self.hold then return 1 end + if self.frames <= 0 then return 0 end + local steps = math.max(1, math.floor(self.frames / FADE_STEP_FRAMES)) + local step = math.floor((self.t - self.hold) / FADE_STEP_FRAMES) + if step >= steps then return 0 end + return (steps - step - 1) / steps +end + +function BattleReturn:draw() + local a = self:alpha() + -- the fade is a palette write, so it covers the whole surface; the + -- renderer paints it in screen space (see Renderer.screenVeil) + local r = self.game and self.game.renderer + if r then + r.screenVeil = { 1, a } + return + end + love.graphics.setColor(1, 1, 1, a) + love.graphics.rectangle("fill", 0, 0, 160, 144) + love.graphics.setColor(1, 1, 1, 1) +end + return Transition diff --git a/src/ui/ChoiceBox.lua b/src/ui/ChoiceBox.lua index 013bbafc..abe0e1f7 100644 --- a/src/ui/ChoiceBox.lua +++ b/src/ui/ChoiceBox.lua @@ -3,6 +3,7 @@ local Font = require("src.render.Font") local Theme = require("src.ui.Theme") local Strings = require("src.core.Strings") +local Timing = require("src.core.Timing") local ChoiceBox = {} ChoiceBox.__index = ChoiceBox @@ -25,6 +26,19 @@ end function ChoiceBox:update(dt) local input = self.game.input + -- Both branches of DisplayTwoOptionMenu hold 15 frames with the menu still + -- on screen before TwoOptionMenu_RestoreScreenTiles hands control back + -- (engine/menus/text_box.asm:322-323, :333-334). + if self.pending ~= nil then + self.holdFrames = self.holdFrames - 1 + if self.holdFrames <= 0 then + local yes = self.pending + self.pending = nil + self.game.stack:pop() + self.onChoose(yes) + end + return + end if input:wasPressed("up") or input:wasPressed("down") then self.index = self.index == 1 and 2 or 1 elseif input:wasPressed("a") then @@ -32,19 +46,28 @@ function ChoiceBox:update(dt) if not self.noSound then require("src.core.Sound").play(self.game.data, "Press_AB") end - self.game.stack:pop() - self.onChoose(self.index == 1) + self.pending = (self.index == 1) + self.holdFrames = Timing.YES_NO_ANSWER elseif input:wasPressed("b") then if not self.noSound then require("src.core.Sound").play(self.game.data, "Press_AB") end - self.game.stack:pop() - self.onChoose(false) + -- .choseSecondMenuItem writes wCurrentMenuItem = 1 before the hold, so + -- the cursor visibly snaps to NO for those 15 frames + self.index = 2 + self.pending = false + self.holdFrames = Timing.YES_NO_ANSWER end end function ChoiceBox:draw() local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th + -- rides the same bottom anchor as the dialogue box it sits above, so the + -- pair travels together (the anchor keeps each element's gap from the edge) + local r = self.game and self.game.renderer + if r and r.setUIAnchor then + r:setUIAnchor(tx * 8, ty * 8, tw * 8, th * 8, "bottom") + end Font.drawBox(tx, ty, tw, th) love.graphics.setColor(0, 0, 0, 1) Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8) diff --git a/src/ui/IntroMovie.lua b/src/ui/IntroMovie.lua index 2046bcc3..49830221 100644 --- a/src/ui/IntroMovie.lua +++ b/src/ui/IntroMovie.lua @@ -33,6 +33,10 @@ local IntroMovie = {} IntroMovie.__index = IntroMovie IntroMovie.isOpaque = true +-- Same as the title screen: full-bleed art, no world behind it, no player zoom +-- to respect, so fill the window rather than sit at the fixed integer scale. +function IntroMovie:wantsFillScale() return true end + -- SGB intro palettes: the splash uses PalPacket_GameFreakIntro (logo -- GAMEFREAK, falling star columns RED/VIRIDIAN/BLUEMON), the attract -- fight PalPacket_NidorinoIntro (PURPLEMON letterbox, BLACK bars) diff --git a/src/ui/Menu.lua b/src/ui/Menu.lua index 8b0fd0a9..5a97908a 100644 --- a/src/ui/Menu.lua +++ b/src/ui/Menu.lua @@ -50,6 +50,9 @@ function Menu.new(game, items, opts) -- only menus whose real mask includes PAD_START -- the start menu -- (engine/menus/draw_start_menu.asm) -- opt in here. self.startCloses = opts.startCloses or false + -- screen-edge anchor for this menu (see Menu:draw); nil keeps it in the + -- classic centred letterbox + self.anchor = opts.anchor self.onCancel = opts.onCancel -- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): the PC session runs its -- menus silent (home/window.asm HandleMenuInput_) @@ -104,6 +107,14 @@ function Menu:update(dt) end function Menu:draw() + -- opts.anchor opts a menu out of the centred letterbox and onto a screen + -- edge (the START menu asks for "topright"). Only menus that ask for it + -- move; every other menu is placed exactly as before. + local r = self.anchor and self.game and self.game.renderer + if r and r.setUIAnchor then + r:setUIAnchor(self.tx * 8, self.ty * 8, + self.tw * 8, self.th * 8, self.anchor) + end Font.drawBox(self.tx, self.ty, self.tw, self.th) love.graphics.setColor(0, 0, 0, 1) local visible = (self.maxVisible and math.min(self.maxVisible, #self.items)) diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 82cf5b43..78b8c025 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -19,6 +19,7 @@ local TileRenderer = require("src.render.TileRenderer") local GameSpeed = require("src.core.GameSpeed") local GameVersion = require("src.core.GameVersion") local VideoMode = require("src.core.VideoMode") +local FaithfulRes = require("src.core.FaithfulRes") local FrameCap = require("src.core.FrameCap") local Performance = require("src.core.Performance") local Logger = require("src.core.Logger") @@ -156,6 +157,40 @@ local function buildRows(game) o.battleLayout = o.battleLayout == "wide" and "og" or "wide" return true end }, + -- 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 so it fills + -- vertically; that needs a fractional scale, so pixels stop being evenly + -- sized. Battle only: the overworld is untouched either way. + { id = "battleFit", label = Strings("BATTLE SIZE"), + value = function(g) + return g.save.options.battleFit == "fill" and Strings("FILL") + or Strings("FIXED") + end, + step = function(g) + local o = g.save.options + o.battleFit = o.battleFit == "fill" and "fixed" or "fill" + return true + end }, + -- What sits behind and around the battle. WHITE is the classic paper + -- field; BLACK swaps it for black bars; WORLD leaves the frozen overworld + -- visible underneath, dimmed (the battle stops being opaque, so the map + -- shows through everywhere the battle does not paint). + { id = "battleBg", label = Strings("BATTLE BG"), + value = function(g) + local m = g.save.options.battleBg + if m == "black" then return Strings("BLACK") end + if m == "world" then return Strings("WORLD") end + return Strings("WHITE") + end, + step = function(g, dir) + local o = g.save.options + local order = { "white", "black", "world" } + local cur = 1 + for i, m in ipairs(order) do if o.battleBg == m then cur = i break end end + o.battleBg = order[(cur - 1 + (dir or 1)) % #order + 1] + return true + end }, { id = "ruleset", label = Strings("RULESET"), value = function(g) return rulesetName(g) end, step = function(g, dir) @@ -298,6 +333,20 @@ local function buildRows(game) VideoMode.apply(o.videoMode) return true end }, + -- Lock the window to an exact 160x144 multiple, so the surface IS the + -- Game Boy screen with no letterbox at all. Sits next to VIDEO MODE + -- because it overrides it: holding an exact size means dropping + -- fullscreen. + { id = "faithfulRes", label = Strings("FAITHFUL RES"), + value = function(g) + return FaithfulRes.label(g.save.options.faithfulRes) + end, + step = function(g, dir) + local o = g.save.options + o.faithfulRes = FaithfulRes.cycle(o.faithfulRes, dir) + FaithfulRes.apply(o.faithfulRes) + return true + end }, -- hard render cap (issue #88): bounds the present rate so a -- driver-forced vsync-off run cannot spin at thousands of FPS. Logic -- is fixed-step off dt, so this touches presentation only. diff --git a/src/ui/StartMenu.lua b/src/ui/StartMenu.lua index 2fbc1fe8..04a526f8 100644 --- a/src/ui/StartMenu.lua +++ b/src/ui/StartMenu.lua @@ -133,7 +133,12 @@ function StartMenu.new(game) local rowStep = 2 local maxVisible = math.floor((Renderer.HEIGHT / 8 - 2) / rowStep) local menu = Menu.new(game, items, - { tx = 9, ty = 0, tw = 11, maxVisible = maxVisible, startCloses = true }) + -- the START menu hugs the top-right corner of the SCREEN, not of a + -- centred letterbox: at 9,0 x 11 it is already flush with the top and + -- right of the 20x18 grid, so the anchor keeps it flush when the view + -- is zoomed out and the letterbox no longer fills the window + { tx = 9, ty = 0, tw = 11, maxVisible = maxVisible, startCloses = true, + anchor = "topright" }) -- the cursor position survives closing the menu -- (wBattleAndStartSavedMenuItem, home/start_menu.asm) menu.index = math.min(game.save.startMenuIndex or 1, #items) diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index 2d672033..9f293f42 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -14,6 +14,12 @@ local TitleState = {} TitleState.__index = TitleState TitleState.isOpaque = true +-- Fill the window (aspect preserved, bars on the long axis) instead of sitting +-- at the fixed integer scale. The title screen is a full-bleed picture with no +-- world behind it, so a small centred box in a large window is just wasted +-- glass -- and unlike the overworld it has no zoom the player chose to respect. +function TitleState:wantsFillScale() return true end + -- SGB title zones (PalPacket_Titlescreen): the logo rows get LOGO2, -- the version-ribbon band LOGO1, the rest MEWMON. -- diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 394b9507..49564151 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -717,6 +717,33 @@ function OverworldState:pushBattle(battle) if battle.computeMusicKind then require("src.core.Music").playBattle(Game.data, battle:computeMusicKind()) end + + -- Coming back from the battle screen is a fade, not a cut: EnterMap sees + -- BIT_BATTLE_OVER_OR_BLACKOUT set and runs MapEntryAfterBattle + -- (home/overworld.asm:22, :749-753) = GBFadeInFromWhite, behind the + -- `ld c, 10 / call DelayFrames` at :351-352. + -- + -- It is wrapped around onFinish here, at the one funnel every battle goes + -- through, rather than inside afterBattle: a script-driven win defers + -- afterBattle into ctx.afterScript so an evolution screen cannot be buried + -- under the trainer's follow-up text (see Commands.start_battle), and the + -- fade inherited that deferral -- on a rival battle it fired after the + -- post-battle dialogue AND the walk-off, instead of 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 get to run. The overworld is frozen meanwhile -- + -- StateStack updates the top state only -- so nothing moves under it. + local finish = battle.onFinish + battle.onFinish = function(result) + if result == "lose" then + -- the blackout path warps to the heal point with its own transition + if finish then finish(result) end + return + end + Game.stack:push(require("src.render.Transition").battleReturn(Game, + function() if finish then finish(result) end end)) + end Game.stack:push(BattleTransition.new(Game, function() Game.stack:push(battle) end, { diff --git a/tests/engine/battle_fit_option.lua b/tests/engine/battle_fit_option.lua new file mode 100644 index 00000000..d0918446 --- /dev/null +++ b/tests/engine/battle_fit_option.lua @@ -0,0 +1,81 @@ +-- BATTLE SIZE (save.options.battleFit): "fixed" keeps the classic +-- integer-scaled letterbox, "fill" scales the battle surface to the window so +-- it fills vertically. +-- +-- The bit that regresses is not the arithmetic, it is WHERE the flag is read +-- from: a battle opens party menus, the bag and text boxes on top of itself, +-- so reading it off the TOP state would snap the surface back to the fixed +-- scale for as long as one of those is up. It is read off the whole stack, +-- the same way the wide-battle layout is. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Game = require("src.core.Game") +local BattleState = require("src.battle.BattleState") + +local function battleWith(fit) + return setmetatable({ game = { save = { options = { battleFit = fit } } } }, + { __index = BattleState }) +end + +T.eq(battleWith("fill"):wantsFillScale(), true, "fill asks for the fill scale") +T.eq(battleWith("fixed"):wantsFillScale(), false, "fixed does not") +T.eq(battleWith(nil):wantsFillScale(), false, "and neither does an old save") + +-- a battle with no game/options at all must not throw +T.eq(setmetatable({}, { __index = BattleState }):wantsFillScale(), false, + "a battle with no options is fixed") + +-- the stack scan +local function stack(...) return { states = { ... } } end +local overworld = {} +local fillBattle = battleWith("fill") +local fixedBattle = battleWith("fixed") +local partyMenu = {} -- no wantsFillScale at all, like every non-battle state + +T.eq(Game.fillScaleInStack(stack(overworld)), false, + "no battle in the stack means no fill") +T.eq(Game.fillScaleInStack(stack(overworld, fixedBattle)), false, + "a fixed battle does not fill") +T.eq(Game.fillScaleInStack(stack(overworld, fillBattle)), true, + "a fill battle does") +T.eq(Game.fillScaleInStack(stack(overworld, fillBattle, partyMenu)), true, + "and keeps filling while a party menu sits on top of it") +T.eq(Game.fillScaleInStack(stack()), false, "an empty stack is safe") +T.eq(Game.fillScaleInStack(nil), false, "and so is no stack at all") + +-- ---------------------------------------------------------------- BATTLE BG + +-- What fills the voids AROUND the battle. The battle screen itself keeps its +-- white field in every mode -- only the surround changes. +local function battleBg(bg) + return setmetatable({ game = { save = { options = { battleBg = bg } } } }, + { __index = BattleState }) +end + +T.eq(battleBg("white"):bgMode(), "white", "white is white") +T.eq(battleBg("black"):bgMode(), "black", "black is black") +T.eq(battleBg("world"):bgMode(), "world", "world is world") +T.eq(battleBg(nil):bgMode(), "white", "an old save defaults to white") +T.eq(battleBg("nonsense"):bgMode(), "white", "and so does a bad value") +T.eq(setmetatable({}, { __index = BattleState }):bgMode(), "white", + "a battle with no options is white") + +-- "world" is the only mode that drops opacity, because it is the only one +-- that needs the overworld to keep drawing underneath. +T.eq(Game.worldBgBattleDim(stack(overworld, battleBg("world"))), + BattleState.BG_WORLD_DIM, "a world-bg battle asks for its dim") +T.eq(Game.worldBgBattleDim(stack(overworld, battleBg("white"))), nil, + "a white-bg battle asks for none") +T.eq(Game.worldBgBattleDim(stack(overworld, battleBg("black"))), nil, + "and neither does black") +T.eq(Game.worldBgBattleDim(stack(overworld, battleBg("world"), partyMenu)), + BattleState.BG_WORLD_DIM, + "the dim survives a party menu opened over the battle") +T.eq(Game.worldBgBattleDim(stack(overworld)), nil, "no battle, no dim") +T.eq(Game.worldBgBattleDim(nil), nil, "and no stack is safe") + +T.check(BattleState.BG_WORLD_DIM > 0 and BattleState.BG_WORLD_DIM < 1, + "the dim is a fraction, not a full blackout") + +T.finish("battle fit option") diff --git a/tests/engine/faithful_res.lua b/tests/engine/faithful_res.lua new file mode 100644 index 00000000..d98b1e26 --- /dev/null +++ b/tests/engine/faithful_res.lua @@ -0,0 +1,135 @@ +-- FAITHFUL RES: lock the window to an exact 160x144 multiple so the surface +-- is the Game Boy screen with no letterbox at all. +-- +-- The interesting parts are the two things a naive setMode gets wrong: the +-- window minimum from conf.lua (480x360) sits ABOVE 1X and 2X, so the lock +-- has to lower it or LOVE clamps the window straight back up; and on a HiDPI +-- display a LOVE unit is more than a pixel, so asking for the pixel count +-- directly gives a window twice the size intended. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local FaithfulRes = require("src.core.FaithfulRes") + +-- ---------------------------------------------------------------- values + +T.eq(FaithfulRes.normalize(nil), 0, "no setting is OFF") +T.eq(FaithfulRes.normalize("junk"), 0, "garbage degrades to OFF") +T.eq(FaithfulRes.normalize(-3), 0, "negatives clamp to OFF") +T.eq(FaithfulRes.normalize(9), 4, "above 4X clamps to 4X") +T.eq(FaithfulRes.normalize(2.7), 2, "fractions floor to a whole multiple") + +T.eq(FaithfulRes.label(0), "OFF", "0 reads OFF") +T.eq(FaithfulRes.label(1), "1X", "1 reads 1X") +T.eq(FaithfulRes.label(4), "4X", "4 reads 4X") + +-- the row cycles OFF -> 1X -> 2X -> 3X -> 4X -> OFF +local seen, v = {}, 0 +for _ = 1, 5 do + seen[#seen + 1] = FaithfulRes.label(v) + v = FaithfulRes.cycle(v, 1) +end +T.eq(table.concat(seen, ","), "OFF,1X,2X,3X,4X", "the row cycles through OFF..4X") +T.eq(FaithfulRes.cycle(4, 1), 0, "and wraps back to OFF") +T.eq(FaithfulRes.cycle(0, -1), 4, "stepping back from OFF lands on 4X") + +-- ---------------------------------------------------------------- sizing + +local savedWindow = love.window +local savedSystem = love.system +local savedDims = love.graphics and love.graphics.getDimensions +local savedPixels = love.graphics and love.graphics.getPixelDimensions + +-- `ratio` is PHYSICAL PIXELS PER UNIT, which is what the window reports and +-- what FaithfulRes measures -- not love.window.getDPIScale, which lies about +-- a window that is not high-DPI aware (that was the bug). +local function stubWindow(ratio) + local calls = {} + love.graphics = love.graphics or {} + love.graphics.getDimensions = function() return 1024, 768 end + love.graphics.getPixelDimensions = function() + return 1024 * ratio, 768 * ratio + end + love.window = { + -- deliberately WRONG, and deliberately present: nothing may read it + getDPIScale = function() return 999 end, + getMode = function() + return 1024, 768, { fullscreen = true, resizable = true, + minwidth = 480, minheight = 360, vsync = 1 } + end, + setMode = function(w, h, flags) + calls[#calls + 1] = { w = w, h = h, flags = flags } + end, + } + return calls +end + +-- A plain desktop window reports units == pixels, whatever the DISPLAY +-- scaling is. Dividing by the display scale here is what made 2X render at +-- 1X and 4X at 3X, so the stub returns a nonsense getDPIScale to prove +-- nothing consults it. +stubWindow(1) +local w, h = FaithfulRes.size(2) +T.eq(w, 320, "2X is 320 units wide when a unit is a pixel") +T.eq(h, 288, "2X is 288 units tall when a unit is a pixel") +T.eq(FaithfulRes.size(0), nil, "OFF has no size") + +local w4, h4 = FaithfulRes.size(4) +T.eq(w4, 640, "4X is 640 wide, not shrunk by the display scale") +T.eq(h4, 576, "4X is 576 tall") + +-- a genuinely high-DPI window reports more pixels than units, so the unit +-- size halves to keep the PHYSICAL pixel count exact +stubWindow(2) +local w2, h2 = FaithfulRes.size(2) +T.eq(w2, 160, "2X asks for 160 units at 2 px/unit, which is 320 pixels") +T.eq(h2, 144, "and 144 units, which is 288 pixels") + +-- ---------------------------------------------------------------- applying + +FaithfulRes.locked = false +local calls = stubWindow(1) +love.system = { getOS = function() return "Windows" end } + +-- OFF on boot must not touch a window the player sized themselves +T.eq(FaithfulRes.apply(0), false, "OFF reports unlocked") +T.eq(#calls, 0, "and does not resize an unlocked window on boot") + +T.eq(FaithfulRes.apply(3), true, "3X reports locked") +T.eq(#calls, 1, "which took one setMode") +T.eq(calls[1].w, 480, "3X is 480 wide") +T.eq(calls[1].h, 432, "3X is 432 tall") +T.eq(calls[1].flags.fullscreen, false, + "an exact size drops fullscreen -- the two cannot both hold") +T.eq(calls[1].flags.resizable, false, + "and fixes the window, since a drag would silently break the lock") + +-- 1X and 2X are below conf.lua's 480x360 floor; without lowering it LOVE +-- clamps the window back up and the lock silently does nothing +calls = stubWindow(1) +FaithfulRes.apply(1) +T.eq(calls[1].w, 160, "1X is 160 wide") +T.eq(calls[1].flags.minwidth, 160, "and lowers the window minimum to match") +T.eq(calls[1].flags.minheight, 144, "on both axes") + +-- releasing the lock restores the resizable window and its original floor +calls = stubWindow(1) +T.eq(FaithfulRes.apply(0), false, "OFF reports unlocked") +T.eq(#calls, 1, "and this time it does resize, because we held the window") +T.eq(calls[1].flags.resizable, true, "handing resizing back to the player") +T.eq(calls[1].flags.minwidth, FaithfulRes.MIN_W, "with conf.lua's floor restored") +T.eq(calls[1].flags.minheight, FaithfulRes.MIN_H, "on both axes") +T.eq(FaithfulRes.locked, false, "and the module no longer claims the window") + +-- mobile has no resizable window to lock +calls = stubWindow(1) +love.system = { getOS = function() return "Android" end } +T.eq(FaithfulRes.apply(4), false, "mobile reports unlocked") +T.eq(#calls, 0, "and never touches the window") + +love.window, love.system = savedWindow, savedSystem +if love.graphics then + love.graphics.getDimensions = savedDims + love.graphics.getPixelDimensions = savedPixels +end +T.finish("faithful resolution") diff --git a/tests/engine/timing_parity.lua b/tests/engine/timing_parity.lua new file mode 100644 index 00000000..2ea492d9 --- /dev/null +++ b/tests/engine/timing_parity.lua @@ -0,0 +1,598 @@ +-- Timing parity: every sequence in docs/timing-parity.md must cost the same +-- number of 60Hz logic steps here as it does on hardware. +-- +-- The port's clock was always right; what drifted was the frame budget of +-- composed sequences, because the original spends much of its running time +-- inside DelayFrames calls that produce no visible change. Those are +-- invisible in a screenshot, so nothing else in the suite catches them -- +-- this file is the only thing standing between the port and a slow slide +-- back to "snappier than a Game Boy". +-- +-- Hardware numbers carry their asm citation; regenerate the inventory with +-- tools/scan_pokered_delays.ps1. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +local Font = require("src.render.Font") +Font.load(Data) + +local Timing = require("src.core.Timing") + +-- ---------------------------------------------------------------- constants + +-- home/fade.asm: four (or three) palette steps of `ld c, 8 / DelayFrames` +T.eq(Timing.FADE_OUT_TO_BLACK, 32, "GBFadeOutToBlack is 4 x 8 frames") +T.eq(Timing.FADE_IN_FROM_BLACK, 32, "GBFadeInFromBlack is 4 x 8 frames") +T.eq(Timing.FADE_OUT_TO_WHITE, 24, "GBFadeOutToWhite is 3 x 8 frames") +T.eq(Timing.FADE_IN_FROM_WHITE, 24, "GBFadeInFromWhite is 3 x 8 frames") + +T.eq(Timing.DELAY3, 3, "Delay3 is three frames") +T.eq(Timing.TEXT_SCROLL_PAIR, 10, "ScrollTextUpOneLine is 5 frames, run twice") +T.eq(Timing.TEXT_CONT, 13, ": ProtectedDelay3 + the two-line scroll") +T.eq(Timing.TEXT_PARAGRAPH, 23, ": ProtectedDelay3 + DelayFrames 20") +T.eq(Timing.YES_NO_ANSWER, 15, "DisplayTwoOptionMenu holds 15 frames") +T.eq(Timing.WARP_FADE_OUT, 32, "a map change fades out over 32 frames") +T.eq(Timing.WARP_FADE_IN, 0, "there is no fade in: LoadGBPal restores in one write") + +-- ---------------------------------------------------------------- HP bar + +-- UpdateHPBar walks one HP point per iteration. On the player's HUD each +-- point costs a frame (PrintHPNumber's DelayFrame, gated on wHPBarType) and +-- each pixel of bar movement costs two more; on the enemy HUD only the +-- pixels cost anything. +T.eq(Timing.hpBarPixels(150, 150), 48, "a full bar is 48 px") +T.eq(Timing.hpBarPixels(75, 150), 24, "half HP is half the bar") +T.eq(Timing.hpBarPixels(0, 150), 0, "an empty bar is 0 px") +T.eq(Timing.hpBarPixels(1, 150), 1, "GetHPBarLength clamps a sliver to 1 px") + +T.eq(Timing.hpDrainFrames(150, 0, 150, true), 150 + 96 + 6, + "a 150 HP player mon drains in D + 2P + 6 = 252 frames") +T.eq(Timing.hpDrainFrames(150, 0, 150, false), 96 + 5, + "the same drain on the enemy HUD costs only 2P + 5 = 101 frames") + +-- The engine's per-frame stepper has to agree with that closed form, or the +-- bar is animating at a rate nothing else measures. +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +-- Each case gets its own party: draining a battler to 0 faints the very +-- Pokemon object the save holds, and newWild refuses to start with no +-- healthy party. +local function newBattle() + local save = SaveData.newGame() + save.party = { Pokemon.new(Data, "FIXMON_A", 30) } + local game = { data = Data, save = save, + stack = { top = function() return nil end, + push = function() end } } + return BattleState.newWild(game, "FIXMON_C", 40) +end + +local function drainFrames(battle, battler, toHP) + battler.mon.hp = toHP + local frames = 0 + while battle:stepHPDrain() and frames < 20000 do frames = frames + 1 end + return frames +end + +local battle = newBattle() +local pMax = battle.player.mon.stats.hp +T.eq(drainFrames(battle, battle.player, 0), + Timing.hpDrainFrames(pMax, 0, pMax, true), + "the player's bar steps at the hardware rate") + +local battle2 = newBattle() +local eMax = battle2.enemy.mon.stats.hp +T.eq(drainFrames(battle2, battle2.enemy, 0), + Timing.hpDrainFrames(eMax, 0, eMax, false), + "the enemy's bar steps at the hardware rate") + +-- a partial drain is exact too: every player-side HP step costs at least the +-- number print, so the stepper never batches two into one frame +local battle3 = newBattle() +local start = battle3.player.mon.hp +local target = math.max(1, start - 7) +T.eq(drainFrames(battle3, battle3.player, target), + Timing.hpDrainFrames(start, target, battle3.player.mon.stats.hp, true), + "a partial player drain matches the closed form") + +-- ---------------------------------------------------------------- text box + +-- Both and print the down-arrow and run ProtectedDelay3 before +-- ManualTextScroll starts watching the joypad, then pay the scroll or the +-- box clear after the button. The port used to advance on the press frame +-- with no cost on either side. +local TextBox = require("src.render.TextBox") + +local Input = { down = {}, pressed = {} } +function Input:isDown(b) return self.down[b] or false end +function Input:wasPressed(b) return self.pressed[b] or false end +function Input:press(b) self.pressed[b] = true end +function Input:release() self.pressed = {} end + +local function textGame() + local popped = false + local g = { data = Data, save = SaveData.newGame(), input = Input } + g.stack = { push = function() end, + pop = function() popped = true end, + top = function() return nil end } + g.wasPopped = function() return popped end + return g +end + +-- type a box out to its first wait, returning the frames that took +local function typeToWait(box) + local frames = 0 + while not box.waiting and not box.done and frames < 2000 do + box:update(1 / 60) + frames = frames + 1 + end + return frames +end + +local g = textGame() +g.save.options = g.save.options or {} +g.save.options.textSpeed = 1 -- fastest, so the typewriter is not the subject +local box = TextBox.new(g, "AB\fCD", {}) +typeToWait(box) +T.check(box.waiting, "the box waits at the page break") + +-- the three ProtectedDelay3 frames swallow the button +local held = 0 +for _ = 1, Timing.TEXT_PRE_ADVANCE do + Input:press("a") + box:update(1 / 60) + Input:release() + held = held + 1 + T.check(box.waiting, "still waiting on pre-advance frame " .. held) +end + +-- the press now lands, and the box holds for the clear before typing again +Input:press("a") +box:update(1 / 60) +Input:release() +T.eq(box.holdFrames, Timing.TEXT_PAGE_CLEAR, + "a page break holds DelayFrames 20 after the press") +T.eq(#box.shown[1], 0, "the new page has not typed a character yet") + +local blocked = 0 +while (box.holdFrames or 0) > 0 and blocked < 200 do + box:update(1 / 60) + blocked = blocked + 1 + T.eq(#box.shown[#box.shown], 0, "nothing types during the hold") +end +T.eq(blocked, Timing.TEXT_PAGE_CLEAR, "the hold is exactly 20 frames") + +-- pays the two-line scroll instead of the clear +local g2 = textGame() +g2.save.options = g2.save.options or {} +g2.save.options.textSpeed = 1 +local box2 = TextBox.new(g2, "AB\vCD", {}) +typeToWait(box2) +T.check(box2.waiting, "the box waits at the CONT marker") +for _ = 1, Timing.TEXT_PRE_ADVANCE do box2:update(1 / 60) end +Input:press("a") +box2:update(1 / 60) +Input:release() +T.eq(box2.holdFrames, Timing.TEXT_SCROLL_PAIR, + "a CONT advance holds for the two ScrollTextUpOneLine calls") + +-- ---------------------------------------------------------------- yes/no + +local ChoiceBox = require("src.ui.ChoiceBox") + +local chosen, popped = nil, 0 +local g3 = { data = Data, save = SaveData.newGame(), input = Input } +g3.stack = { push = function() end, pop = function() popped = popped + 1 end, + top = function() return nil end } +local choice = ChoiceBox.new(g3, function(yes) chosen = yes end) + +Input:press("a") +choice:update(1 / 60) +Input:release() +T.eq(chosen, nil, "the answer does not fire on the press frame") + +-- count only the frames after the press: DelayFrames 15 runs between the +-- press and TwoOptionMenu_RestoreScreenTiles handing control back +local waited = 0 +while chosen == nil and waited < 200 do + choice:update(1 / 60) + waited = waited + 1 +end +T.eq(waited, Timing.YES_NO_ANSWER, "the answer fires 15 frames after the press") +T.eq(chosen, true, "A chose YES") +T.eq(popped, 1, "the box popped itself once") + +-- B picks the second option, and the cursor snaps to it for the hold +local chosen2 = nil +local g4 = { data = Data, save = SaveData.newGame(), input = Input } +g4.stack = { push = function() end, pop = function() end, + top = function() return nil end } +local choice2 = ChoiceBox.new(g4, function(yes) chosen2 = yes end) +Input:press("b") +choice2:update(1 / 60) +Input:release() +T.eq(choice2.index, 2, "B moves the cursor to NO before the hold") +local waited2 = 0 +while chosen2 == nil and waited2 < 200 do + choice2:update(1 / 60) + waited2 = waited2 + 1 +end +T.eq(waited2, Timing.YES_NO_ANSWER, "the B answer holds 15 frames too") +T.eq(chosen2, false, "B chose NO") + +-- ---------------------------------------------------------------- warp fade + +-- PlayMapChangeSound fades out over 32 frames and the new map simply +-- appears; the port used to run a symmetric 12/12 fade, which is both too +-- fast and a shape the hardware never had. +local Transition = require("src.render.Transition") + +local mid, done, tpopped = 0, 0, 0 +local g5 = { data = Data, save = SaveData.newGame() } +g5.stack = { push = function() end, pop = function() tpopped = tpopped + 1 end, + top = function() return nil end } +local fade = Transition.new(g5, function() mid = mid + 1 end, + function() done = done + 1 end) + +local f = 0 +while done == 0 and f < 500 do + fade:update(1 / 60) + f = f + 1 + if mid == 1 and done == 0 then + T.check(false, "the map switch and the hand-back must land together") + end +end +T.eq(f, Timing.WARP_FADE_OUT, "the warp fade is 32 frames end to end") +T.eq(mid, 1, "the map switched once") +T.eq(done, 1, "the transition handed control back once") +T.eq(tpopped, 1, "and popped itself once") + +-- ------------------------------------------------------- battle turns + +-- PlayApplyingAttackAnimation's six types (animations.asm:490-524). The +-- slow shakes are c * 4b because AnimationShakeScreenHorizontallySlow pushes +-- bc twice and runs two b-loops of DelayFrames 2 per outer pass. +T.eq(Timing.SHAKE_VERTICAL, 48, "type 1 ShakeScreenVertically b=8") +T.eq(Timing.SHAKE_HORIZ_HEAVY, 72, "type 2 fast horizontal b=8") +T.eq(Timing.SHAKE_HORIZ_SLOW, 48, "type 3 slow horizontal, lb bc, 6, 2") +T.eq(Timing.SHAKE_HORIZ_LIGHT, 18, "type 5 fast horizontal b=2") +T.eq(Timing.SHAKE_HORIZ_SLOW2, 24, "type 6 slow horizontal, lb bc, 3, 2") + +-- Type 4 is the player's plain damaging move -- the most-seen animation in +-- the game. AnimationBlinkMon is `ld c, 6` of hide/DelayFrames 5/show/ +-- DelayFrames 5 (animations.asm:1360-1376): 60 frames, not the 20 the port +-- used to run. +T.eq(Timing.BLINK_MON, 60, "AnimationBlinkMon is 6 x (5 hidden + 5 shown)") +T.eq(Timing.BLINK_MON % 10, 0, + "and divides into whole 10-frame blinks, matching fxHidden's period") + +-- SlideDownFaintedMonPic: b = PIC_HEIGHT slide steps of DelayFrames 2 +T.eq(Timing.FAINT_SLIDE, 14, "the faint slide is 7 steps x 2 frames") + +T.eq(Timing.MOVE_STATUS_OR_MISS, 30, + "a status move or a miss holds DelayFrames 30 before its text") + +T.eq(Timing.MOVE_ANIM_PRE, 3, + "PlayMoveAnimation calls Delay3 before handing off to MoveAnimation") +T.eq(Timing.CRIT_OHKO_TEXT, 20, + "PrintCriticalOHKOText closes with DelayFrames 20") +T.eq(Timing.BATTLE_START_SENDOUT, 40, "StartBattle holds 40 after the send-out") + +-- An animation row pays PlayMoveAnimation's Delay3 before the first frame +-- of the animation, so the row goes back on the queue once (core.asm:6638). +do + local b = newBattle() + b.queue, b.nextInsert, b.current = {}, 0, nil + b.waitFrames, b.waitingSound = nil, nil + b.draining, b.animPlaying, b.waitingUI = nil, nil, nil + b.queue[1] = { anim = "FIX_TACKLE", attackerIsPlayer = true } + b:updateQueue() + T.eq(b.waitFrames, Timing.MOVE_ANIM_PRE, + "the anim row pays Delay3 before it plays") + T.eq(#b.queue, 1, "and is put back on the queue to run after the hold") + T.check(b.queue[1].animDelayed, "flagged so the hold is paid only once") +end + +-- StartBattle's 40-frame hold is unconditional -- the `call nz` gates only +-- EnemySendOutFirstMon -- so a wild battle queues it too, between +-- "Wild X appeared!" and "Go! Y!" (core.asm:152-156). +do + -- the intro queue is built by enter(), not by newWild + local b = newBattle() + local ok = pcall(b.enter, b) + T.check(ok, "a wild battle's intro builds") + local found = false + for _, row in ipairs(b.queue) do + if row.wait == Timing.BATTLE_START_SENDOUT then found = true break end + end + T.check(found, "a wild battle's intro queues the 40-frame send-out hold") +end + +-- waitNext is what puts that hold in the turn queue +do + local b = newBattle() + b.queue, b.nextInsert = {}, 0 + b:waitNext(Timing.MOVE_STATUS_OR_MISS) + T.eq(#b.queue, 1, "waitNext queues one row") + T.eq(b.queue[1].wait, Timing.MOVE_STATUS_OR_MISS, "carrying the hold length") + b:waitNext(0) + T.eq(#b.queue, 1, "a zero-length hold queues nothing") +end + +-- Battle text prints through the same PrintText path as overworld text, so +-- it pays PrintLetterDelay per character (home/print_text.asm:4-45): one +-- glyph per wOptions & $f frames, collapsing to one frame while A or B is +-- held. It used to run a flat two glyphs per frame -- six times hardware +-- speed at the default setting -- and ignored the text-speed option. +local function typedFrames(speed, hold) + local Btn = { down = {}, pressed = {} } + function Btn:isDown(k) return self.down[k] or false end + function Btn:wasPressed(k) return self.pressed[k] or false end + if hold then Btn.down.a = true end + + local b = newBattle() + b.game.input = Btn + b.game.save.options = b.game.save.options or {} + b.game.save.options.textSpeed = speed + b.queue, b.nextInsert = {}, 0 + b.waitFrames, b.waitingSound = nil, nil + b.draining, b.animPlaying, b.waitingUI = nil, nil, nil + b:startMessage({ text = "ABCDEF" }) + local frames = 0 + while (b.charIndex or 0) < 6 and frames < 400 do + b:updateQueue() + frames = frames + 1 + end + return frames +end + +T.eq(typedFrames(3), 18, "six glyphs at MEDIUM take 3 frames each") +T.eq(typedFrames(5), 30, "six glyphs at SLOW take 5 frames each") +T.eq(typedFrames(1), 6, "six glyphs at FAST take 1 frame each") +T.eq(typedFrames(3, true), 6, + "holding A collapses the per-letter wait to a single frame") + +-- WaitForSoundToFinish (home/delay.asm:15-20) is how the original gives a +-- sound its own clear window. A trainer intro plays SFX_Silph_Scope -- +-- extracted here as "Trainer_Appeared" -- blocks on it, and only then pays +-- the DelayFrames 20 before the balls and the text. +do + local b = newBattle() + b.queue, b.nextInsert, b.current = {}, 0, nil + b.waitFrames, b.waitingSound = nil, nil + b.draining, b.animPlaying, b.waitingUI = nil, nil, nil + local playing = true + local src = { isPlaying = function() return playing end } + b.queue[1] = { waitSound = function() return src end } + T.check(b:updateQueue(), "the waitSound row is taken off the queue") + T.eq(b.waitingSound, src, "and parks the queue on that source") + T.check(b:updateQueue(), "the queue blocks while the sound is audible") + playing = false + b:updateQueue() + T.eq(b.waitingSound, nil, "and releases the frame the sound stops") +end + +-- Every battle enters through the wipe, script-driven ones included. +-- BattleTransition runs from DoBattleTransitionAndInitBattleVariables for +-- all of them; start_battle used to push the BattleState straight onto the +-- stack, so every scripted trainer -- gym leaders, the rival -- and every +-- scripted wild battle cut to the battle screen with no transition at all. +do + local Commands = require("src.script.Commands") + local route = {} + local save = SaveData.newGame() + save.party = { Pokemon.new(Data, "FIXMON_A", 30) } + local ctx = { + game = { data = Data, save = save, + stack = { push = function() route[#route + 1] = "raw" end, + top = function() return nil end } }, + runner = { yield = function() end, resume = function() end }, + overworld = { pushBattle = function() route[#route + 1] = "wipe" end }, + } + Commands.start_battle(ctx, "wild", "FIXMON_C", 5) + T.eq(#route, 1, "start_battle pushes the battle exactly once") + T.eq(route[1], "wipe", "and routes it through the transition wipe") + + -- with no overworld (a headless or menu-driven caller) it still works + local route2 = {} + local ctx2 = { + game = { data = Data, save = save, + stack = { push = function() route2[#route2 + 1] = "raw" end, + top = function() return nil end } }, + runner = { yield = function() end, resume = function() end }, + } + Commands.start_battle(ctx2, "wild", "FIXMON_C", 5) + T.eq(route2[1], "raw", "and falls back to a direct push with no overworld") +end + +-- --------------------------------------------------- battle transition + +-- The eight wipes, from pokered-c's battle_transition.c budget (derived from +-- battle_transitions.asm, then checked against the ROM side by side). The +-- port used a flat 40/24 for all eight. +local BT = require("src.render.BattleTransition") +local WIPES = { + doublecircle = 30, -- 10 steps x 3 frames + circle = 60, -- 20 x 3 + spiralout = 120, -- 360 fills / 3 per frame + hstripes = 60, -- 20 x 3 + vstripes = 54, -- 18 x 3 + shrink = 54, -- 9 x 6 + split = 54, -- 9 x 6 +} +for id, frames in pairs(WIPES) do + T.eq(BT.STYLES[id].frames, frames, id .. " runs its asm frame budget") +end + +-- The inward spiral writes one tile per iteration and calls +-- BattleTransition_TransferDelay3 every seventh -- and that helper is a +-- Delay3, three frames, not a one-frame transfer. Reading it as one frame +-- gives ~46 frames against the ROM's ~150, which is the exact mistake +-- pokered-c caught on a live comparison. +T.eq(BT.STYLES.spiralin.frames % 3, 0, + "the inward spiral advances in whole Delay3 units") +T.check(BT.STYLES.spiralin.frames >= 130 and BT.STYLES.spiralin.frames <= 170, + "the inward spiral lands near the ROM's ~150 frames, not the ~46 a " + .. "one-frame transfer would give (got " + .. tostring(BT.STYLES.spiralin.frames) .. ")") + +-- The flash belongs to the two wild wipes only: BattleTransition_FlashScreen +-- is called from BattleTransition_Circle (:585) and _DoubleCircle (:628) and +-- nowhere else. A trainer battle's transition is the spiral, inward against +-- a weaker foe and outward against a stronger one +-- (wBattleTransitionSpiralDirection, :119-126). +do + local fakeRenderer = {} + local g = { data = Data, save = SaveData.newGame(), renderer = fakeRenderer, + stack = { push = function() end, pop = function() end, + top = function() return nil end } } + + local wild = BT.new(g, function() end, { trainer = false, stronger = false }) + T.eq(wild.style, "doublecircle", "a weak wild foe gets the double circle") + T.eq(wild.phase, "flash", "and flashes before the wipe") + + local weak = BT.new(g, function() end, { trainer = true, stronger = false }) + T.eq(weak.style, "spiralin", "a weaker trainer gets the inward spiral") + T.eq(weak.phase, "wipe", "with no flash in front of it") + + local strong = BT.new(g, function() end, { trainer = true, stronger = true }) + T.eq(strong.style, "spiralout", "a stronger trainer gets the outward spiral") + + -- the flash is a palette write, so it veils the whole surface: it is + -- handed to the renderer in screen space rather than filling the 160x144 + -- UI canvas, which at any zoom above 1x left the surround unlit + wild:draw() + T.check(fakeRenderer.screenVeil ~= nil, + "the flash publishes a screen-space veil to the renderer") + T.eq(#fakeRenderer.screenVeil, 2, "as a {shade, alpha} pair") +end + +-- Returning to the overworld after a battle is a fade, not a cut: +-- DelayFrames 10 (home/overworld.asm:351-352) with the palettes still white, +-- then MapEntryAfterBattle's GBFadeInFromWhite (:749-753) = 24 frames. +do + local popped, done = 0, 0 + local r = {} + local g = { data = Data, save = SaveData.newGame(), renderer = r, + stack = { push = function() end, + pop = function() popped = popped + 1 end, + top = function() return nil end } } + local fade = require("src.render.Transition").battleReturn(g, + function() done = done + 1 end) + + -- solid white through the 10-frame hold + for i = 1, Timing.POST_BATTLE_RETURN do + fade:draw() + T.eq(r.screenVeil[1], 1, "the veil is white on hold frame " .. i) + T.eq(r.screenVeil[2], 1, "and fully opaque on hold frame " .. i) + fade:update(1 / 60) + end + + -- then it steps off in three palette stages of 8 frames, the way + -- GBFadeIncCommon writes a palette and holds it (home/fade.asm:30-41) + fade:draw() + T.eq(r.screenVeil[2], 2 / 3, "the first fade step drops to two thirds") + for _ = 1, 8 do fade:update(1 / 60) end + fade:draw() + T.eq(r.screenVeil[2], 1 / 3, "the second step to one third") + for _ = 1, 8 do fade:update(1 / 60) end + fade:draw() + T.eq(r.screenVeil[2], 0, "the third clears it") + + -- total duration, measured on a fresh instance (the staircase checks above + -- advanced this one) + local popped2, done2 = 0, 0 + local g2 = { data = Data, save = SaveData.newGame(), renderer = {}, + stack = { push = function() end, + pop = function() popped2 = popped2 + 1 end, + top = function() return nil end } } + local fade2 = require("src.render.Transition").battleReturn(g2, + function() done2 = done2 + 1 end) + local frames = 0 + while done2 == 0 and frames < 500 do + fade2:update(1 / 60) + frames = frames + 1 + end + T.eq(frames, Timing.POST_BATTLE_RETURN + Timing.FADE_IN_FROM_WHITE, + "the whole return is the 10-frame hold plus a 24-frame fade") + T.eq(popped2, 1, "and it pops itself exactly once") + T.check(popped >= 0, "the staircase instance is independent") +end + +-- The spiral and circle walks generalise to an arbitrary grid, so a zoomed +-- or windowed surface wipes as one figure instead of a spiral in a box +-- surrounded by a square cascade. +do + local COLS_GB, ROWS_GB = 20, 18 -- the Game Boy's own tile grid + for _, style in ipairs({ "spiralin", "spiralout", "circle", "doublecircle" }) do + local order = BT.gridOrder(style, 40, 23) + T.check(order ~= nil, style .. " builds an order for an arbitrary grid") + T.eq(#order, 40 * 23, style .. " covers every tile of a 40x23 grid") + local seen, dup = {}, false + for _, t in ipairs(order) do + local k = t[1] .. "," .. t[2] + if seen[k] then dup = true end + seen[k] = true + end + T.check(not dup, style .. " visits each tile exactly once") + end + T.eq(BT.gridOrder("shrink", 40, 23), nil, + "geometry-shaped styles have no tile order and are extended as rects") + -- At exactly the Game Boy's grid, gridOrder hands back the ROM's own walk + -- rather than the generic one -- so an unzoomed window is the classic wipe. + -- BattleTransition_InwardSpiral fills 359 of the 360 tiles and leaves the + -- centre one to the final blackout, which is how you tell the two apart. + T.eq(#BT.gridOrder("spiralin", COLS_GB, ROWS_GB), 359, + "the classic grid gets the ROM's walk, not the generic spiral") + T.check(#BT.gridOrder("spiralin", COLS_GB + 1, ROWS_GB) + == (COLS_GB + 1) * ROWS_GB, + "one tile wider and it is the generic spiral, covering everything") + -- degenerate grids must not hang or error + T.eq(#BT.gridOrder("spiralin", 1, 1), 1, "a 1x1 grid is one tile") + T.eq(#BT.gridOrder("spiralout", 3, 1), 3, "a single-row grid walks straight") +end + +-- SlidePlayerAndEnemySilhouettesOnScreen: 144 px at 2 px/frame +T.eq(Timing.BATTLE_SLIDE_IN_FRAMES, 72, "the silhouettes slide for 72 frames") +T.eq(Timing.BATTLE_SLIDE_PX_PER_FRAME, 2, "at 2 px per frame") +T.eq(Timing.BATTLE_SLIDE_IN_FRAMES * Timing.BATTLE_SLIDE_PX_PER_FRAME, 144, + "which is the SCX $90 the enemy side scrolls through") + +T.eq(Timing.TRAINER_INTRO_SFX_GAP, 20, + "a trainer intro waits DelayFrames 20 before the balls and the text") + +-- ------------------------------------------------------- catch-up clamping + +-- Removing the warp fade in took away the counter that used to absorb the +-- map-load hitch, so discardCatchup has to handle the oversized dt the hitch +-- produces on the FOLLOWING frame -- otherwise the burst just moves one +-- frame later and shows up as a walk-animation slide (issue #93). +local FixedStep = require("src.core.FixedStep") + +local steps = 0 +FixedStep:init(function() steps = steps + 1 end) +FixedStep:update(1 / 60) +T.eq(steps, 1, "an ordinary frame runs exactly one logic step") + +-- what the hitch does when nothing is armed +steps = 0 +FixedStep:update(0.25) +T.check(steps > 10, + "an unclamped hitch frame burns a burst of steps before the next draw") + +-- and with the clamp armed +FixedStep:init(function() steps = steps + 1 end) +FixedStep:discardCatchup() +steps = 0 +FixedStep:update(0.25) +T.eq(steps, 1, "the frame after discardCatchup is clamped to one step") + +steps = 0 +FixedStep:update(1 / 60) +T.eq(steps, 1, "and the clamp expires after that one frame") + +T.finish("timing parity") diff --git a/tests/engine/title_fill_scale.lua b/tests/engine/title_fill_scale.lua new file mode 100644 index 00000000..4ad03d40 --- /dev/null +++ b/tests/engine/title_fill_scale.lua @@ -0,0 +1,50 @@ +-- The title screen and the intro fill the window (aspect preserved, bars on +-- the long axis) instead of sitting at the fixed integer scale, and the +-- overworld's survey zoom does not shrink them. +-- +-- Both halves shipped broken together. Renderer:uiScale steps the UI down one +-- whole integer per zoom-out step, which is right for the overworld -- a +-- full-size dialogue box over a shrunken map looks wrong -- but it was applied +-- unconditionally, so a saved zoom of -2 also drew the TITLE SCREEN at a +-- reduced scale, in a window showing no map at all. The fix gates the +-- step-down on a world actually being on screen, and opts these two states +-- into the fill scale the battle "fill" size already uses. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Game = require("src.core.Game") +local TitleState = require("src.ui.TitleState") +local IntroMovie = require("src.ui.IntroMovie") + +-- --------------------------------------------------------------- the opt-in + +local title = setmetatable({}, { __index = TitleState }) +local intro = setmetatable({}, { __index = IntroMovie }) + +T.eq(title:wantsFillScale(), true, "the title screen asks for the fill scale") +T.eq(intro:wantsFillScale(), true, "and so does the intro") + +-- neither reads options, so this must hold with no game attached at all -- +-- the title screen is up before a save is loaded +T.eq(TitleState.wantsFillScale(nil), true, + "the title screen fills with no game or save behind it") +T.eq(IntroMovie.wantsFillScale(nil), true, "and so does the intro") + +-- ------------------------------------------------------------- the stack scan + +local function stack(...) return { states = { ... } } end +local overworld = {} -- no wantsFillScale at all, like every other state + +T.eq(Game.fillScaleInStack(stack(title)), true, + "the shared scan picks the title screen up") +T.eq(Game.fillScaleInStack(stack(intro)), true, "and the intro") +T.eq(Game.fillScaleInStack(stack(overworld)), false, + "and the overworld still draws at the fixed scale") + +-- the title screen opens the CONTINUE/NEW GAME menu and the options menu on +-- top of itself; those must not snap the surface back for a frame, the same +-- whole-stack rule a battle relies on +T.eq(Game.fillScaleInStack(stack(title, {})), true, + "a menu opened over the title screen keeps it filling") + +T.finish("title fill scale") From 4cb132199cf0eb47254a66f9ae9d9b8cf9d0f537 Mon Sep 17 00:00:00 2001 From: spiritsnails <307422241+spiritsnails@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:04:22 -0600 Subject: [PATCH 2/5] Fix ADVANCED palette pack using Gen 2 per-species palettes instead of Gen 1's pokered-gbc's palettes.asm carries two species->palette tables gated on GEN_2_GRAPHICS; data/palettes_gbc.lua had imported the per-species Gen 2 table, shaded for Gen 2 sprite art this port doesn't use, instead of Gen 1's own assignments. Bulbasaur wore PAL_BULBASAUR's red-orange, Squirtle wore PAL_SQUIRTLE's shell brown on his head. Palette values are unchanged; only which palette each species points at is corrected. --- data/palettes_gbc.lua | 316 ++++++++++++++------------ tests/engine/advanced_palette_map.lua | 81 +++++++ 2 files changed, 246 insertions(+), 151 deletions(-) create mode 100644 tests/engine/advanced_palette_map.lua diff --git a/data/palettes_gbc.lua b/data/palettes_gbc.lua index 5c98c88b..66a66e19 100644 --- a/data/palettes_gbc.lua +++ b/data/palettes_gbc.lua @@ -1683,158 +1683,172 @@ return { { 0, 0, 0 }, }, }, + -- Species -> palette NAME, taken from pokered-gbc data/pokemon/palettes.asm + -- ELSE branch -- the one that is NOT gated on GEN_2_GRAPHICS. + -- + -- That file has two tables. The IF GEN_2_GRAPHICS branch assigns a + -- PER-SPECIES palette (PAL_BULBASAUR, PAL_SQUIRTLE, ...) authored for Gen 2 + -- sprite art; the ELSE branch keeps Gen 1's own assignments (GREENMON, + -- CYANMON, ...). This port extracts Gen 1 pics from the ROM, so the Gen 1 + -- branch is the matching one. The per-species table was imported here by + -- mistake, which pointed every mon at colours shaded for different art: + -- Bulbasaur wore PAL_BULBASAUR's red-orange over a sprite that has no red + -- on it, and Squirtle wore PAL_SQUIRTLE's shell brown on his head. + -- + -- The palette VALUES below are still pokered-gbc's, so ADVANCED keeps its + -- richer colours -- only which palette each species points at changed. pokemon = { - ABRA = "ABRA", - AERODACTYL = "AERODACTYL", - ALAKAZAM = "ALAKAZAM", - ARBOK = "ARBOK", - ARCANINE = "ARCANINE", - ARTICUNO = "ARTICUNO", - BEEDRILL = "BEEDRILL", - BELLSPROUT = "BELLSPROUT", - BLASTOISE = "BLASTOISE", - BULBASAUR = "BULBASAUR", - BUTTERFREE = "BUTTERFREE", - CATERPIE = "CATERPIE", - CHANSEY = "CHANSEY", - CHARIZARD = "CHARIZARD", - CHARMANDER = "CHARMANDER", - CHARMELEON = "CHARMELEON", - CLEFABLE = "CLEFABLE", - CLEFAIRY = "CLEFAIRY", - CLOYSTER = "CLOYSTER", - CUBONE = "CUBONE", - DEWGONG = "DEWGONG", - DIGLETT = "DIGLETT", - DITTO = "DITTO", - DODRIO = "DODRIO", - DODUO = "DODUO", - DRAGONAIR = "DRAGONAIR", - DRAGONITE = "DRAGONITE", - DRATINI = "DRATINI", - DROWZEE = "DROWZEE", - DUGTRIO = "DUGTRIO", - EEVEE = "EEVEE", - EKANS = "EKANS", - ELECTABUZZ = "ELECTABUZZ", - ELECTRODE = "ELECTRODE", - EXEGGCUTE = "EXEGGCUTE", - EXEGGUTOR = "EXEGGUTOR", - FARFETCHD = "FARFETCH_D", - FEAROW = "FEAROW", - FLAREON = "FLAREON", - GASTLY = "GASTLY", - GENGAR = "GENGAR", - GEODUDE = "GEODUDE", - GLOOM = "GLOOM", - GOLBAT = "GOLBAT", - GOLDEEN = "GOLDEEN", - GOLDUCK = "GOLDUCK", - GOLEM = "GOLEM", - GRAVELER = "GRAVELER", - GRIMER = "GRIMER", - GROWLITHE = "GROWLITHE", - GYARADOS = "GYARADOS", - HAUNTER = "HAUNTER", - HITMONCHAN = "HITMONCHAN", - HITMONLEE = "HITMONLEE", - HORSEA = "HORSEA", - HYPNO = "HYPNO", - IVYSAUR = "IVYSAUR", - JIGGLYPUFF = "JIGGLYPUFF", - JOLTEON = "JOLTEON", - JYNX = "JYNX", - KABUTO = "KABUTO", - KABUTOPS = "KABUTOPS", - KADABRA = "KADABRA", - KAKUNA = "KAKUNA", - KANGASKHAN = "KANGASKHAN", - KINGLER = "KINGLER", - KOFFING = "KOFFING", - KRABBY = "KRABBY", - LAPRAS = "LAPRAS", - LICKITUNG = "LICKITUNG", - MACHAMP = "MACHAMP", - MACHOKE = "MACHOKE", - MACHOP = "MACHOP", - MAGIKARP = "MAGIKARP", - MAGMAR = "MAGMAR", - MAGNEMITE = "MAGNEMITE", - MAGNETON = "MAGNETON", - MANKEY = "MANKEY", - MAROWAK = "MAROWAK", - MEOWTH = "MEOWTH", - METAPOD = "METAPOD", - MEW = "MEW", - MEWTWO = "MEWTWO", - MOLTRES = "MOLTRES", - MR_MIME = "MR_MIME", - MUK = "MUK", - NIDOKING = "NIDOKING", - NIDOQUEEN = "NIDOQUEEN", - NIDORAN_F = "NIDORAN_F", - NIDORAN_M = "NIDORAN_M", - NIDORINA = "NIDORINA", - NIDORINO = "NIDORINO", - NINETALES = "NINETALES", - ODDISH = "ODDISH", - OMANYTE = "OMANYTE", - OMASTAR = "OMASTAR", - ONIX = "ONIX", - PARAS = "PARAS", - PARASECT = "PARASECT", - PERSIAN = "PERSIAN", - PIDGEOT = "PIDGEOT", - PIDGEOTTO = "PIDGEOTTO", - PIDGEY = "PIDGEY", - PIKACHU = "PIKACHU", - PINSIR = "PINSIR", - POLIWAG = "POLIWAG", - POLIWHIRL = "POLIWHIRL", - POLIWRATH = "POLIWRATH", - PONYTA = "PONYTA", - PORYGON = "PORYGON", - PRIMEAPE = "PRIMEAPE", - PSYDUCK = "PSYDUCK", - RAICHU = "RAICHU", - RAPIDASH = "RAPIDASH", - RATICATE = "RATICATE", - RATTATA = "RATTATA", - RHYDON = "RHYDON", - RHYHORN = "RHYHORN", - SANDSHREW = "SANDSHREW", - SANDSLASH = "SANDSLASH", - SCYTHER = "SCYTHER", - SEADRA = "SEADRA", - SEAKING = "SEAKING", - SEEL = "SEEL", - SHELLDER = "SHELLDER", - SLOWBRO = "SLOWBRO", - SLOWPOKE = "SLOWPOKE", - SNORLAX = "SNORLAX", - SPEAROW = "SPEAROW", - SQUIRTLE = "SQUIRTLE", - STARMIE = "STARMIE", - STARYU = "STARYU", - TANGELA = "TANGELA", - TAUROS = "TAUROS", - TENTACOOL = "TENTACOOL", - TENTACRUEL = "TENTACRUEL", - VAPOREON = "VAPOREON", - VENOMOTH = "VENOMOTH", - VENONAT = "VENONAT", - VENUSAUR = "VENUSAUR", - VICTREEBEL = "VICTREEBEL", - VILEPLUME = "VILEPLUME", - VOLTORB = "VOLTORB", - VULPIX = "VULPIX", - WARTORTLE = "WARTORTLE", - WEEDLE = "WEEDLE", - WEEPINBELL = "WEEPINBELL", - WEEZING = "WEEZING", - WIGGLYTUFF = "WIGGLYTUFF", - ZAPDOS = "ZAPDOS", - ZUBAT = "ZUBAT", + ABRA = "YELLOWMON", + AERODACTYL = "GRAYMON", + ALAKAZAM = "YELLOWMON", + ARBOK = "PURPLEMON", + ARCANINE = "REDMON", + ARTICUNO = "BLUEMON", + BEEDRILL = "YELLOWMON", + BELLSPROUT = "GREENMON", + BLASTOISE = "CYANMON", + BULBASAUR = "GREENMON", + BUTTERFREE = "CYANMON", + CATERPIE = "GREENMON", + CHANSEY = "PINKMON", + CHARIZARD = "REDMON", + CHARMANDER = "REDMON", + CHARMELEON = "REDMON", + CLEFABLE = "PINKMON", + CLEFAIRY = "PINKMON", + CLOYSTER = "GRAYMON", + CUBONE = "GRAYMON", + DEWGONG = "BLUEMON", + DIGLETT = "BROWNMON", + DITTO = "GRAYMON", + DODRIO = "BROWNMON", + DODUO = "BROWNMON", + DRAGONAIR = "BLUEMON", + DRAGONITE = "BROWNMON", + DRATINI = "GRAYMON", + DROWZEE = "YELLOWMON", + DUGTRIO = "BROWNMON", + EEVEE = "GRAYMON", + EKANS = "PURPLEMON", + ELECTABUZZ = "YELLOWMON", + ELECTRODE = "YELLOWMON", + EXEGGCUTE = "PINKMON", + EXEGGUTOR = "GREENMON", + FARFETCHD = "BROWNMON", + FEAROW = "BROWNMON", + FLAREON = "REDMON", + GASTLY = "PURPLEMON", + GENGAR = "PURPLEMON", + GEODUDE = "GRAYMON", + GLOOM = "REDMON", + GOLBAT = "BLUEMON", + GOLDEEN = "REDMON", + GOLDUCK = "CYANMON", + GOLEM = "GRAYMON", + GRAVELER = "GRAYMON", + GRIMER = "PURPLEMON", + GROWLITHE = "BROWNMON", + GYARADOS = "BLUEMON", + HAUNTER = "PURPLEMON", + HITMONCHAN = "BROWNMON", + HITMONLEE = "BROWNMON", + HORSEA = "CYANMON", + HYPNO = "YELLOWMON", + IVYSAUR = "GREENMON", + JIGGLYPUFF = "PINKMON", + JOLTEON = "YELLOWMON", + JYNX = "MEWMON", + KABUTO = "BROWNMON", + KABUTOPS = "BROWNMON", + KADABRA = "YELLOWMON", + KAKUNA = "YELLOWMON", + KANGASKHAN = "BROWNMON", + KINGLER = "REDMON", + KOFFING = "PURPLEMON", + KRABBY = "REDMON", + LAPRAS = "CYANMON", + LICKITUNG = "PINKMON", + MACHAMP = "GRAYMON", + MACHOKE = "GRAYMON", + MACHOP = "GRAYMON", + MAGIKARP = "REDMON", + MAGMAR = "REDMON", + MAGNEMITE = "GRAYMON", + MAGNETON = "GRAYMON", + MANKEY = "BROWNMON", + MAROWAK = "GRAYMON", + MEOWTH = "YELLOWMON", + METAPOD = "GREENMON", + MEW = "MEWMON", + MEWTWO = "MEWMON", + MOLTRES = "REDMON", + MR_MIME = "PINKMON", + MUK = "PURPLEMON", + NIDOKING = "PURPLEMON", + NIDOQUEEN = "BLUEMON", + NIDORAN_F = "BLUEMON", + NIDORAN_M = "PURPLEMON", + NIDORINA = "BLUEMON", + NIDORINO = "PURPLEMON", + NINETALES = "YELLOWMON", + ODDISH = "GREENMON", + OMANYTE = "BLUEMON", + OMASTAR = "BLUEMON", + ONIX = "GRAYMON", + PARAS = "REDMON", + PARASECT = "REDMON", + PERSIAN = "YELLOWMON", + PIDGEOT = "BROWNMON", + PIDGEOTTO = "BROWNMON", + PIDGEY = "BROWNMON", + PIKACHU = "YELLOWMON", + PINSIR = "BROWNMON", + POLIWAG = "BLUEMON", + POLIWHIRL = "BLUEMON", + POLIWRATH = "BLUEMON", + PONYTA = "REDMON", + PORYGON = "GRAYMON", + PRIMEAPE = "BROWNMON", + PSYDUCK = "YELLOWMON", + RAICHU = "YELLOWMON", + RAPIDASH = "REDMON", + RATICATE = "GRAYMON", + RATTATA = "GRAYMON", + RHYDON = "GRAYMON", + RHYHORN = "GRAYMON", + SANDSHREW = "BROWNMON", + SANDSLASH = "BROWNMON", + SCYTHER = "GREENMON", + SEADRA = "CYANMON", + SEAKING = "REDMON", + SEEL = "BLUEMON", + SHELLDER = "GRAYMON", + SLOWBRO = "PINKMON", + SLOWPOKE = "PINKMON", + SNORLAX = "PINKMON", + SPEAROW = "BROWNMON", + SQUIRTLE = "CYANMON", + STARMIE = "GRAYMON", + STARYU = "REDMON", + TANGELA = "BLUEMON", + TAUROS = "GRAYMON", + TENTACOOL = "CYANMON", + TENTACRUEL = "CYANMON", + VAPOREON = "CYANMON", + VENOMOTH = "PURPLEMON", + VENONAT = "PURPLEMON", + VENUSAUR = "GREENMON", + VICTREEBEL = "GREENMON", + VILEPLUME = "REDMON", + VOLTORB = "YELLOWMON", + VULPIX = "REDMON", + WARTORTLE = "CYANMON", + WEEDLE = "YELLOWMON", + WEEPINBELL = "GREENMON", + WEEZING = "PURPLEMON", + WIGGLYTUFF = "PINKMON", + ZAPDOS = "YELLOWMON", + ZUBAT = "BLUEMON", }, source = "pokered-gbc data/super_palettes.asm + data/mon_palettes.asm + color/**", world = { diff --git a/tests/engine/advanced_palette_map.lua b/tests/engine/advanced_palette_map.lua new file mode 100644 index 00000000..67cddd10 --- /dev/null +++ b/tests/engine/advanced_palette_map.lua @@ -0,0 +1,81 @@ +-- ADVANCED (redpp) must use pokered-gbc's GEN 1 species->palette map. +-- +-- pokered-gbc's data/pokemon/palettes.asm carries TWO tables: +-- +-- IF GEN_2_GRAPHICS db PAL_BULBASAUR / PAL_SQUIRTLE / ... (per species) +-- ELSE db PAL_GREENMON ; BULBASAUR +-- db PAL_CYANMON ; SQUIRTLE (Gen 1's own) +-- ENDC +-- +-- The per-species palettes are authored for GEN 2 sprite art, whose shading +-- puts different regions on different 2bpp shades. This port extracts Gen 1 +-- pics from the ROM, so the ELSE branch is the matching one. +-- +-- data/palettes_gbc.lua had imported the GEN_2_GRAPHICS table, which pointed +-- every species at colours shaded for art it does not use. Bulbasaur wore +-- PAL_BULBASAUR's red-orange (255,82,49) across 231 pixels of a sprite that +-- has no red on it at all, and Squirtle wore PAL_SQUIRTLE's shell brown on +-- his head instead of blue. It looked least wrong on mons whose two mid +-- tones are close in hue, which is why it survived so long. +-- +-- The palette VALUES are untouched -- ADVANCED keeps pokered-gbc's richer +-- colours. Only the species -> name mapping changed. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local pack = require("data.palettes_gbc") + +T.check(pack and pack.pokemon, "the ADVANCED pack carries a species map") + +local n = 0 +for _ in pairs(pack.pokemon) do n = n + 1 end +T.eq(n, 151, "every species is mapped") + +-- Gen 1's palette set: the ten MonsterPalettes names plus nothing else. A +-- per-species name here means the GEN_2_GRAPHICS table crept back in. +local GEN1_NAMES = { + MEWMON = true, BLUEMON = true, REDMON = true, CYANMON = true, + PURPLEMON = true, BROWNMON = true, GREENMON = true, PINKMON = true, + YELLOWMON = true, GRAYMON = true, +} + +local strays = {} +for species, name in pairs(pack.pokemon) do + if not GEN1_NAMES[name] then strays[#strays + 1] = species .. "->" .. name end +end +table.sort(strays) +T.eq(#strays, 0, + "no species points at a Gen-2-only per-species palette (" .. + table.concat(strays, ", ", 1, math.min(#strays, 6)) .. ")") + +-- the four the bug was reported on, plus their lines +local EXPECT = { + BULBASAUR = "GREENMON", IVYSAUR = "GREENMON", VENUSAUR = "GREENMON", + CHARMANDER = "REDMON", CHARMELEON = "REDMON", CHARIZARD = "REDMON", + SQUIRTLE = "CYANMON", WARTORTLE = "CYANMON", BLASTOISE = "CYANMON", + MEW = "MEWMON", MEWTWO = "MEWMON", JYNX = "MEWMON", + LAPRAS = "CYANMON", +} +for species, want in pairs(EXPECT) do + T.eq(pack.pokemon[species], want, species .. " uses " .. want) +end + +-- Bulbasaur's palette must contain no red channel dominance in either mid -- +-- the concrete symptom that was reported ("he shouldn't have red anywhere"). +local green = pack.palettes[pack.pokemon.BULBASAUR] +T.check(green ~= nil, "GREENMON resolves to colours") +for _, i in ipairs({ 2, 3 }) do + local c = green[i] + T.check(c[2] > c[1], ("GREENMON mid %d is green-dominant, not red (%d,%d,%d)") + :format(i - 1, c[1], c[2], c[3])) +end + +-- and Squirtle's mids must be blue-dominant +local cyan = pack.palettes[pack.pokemon.SQUIRTLE] +for _, i in ipairs({ 2, 3 }) do + local c = cyan[i] + T.check(c[3] >= c[1], ("CYANMON mid %d is blue-dominant (%d,%d,%d)") + :format(i - 1, c[1], c[2], c[3])) +end + +T.finish("advanced palette map") From f109530c5f23a483366593aa385a511747c1db89 Mon Sep 17 00:00:00 2001 From: spiritsnails <307422241+spiritsnails@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:12:29 -0600 Subject: [PATCH 3/5] Route scripted battles through pushBattle so the entry wipe actually plays Commands.lua's start_battle and old-man-demo battle commands pushed BattleState straight onto the stack, bypassing pushBattle (and therefore BattleTransition) entirely. Every script-triggered battle -- gym leaders, the rival, Giovanni, the catch tutorial -- cut straight to the battle screen with no transition wipe. Only the walk-up trainer-sight path (OverworldState:engageTrainer) went through pushBattle already. --- src/script/Commands.lua | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 93ed8869..c8e468d7 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -281,7 +281,19 @@ function Commands.start_battle(ctx, kind, a, b) end runner:resume() end - ctx.game.stack:push(battle) + -- Every battle enters through the transition wipe, script-driven ones + -- included: BattleTransition (engine/battle/battle_transitions.asm:1) runs + -- from DoBattleTransitionAndInitBattleVariables for all of them, and + -- GetBattleTransitionID_WildOrTrainer picks the style from the battle kind. + -- Pushing the BattleState straight onto the stack skipped the wipe + -- entirely, so every scripted trainer -- gym leaders, the rival, Giovanni -- + -- and every scripted wild battle simply cut to the battle screen. The + -- trainer-sight path already went through pushBattle; this one did not. + if ctx.overworld and ctx.overworld.pushBattle then + ctx.overworld:pushBattle(battle) + else + ctx.game.stack:push(battle) + end runner:yield() end @@ -770,7 +782,15 @@ function Commands.old_man_demo(ctx) local battle = BattleState.newWild(ctx.game, om.species, om.level) battle:makeOldManDemo() battle.onFinish = function() runner:resume() end - ctx.game.stack:push(battle) + -- InitWildBattle calls DoBattleTransitionAndInitBattleVariables + -- unconditionally (core.asm:6699) -- there is no BATTLE_TYPE_OLD_MAN + -- special case -- so the catch tutorial gets the wipe like any other + -- wild battle + if ctx.overworld and ctx.overworld.pushBattle then + ctx.overworld:pushBattle(battle) + else + ctx.game.stack:push(battle) + end runner:yield() end From 6b012a31ce559b5d4f918ee87caf1375bfa2492d Mon Sep 17 00:00:00 2001 From: spiritsnails <307422241+spiritsnails@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:41:51 -0600 Subject: [PATCH 4/5] Guarantee the post-battle fade-in by moving it into BattleState:finish() pushBattle wrapped onFinish to push Transition.battleReturn on any non-lose result, but that only fires for callers that route through pushBattle correctly. Moving the same push into BattleState:finish() instead -- the one choke point every battle (wild, trainer, walk-up, scripted, link) already passes through on exit -- makes the fade unconditional rather than dependent on each call site's wiring. --- src/battle/BattleState.lua | 16 +++++++++++++++- src/world/OverworldController.lua | 30 ++++-------------------------- 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 2830497f..075572a9 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -4430,7 +4430,21 @@ function BattleState:finish() require("src.core.Music").restoreMap(self.data) self.game.stack:pop() Runtime.emit("battle.ended", { battle = self, result = self.result or "run" }) - if self.onFinish then self.onFinish(self.result or "run") end + -- Coming back from the battle screen is a fade, not a cut: EnterMap sees + -- BIT_BATTLE_OVER_OR_BLACKOUT set and runs MapEntryAfterBattle + -- (home/overworld.asm:22, :749-753) = GBFadeInFromWhite. This is the one + -- choke point every battle -- wild, trainer, walk-up, scripted, link -- + -- passes through on its way out, so the fade is guaranteed here rather + -- than depending on each caller having wrapped onFinish correctly. + local result = self.result or "run" + local onFinish = self.onFinish + if result == "lose" then + -- the blackout path warps to the heal point with its own transition + if onFinish then onFinish(result) end + return + end + self.game.stack:push(require("src.render.Transition").battleReturn(self.game, + function() if onFinish then onFinish(result) end end)) end -- --------------------------------------------------------------------- diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 49564151..ac620cd4 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -718,32 +718,10 @@ function OverworldState:pushBattle(battle) require("src.core.Music").playBattle(Game.data, battle:computeMusicKind()) end - -- Coming back from the battle screen is a fade, not a cut: EnterMap sees - -- BIT_BATTLE_OVER_OR_BLACKOUT set and runs MapEntryAfterBattle - -- (home/overworld.asm:22, :749-753) = GBFadeInFromWhite, behind the - -- `ld c, 10 / call DelayFrames` at :351-352. - -- - -- It is wrapped around onFinish here, at the one funnel every battle goes - -- through, rather than inside afterBattle: a script-driven win defers - -- afterBattle into ctx.afterScript so an evolution screen cannot be buried - -- under the trainer's follow-up text (see Commands.start_battle), and the - -- fade inherited that deferral -- on a rival battle it fired after the - -- post-battle dialogue AND the walk-off, instead of 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 get to run. The overworld is frozen meanwhile -- - -- StateStack updates the top state only -- so nothing moves under it. - local finish = battle.onFinish - battle.onFinish = function(result) - if result == "lose" then - -- the blackout path warps to the heal point with its own transition - if finish then finish(result) end - return - end - Game.stack:push(require("src.render.Transition").battleReturn(Game, - function() if finish then finish(result) end end)) - end + -- The fade back in from white on the way out is BattleState:finish()'s + -- job now -- the one choke point every battle passes through on exit, + -- guaranteed regardless of which caller pushed the battle -- so this + -- function only owns the entry wipe. Game.stack:push(BattleTransition.new(Game, function() Game.stack:push(battle) end, { From b820d3917c7e8200176483b30da6b6ea08f0aba1 Mon Sep 17 00:00:00 2001 From: spiritsnails <307422241+spiritsnails@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:13:03 -0600 Subject: [PATCH 5/5] fixing failed tests/harnesses --- src/core/FixedStep.lua | 21 +++++++++++++++++---- tests/engine/rebind_swap_clear_bug589.lua | 11 +++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/core/FixedStep.lua b/src/core/FixedStep.lua index 39cd980f..fe773e27 100644 --- a/src/core/FixedStep.lua +++ b/src/core/FixedStep.lua @@ -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 diff --git a/tests/engine/rebind_swap_clear_bug589.lua b/tests/engine/rebind_swap_clear_bug589.lua index 8efd19d0..0e49d03d 100644 --- a/tests/engine/rebind_swap_clear_bug589.lua +++ b/tests/engine/rebind_swap_clear_bug589.lua @@ -17,6 +17,7 @@ love = love or require("tests.love_stub") local Input = require("src.core.Input") local Strings = require("src.core.Strings") +local Timing = require("src.core.Timing") local BindingsMenu = require("src.ui.BindingsMenu") -- same doubles as rebind_capture_bug510: a stack the menu can pop itself @@ -45,6 +46,14 @@ local function press(state, btn) state.game.input.queue = {} end +-- ChoiceBox now holds YES/NO answers on screen for Timing.YES_NO_ANSWER +-- frames before it commits and pops (DisplayTwoOptionMenu's 15-frame hold, +-- #GH-627 timing parity); a bare press only arms the choice, so answering +-- it needs the hold run out before the pop/commit is visible. +local function settleChoice(state) + for _ = 1, Timing.YES_NO_ANSWER do state:update(1 / 60) end +end + -- rows are BindingsMenu's BUTTONS order local ROW_A, ROW_B, ROW_SELECT = 5, 6, 8 @@ -146,6 +155,7 @@ eq(bm.footer, Strings("RESET ALL BINDINGS?"), -- the box starts on NO: a bare A press must keep the overlay press(box, "a") +settleChoice(box) eq(game.stack:top(), bm, "answering pops the box") check(game.save.options.bindings ~= nil, "NO keeps the bindings (defaultNo)") eq(bm.items[ROW_A].right, "P/B", "and the rows keep showing them") @@ -155,6 +165,7 @@ press(bm, "start") box = game.stack:top() press(box, "up") press(box, "a") +settleChoice(box) check(game.save.options.bindings == nil, "YES clears options.bindings (#589)") eq(bm.items[ROW_A].right, "Z/A", "the A row reads its default again") eq(bm.items[ROW_B].right, "X/B", "so does the B row the swap had touched")