Skip to content

Add battle-init smoke test and pre-push pytest hook - #30

Merged
manz merged 9 commits into
masterfrom
dev/battle-init-smoke
May 16, 2026
Merged

Add battle-init smoke test and pre-push pytest hook#30
manz merged 9 commits into
masterfrom
dev/battle-init-smoke

Conversation

@manz

@manz manz commented May 15, 2026

Copy link
Copy Markdown
Owner

Summary

The bank-20 relocation iteration shipped a build that passed format / lint but BRK-trapped during battle redraw on the first menu interaction. Build + format hooks could not see it, and the kintsuki suite never ran on the broken iteration because I pushed the fix before testing locally.

This branch adds two layers of protection.

Layer one is a battle-init smoke test (tests/test_battle_init.py). It loads ff4-battle-ext.kss at the Battle_ext seed point, settles 120 frames, asserts the CPU did not execute STP, and pixel-matches the framebuffer against a PNG golden. The savestate sits at the exact entry the bank-20 reloc work touches (sram_patches + redraw_writer_patches both seed gate state at Battle_ext), so any regression in the bank-02 trampolines or bank-20 module placement shows up either as a STP halt or a screen diff.

Layer two is a pytest pre-push hook in .pre-commit-config.yaml. Tests stay out of the per-commit critical path (134 tests, kintsuki spin-up) but run once before any push, so a broken patch can no longer escape the local checkout.

Test plan

  • pytest tests/test_battle_init.py passes on the current master tree
  • Golden PNG recorded with UPDATE_GOLDENS=1 and replays clean
  • pre-commit run --hook-stage pre-push --all-files triggers the pytest hook

Loads ff4-battle-ext.kss at the Battle_ext seed point, settles 120
frames, asserts the CPU never STPs, and pixel-matches the
framebuffer against a PNG golden. Catches the regression that the
bank-20 reloc iteration shipped: build + format hooks passed but
the engine BRK-trapped during DrawText on first menu interaction.

Wire pytest as a pre-push hook so the full kintsuki suite runs once
before any push, catching similar codegen regressions before they
reach the remote.
@github-actions

github-actions Bot commented May 15, 2026

Copy link
Copy Markdown

🎮 FF4 IPS Build Ready!

Your patch has been built successfully! Download the IPS file from the artifacts:

📦 Artifact: ff4-ips-pr-30
🔗 Download: Click here to go to the build page

The artifact will be available for 30 days.
Last updated: bfad9e3

manz added 8 commits May 15, 2026 23:42
120 frames left the slice-2 redraw gates clean: names and the cmd
list intentionally do not draw on the first ATB tick. Bump to 600
frames so ATB fills the first character's command list and the
golden actually exercises the rendering path the bank-20 reloc
regression broke.
Slice-2 redraw gates stayed clean through the 600-frame settle, so
the golden captured battle with empty name + monster headers and
only the cmd list visible. Poke battle_menu_dirty ($7E:EF9A) and
battle_monster_dirty ($7E:EF9B) to $FF plus the slice-2
region_dirty_bits queue at $70:3C01 before running frames, so
DrawCharNames + DrawMonsterNames fire on the first frame. Golden
now shows monster sprite + label, char names, cmd list and HP/MP
boxes.
Space ($FF) glyph bytes are all zero so the row loop only OR-stores
zeros into the tile buffer — visually a no-op, but it still pays the
8 `lda assets_menu_font_dat, x` + `inx` round-trip plus the shift /
store dance when bits_left_on_tile is unaligned. Bump X past the
glyph rows up front and jump straight to brk_bits_left so the width
byte still drives the tilemap advance.
Loads ff4-battle-ext.kss, pokes every redraw-gate dirty bit so the
char names, monster names and command list re-render on every
profiled frame, then runs profile_start over the 4 KB neighbourhood
around battle_render.display_char and prints per-function call
counts plus inclusive / exclusive cycles. Sorts by exclusive so the
hot inner loops surface first. Use as the baseline before / after
any change to the 8x8 VWF blit or the kerning binary search:

    python3 tests/_profile/profile_battle_vwf.py > /tmp/vwf.before
    # apply change, rebuild
    python3 tests/_profile/profile_battle_vwf.py > /tmp/vwf.after
    diff /tmp/vwf.before /tmp/vwf.after

Current numbers on master: _display_char 22 calls, 140k excl cycles
over 60 frames; _get_kerning_adjustment_binary_search 22 calls,
84k cycles ; _adjust_bits_left_for_kerning 103k inclusive.
prev_char or current_char of \$FF (space) has no kerning entries in
any font's pair table, so the binary search always reports "not
found" after walking the full table. Early-out at the top of
_adjust_bits_left_for_kerning and fall through to _finalize so
prev_char still advances for the next call.

Profile delta on the battle VWF bench (60 frames, all redraw gates
forced dirty):

  binary search calls:    22 -> 15  (-32%)
  binary search cycles:   84792 -> 57670  (-32%)
  _adjust_bits_left incl: 103824 -> 74088 (-29%)
  total incl (60f):       3107570 -> 2931590 (-5.7%)
Tier-2 optimisation. The original char_line_loop ran the cmp #8 +
jmp-table dispatch + asl ladder per row even though bits_left_on_tile
is invariant across the 8 rows of a single glyph. Hoist the
classification to glyph entry, dispatch once into one of 8 loops
(aligned + shift_0..shift_7) whose asl chain is baked into the body
via a .for macro. Aligned drops the redundant ora/sta on the high
bitplane (always a noop) for an extra ~80 cy/glyph.

X holds the font pointer at entry and the shift dispatch clobbers it
for the indirect jump, so the font pointer is stashed in temp first
and each shift loop restores it via ldx.b temp on entry. Aligned
bypasses save / restore since it never touches X.

Profile delta on the battle VWF bench (60 frames, all redraw gates
forced dirty):

  _display_char excl:   139954 -> 97588  (-30%)
  _display_char avg:    6361 -> 4435     (-30%)
  total inclusive:      2931590 -> 2416816 (-17.6%)
  total exclusive:      819092 -> 773366  (-5.6%)

Cumulative vs master (space-skip + kerning early-out + tier 2):

  total inclusive:      3107570 -> 2416816 (-22.2%)
  _display_char avg:    6365 -> 4435      (-30.3%)

ROM cost: +375 bytes in bank-20 reloc (build/ff4.ips 262313 ->
262686). Bank-20 still well under capacity.
Push the space-pair early-out down into each of the three kerning
binary searches (battle_render, dialog VWF, small_vwf). The previous
patch only short-circuited at _adjust_bits_left_for_kerning, so
external callers like battle_msg_kerning_binary_ext and the dialog +
small_vwf entry points still walked the full table for space pairs.

Now every binary search bails before touching the table when prev_char
or current_char is \$FF. Costs ~22 cy per non-space call on the path
that already had the adjust-level early-out (battle bench shows
+14k inclusive cycles in 60 frames, well within noise) but recovers
the win uniformly across all three VWF surfaces and all external
callers.
The binary search itself now bails on space pairs, so the
adjust-level check is duplicate work. Keep the early-out at the
single load-bearing site (the binary search entry) and let the
adjust wrapper fall through to its normal jsr.w get_kerning_adjustment
which returns carry-set immediately for space pairs.
@manz
manz merged commit e65cbad into master May 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant