From 68865e6a131802683f752b0742a5951ef93f5009 Mon Sep 17 00:00:00 2001 From: Emmanuel Peralta Date: Thu, 3 Sep 2026 14:27:17 +0200 Subject: [PATCH 1/3] Move battle render gate state out of the CHR buffer Inventory slot N's CHR runs $70:3C00 + N*160, so slot 4 spans $70:3E80..$70:3F1F and overwrote the gate bytes with glyph pixels. --- src/battle/message.s | 18 ++++++++++-------- src/battle/render_defs.i | 8 +++++--- src/vwf_state.i | 13 +++++++++++++ 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/battle/message.s b/src/battle/message.s index 1e216ba..11cc717 100644 --- a/src/battle/message.s +++ b/src/battle/message.s @@ -15,13 +15,13 @@ dialog-stream consumer). TILES_PER_ENTRY = 8 ; Fixed 8 tiles per string ; Memory layout - tile_ring_head = 0x703FF0 + tile_ring_head = BATTLE_RENDER_STATE + 0xF0 ; Current allocation position (entry index) - byte - tile_ring_count = 0x703FF1 + tile_ring_count = BATTLE_RENDER_STATE + 0xF1 ; Number of active allocations - byte - tile_ring_next_id = 0x703FF2 + tile_ring_next_id = BATTLE_RENDER_STATE + 0xF2 ; Next ID to assign - word - tile_ring_base_tile = 0x703FF4 + tile_ring_base_tile = BATTLE_RENDER_STATE + 0xF4 init: """ Base tile ID for ring buffer area - byte @@ -154,16 +154,18 @@ _not_found: buffer_ptr = VWF_CHR_BUFFER buffer_size = 8 * ( 128 + 32 ) * 2 region_size = 48 -; Gate state moved past the inventory tile slice ($703C00..$703EF0) -; so the rolling pre-render does not stomp these bytes. - pending_transfer_mask = 0x703f00 +; Gate state lives at BATTLE_RENDER_STATE ($70:7100), outside the CHR +; buffer entirely. The inventory slot slices run $70:3C00..$70:4240, so +; any 'past the inventory tile slice' address inside the buffer gets +; overwritten by rendered item CHR -- see vwf_state.i for the detail. + pending_transfer_mask = BATTLE_RENDER_STATE + 0x00 ; Per-slot CHR dirty bitmask for the inventory rolling buffer. Bit N ; set when slot N's CHR slice at $703000 + (slot_base + N*10)*16 has ; been touched and needs a VRAM flush. NMI `dma_transfer` consumes ; one or more bits per frame and DMAs only the dirty slot's 160-byte ; slice instead of the full 4KB CHR region, fits in vblank without ; forced blank. - dma_dirty_slots = 0x703f10 + dma_dirty_slots = BATTLE_RENDER_STATE + 0x10 ; Dirty-bit / render-skipped / tilemap-pending interface: shared with ; redraw_gates.s and the writer-site shims via a compile-time include. .include "render_defs.i" diff --git a/src/battle/render_defs.i b/src/battle/render_defs.i index 9885976..a693743 100644 --- a/src/battle/render_defs.i +++ b/src/battle/render_defs.i @@ -10,7 +10,9 @@ sharing them via `.include` (not `.extern`) is the correct, link-free idiom. ; --- Per-region dirty bits (normal sense: 1 = dirty, 0 = clean) --- ; Sits next to the DMA queue byte; writers SET bits on state change. -region_dirty_bits = 0x703f01 +; Lives at BATTLE_RENDER_STATE (see vwf_state.i) -- NOT in the CHR buffer, +; which the inventory slot slices overwrite. +region_dirty_bits = BATTLE_RENDER_STATE + 0x01 REGION_DIRTY_MESSAGES = 0x01 REGION_DIRTY_MONSTERS = 0x02 REGION_DIRTY_NAMES = 0x04 @@ -18,10 +20,10 @@ REGION_DIRTY_COMMANDS = 0x08 ; Transient marker: $FF if `init_*_with_gate` short-circuited because the ; region was clean; $00 if it ran the full init. -render_skipped = 0x703f02 +render_skipped = BATTLE_RENDER_STATE + 0x02 ; Per-region tilemap-DMA pending bitmask. Set by `init_*_gated` on the render ; path; consumed by `dma_transfer` in NMI to fire a per-region tilemap DMA. -tilemap_pending_mask = 0x703f03 +tilemap_pending_mask = BATTLE_RENDER_STATE + 0x03 TILEMAP_PENDING_COMMANDS = 0x01 TILEMAP_PENDING_MAIN = 0x02 diff --git a/src/vwf_state.i b/src/vwf_state.i index 3786a45..2d2d271 100644 --- a/src/vwf_state.i +++ b/src/vwf_state.i @@ -37,6 +37,19 @@ VWF_CHR_BUFFER := 0x703000 ; $704000-$704FFF which was outside any blit / DMA reach. VWF_CHR_BUFFER_SIZE := 0x2000 +; --- Battle-render gate state (OUTSIDE the CHR buffer) ------------------ +; These bytes must not live inside VWF_CHR_BUFFER. The inventory rolling +; buffer anchors at tile_id $C0 and hands each of its 11 slots +; ITEM_VWF_TILE_BUDGET (10) tiles, so slot N's CHR slice runs from +; $70:3C00 + N*160 and the last slot ends at $70:4240. Slot 4 alone spans +; $70:3E80..$70:3F1F, which is exactly where the gate bytes used to sit -- +; a rendered item name wrote glyph pixels over pending_transfer_mask, the +; region dirty bits, render_skipped and dma_dirty_slots (observed: +; pending_transfer_mask = $F3), so the battle names / monsters regions lost +; their dirty + CHR-pending bits and never flushed again: black name blocks +; and an empty monster window for the rest of the battle. +BATTLE_RENDER_STATE := 0x707100 + ; --- Null-terminated text-staging buffer ------------------------------ ; Callers copy the source string (from items_unleashed, monster names, ; magic list, ...) into this buffer + write $00 terminator, then call From c814a41305e0cc5972f1d98ac748924236cc97ab Mon Sep 17 00:00:00 2001 From: Emmanuel Peralta Date: Sat, 27 Jun 2026 23:40:39 +0200 Subject: [PATCH 2/3] Set battle forced-blank when NMI DMA work is queued The battle VWF tile + tilemap + inventory-CHR DMA pass can run ~40 scanlines and starts late in the NMI (after OAM DMA and the flying-HDMA chain), so its tail spilled past the ~37-line vblank into active scan and tore VRAM. Only the restore half of the forced-blank handshake survived (vanilla NMI tail $02:837F: lda $6CC1 / sta $2100); the set half was missing, so the screen was never blanked during the overrun. Set $2100=$80 at the head of dma_transfer whenever any DMA work is queued (pending_transfer_mask / tilemap_pending_mask / dma_dirty_slots), letting the DMA tail blank the first active lines; $02:837F restores brightness. Idle frames skip the blank so there's no visible black strip. --- src/battle/message.s | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/battle/message.s b/src/battle/message.s index 11cc717..21c9160 100644 --- a/src/battle/message.s +++ b/src/battle/message.s @@ -1333,6 +1333,24 @@ normal length, no visible black strip. pha phx phy +; --- Forced-blank for the over-vblank DMA window --- +; The VWF tile + tilemap + inventory-CHR DMA below can run ~40 scanlines +; of transfer and starts late in the NMI (after OAM DMA + the flying-HDMA +; chain), so the tail spills past the ~37-line vblank into active scan and +; tears VRAM. Set forced-blank ($2100=$80) whenever any DMA work is queued +; so those first active lines stay blanked while the DMA finishes; the +; vanilla NMI tail at $02:837F restores brightness from $6CC1. Idle frames +; (nothing queued) skip the blank, so there's no visible black strip. + php + sep #0x20 + lda.l battle_render.pending_transfer_mask + ora.l battle_render.tilemap_pending_mask + ora.l battle_render.dma_dirty_slots + beq _no_force_blank + lda #0x80 + sta.l 0x002100 +_no_force_blank: + plp .if BATTLE_ITEMS_VWF { ; Per-NMI BG3 V-scroll footer override. ; From ec821ef9395f1c94eeb04d5e630049f3be52eb3d Mon Sep 17 00:00:00 2001 From: Emmanuel Peralta Date: Thu, 3 Sep 2026 15:45:54 +0200 Subject: [PATCH 3/3] Flush battle VWF CHR per dirty region instead of the full buffer The NMI VWF tile DMA blasted the whole 0x1000 CHR buffer every dirty frame (~24 scanlines), which on stacked frames overran vblank. The abandoned SMART path tried per-region transfers but was dead code (SMART undefined) and its count was wrong (0x400 vs the real 0x300). Turn pending_transfer_mask into a dirty-region bitmask: bit 0 = transfer queued (set by deinit), bits 1-4 = which of the four VWF regions (messages/monsters/names/commands) was re-rendered. Each init path sets its region bit; dma_transfer flushes only the flagged regions, each a 0x300-byte slice at buffer_ptr + N*0x300 -> VRAM $B000 + N*0x300. Worst case (all four) is 0xC00 (~18 lines), comfortably inside vblank. Inventory CHR already rides the separate dma_dirty_slots path, so init_inventory_region no longer writes pending_transfer_mask (its old base store would corrupt the region bits). Two more sites in the slot pre-render also stored the tile_id base into that byte -- leftover from before it became a bitmask -- which clobbered CHR_PENDING and every region bit on every inventory slot render; dropped both, they only needed A for the allocator thunk. The per-region bit sets grew the gated init bodies past 8-bit branch range, so the long intra-block branches move to brl / a near trampoline. With the typical 1-region frame now ~4.5 lines, the forced-blank set stays only as a backstop for pathological stacked frames. --- src/battle/message.s | 186 ++++++++++++++++++++++++++++++------------- 1 file changed, 130 insertions(+), 56 deletions(-) diff --git a/src/battle/message.s b/src/battle/message.s index 21c9160..4b5325a 100644 --- a/src/battle/message.s +++ b/src/battle/message.s @@ -159,6 +159,18 @@ _not_found: ; any 'past the inventory tile slice' address inside the buffer gets ; overwritten by rendered item CHR -- see vwf_state.i for the detail. pending_transfer_mask = BATTLE_RENDER_STATE + 0x00 +; Bit 0 (CHR_PENDING) = "a CHR transfer is queued" (set by deinit / +; deinit_gated). Bits 1-4 = which VWF region was (re)rendered this frame +; and needs its 0x300-byte CHR slice flushed. dma_transfer DMAs only the +; flagged regions (<= 0xC00 total = ~18 lines, fits vblank) instead of +; blasting the whole 0x1000 buffer every dirty frame. Region N's slice +; lives at buffer_ptr + N*0x300 and targets VRAM $B000 + N*0x300. +; N=0 messages, N=1 monsters, N=2 names, N=3 commands. + CHR_PENDING = 0x01 + CHR_REGION_MESSAGES = 0x02 + CHR_REGION_MONSTERS = 0x04 + CHR_REGION_NAMES = 0x08 + CHR_REGION_COMMANDS = 0x10 ; Per-slot CHR dirty bitmask for the inventory rolling buffer. Bit N ; set when slot N's CHR slice at $703000 + (slot_base + N*10)*16 has ; been touched and needs a VRAM flush. NMI `dma_transfer` consumes @@ -178,16 +190,25 @@ _not_found: ;font_ptr = assets_menu_font_dat ; moved to direct use of assets_menu_font_dat init_monsters: """Initialize the renderer targeting the monsters region.""" + lda.l pending_transfer_mask + ora.b #CHR_REGION_MONSTERS + sta.l pending_transfer_mask lda.b #region_size - bra _init + brl _init init_names: """Initialize the renderer targeting the name region.""" + lda.l pending_transfer_mask + ora.b #CHR_REGION_NAMES + sta.l pending_transfer_mask lda.b #region_size * 2 - bra _init + brl _init init_commands_list: """Initialize the renderer targeting the commands list region.""" + lda.l pending_transfer_mask + ora.b #CHR_REGION_COMMANDS + sta.l pending_transfer_mask lda.b #region_size * 3 - bra _init + brl _init init_inventory_region: """ Reset the allocator to the inventory tile_id base (0xC0) once per @@ -199,8 +220,10 @@ overrun the shared buffer into the state words at $703C00+. """ +; Inventory CHR flushes via dma_dirty_slots (per-slot 160B DMA), not the +; region blast, so it must NOT touch pending_transfer_mask (a base store +; here would corrupt the region bits). A = tile_id base for the allocator. lda.b #region_size * 4 - sta.l pending_transfer_mask jsr.w render_allocator.init_with_tile_id .if ENABLE_KERNING_MENU { stz.b prev_char @@ -219,9 +242,14 @@ across scope boundaries. A on entry = tile_id base. jmp.w render_allocator.init_with_tile_id _init: - sta.l pending_transfer_mask +; Region bit already set by the caller; A = tile_id base for the allocator. jsr.w render_allocator.init_with_tile_id - bra _internal_init + brl _internal_init +; Near trampoline: the per-region CHR-bit sets grew the gated bodies past +; the 8-bit branch range to _gated_skip, so the clean-path branches hop +; here and jmp the rest of the way. +_gated_skip_near: + jmp.w _gated_skip ; --- Region-gated init variants --- ; Mirror the public init_X paths but check the matching region-dirty ; bit in `region_dirty_bits` first. When the bit is CLEAR (= clean), set @@ -235,52 +263,62 @@ init_monsters_gated: """Gated init for the monsters region.""" lda.l region_dirty_bits bit.b #REGION_DIRTY_MONSTERS - beq _gated_skip + beq _gated_skip_near and.b #( ~ REGION_DIRTY_MONSTERS ) & 0xFF sta.l region_dirty_bits lda.l tilemap_pending_mask ora.b #TILEMAP_PENDING_MAIN sta.l tilemap_pending_mask + lda.l pending_transfer_mask + ora.b #CHR_REGION_MONSTERS + sta.l pending_transfer_mask lda.b #region_size - bra _init_continue + brl _init_continue init_names_gated: """Gated init for the names region.""" lda.l region_dirty_bits bit.b #REGION_DIRTY_NAMES - beq _gated_skip + beq _gated_skip_near and.b #( ~ REGION_DIRTY_NAMES ) & 0xFF sta.l region_dirty_bits lda.l tilemap_pending_mask ora.b #TILEMAP_PENDING_MAIN sta.l tilemap_pending_mask + lda.l pending_transfer_mask + ora.b #CHR_REGION_NAMES + sta.l pending_transfer_mask lda.b #region_size * 2 - bra _init_continue + brl _init_continue init_commands_list_gated: """Gated init for the commands region.""" lda.l region_dirty_bits bit.b #REGION_DIRTY_COMMANDS - beq _gated_skip + beq _gated_skip_near and.b #( ~ REGION_DIRTY_COMMANDS ) & 0xFF sta.l region_dirty_bits lda.l tilemap_pending_mask ora.b #TILEMAP_PENDING_COMMANDS sta.l tilemap_pending_mask + lda.l pending_transfer_mask + ora.b #CHR_REGION_COMMANDS + sta.l pending_transfer_mask lda.b #region_size * 3 _init_continue: pha lda.b #0x00 sta.l render_skipped pla - sta.l pending_transfer_mask +; Region bit already set above; A = tile_id base for the allocator. jsr.w render_allocator.init_with_tile_id - bra _internal_init + brl _internal_init _gated_skip: lda.b #0xFF sta.l render_skipped rts init: pha - lda #0 + lda.l pending_transfer_mask + ora.b #CHR_REGION_MESSAGES sta.l pending_transfer_mask pla jsr.w render_allocator.init @@ -929,7 +967,15 @@ _chr_clear_loop: pha jsr.l battle_flags.set_vwf_render pla - sta.l battle_render.pending_transfer_mask +; A = this slot's tile_id base, for the allocator thunk below. It used to +; also get stored into pending_transfer_mask, back when that byte held the +; tile base; now the mask is a dirty-region bitmask, so the store wrote +; ITEM_VWF_TILE_BASE + N*ITEM_VWF_TILE_BUDGET ($C0, $CA, $D4, ...) over +; CHR_PENDING and every region bit -- each inventory slot pre-render wiped +; the pending flush of whatever region had just rendered. The battle names +; region renders once per battle, so losing that one flush left the +; glyphs in WRAM and black tiles on screen for the whole fight. Inventory +; CHR flushes via dma_dirty_slots, not this mask. jsr.w battle_render.render_allocator_init_with_tile_id_thunk ; Slot owns ITEM_VWF_TILE_BUDGET tile_ids. Set the allocator clamp at ; slot_base + (K-1) AFTER init_with_tile_id (which resets slot_limit_low @@ -1236,7 +1282,15 @@ _dis_chr_clear: pha jsr.l battle_flags.set_vwf_render pla - sta.l battle_render.pending_transfer_mask +; A = this slot's tile_id base, for the allocator thunk below. It used to +; also get stored into pending_transfer_mask, back when that byte held the +; tile base; now the mask is a dirty-region bitmask, so the store wrote +; ITEM_VWF_TILE_BASE + N*ITEM_VWF_TILE_BUDGET ($C0, $CA, $D4, ...) over +; CHR_PENDING and every region bit -- each inventory slot pre-render wiped +; the pending flush of whatever region had just rendered. The battle names +; region renders once per battle, so losing that one flush left the +; glyphs in WRAM and black tiles on screen for the whole fight. Inventory +; CHR flushes via dma_dirty_slots, not this mask. jsr.w battle_render.render_allocator_init_with_tile_id_thunk ; Slot owns ITEM_VWF_TILE_BUDGET tile_ids. Set the allocator clamp at ; slot_base + (K-1) AFTER init_with_tile_id (which resets slot_limit_low @@ -1465,51 +1519,71 @@ _inv_dma_done: _no_inv_dma: plp } - lda.l battle_render.pending_transfer_mask - bit #1 - beq _no_transfer - and #0xfe - .if SMART { - rep #0x20 - asl - asl - asl - asl - pha - clc - adc.w #0xb000 - lsr - tay - pla - clc - adc.w #battle_render.buffer_ptr - tax +; --- Per-region CHR DMA --- +; Flush only the regions flagged dirty this frame (bits 1-4), each a +; 0x300-byte 2bpp slice at buffer_ptr + N*0x300 -> VRAM $B000 + N*0x300. +; Worst case (all four) = 0xC00 = ~18 lines, comfortably inside vblank, +; so the screen no longer tears / needs forced-blank for text. Inventory +; CHR rides the separate dma_dirty_slots path above. + php sep #0x20 - } + rep #0x10 + lda.l battle_render.pending_transfer_mask + bit.b #battle_render.CHR_PENDING + bne _chr_dma_go + jmp.w _chr_dma_skip +_chr_dma_go: + bit.b #battle_render.CHR_REGION_MESSAGES + beq _chr_no_msg ldy.w #0xb000 >> 1 ldx.w #battle_render.buffer_ptr - phx - .if SMART { - ldx.w #0x400 - } else { - .if BATTLE_ITEMS_VWF { -; Inventory region extends past the legacy 0xC00 window. Bump to -; 0x1000 so the DMA covers the full BG3 CHR upper half ($B000..$BFFF -; = tile_ids 0x100..0x1FF). Gains 16 tile_ids for the inventory slot -; budget at zero risk -- the trailing 0x100 bytes were unused. -; TODO: don't transfer the whole thing every frame ; only dirty -; regions need flushing. - ldx.w #0x1000 - } else { - ldx.w #0xc00 - } - } - stx 0x0e - plx - lda #0x70 + rep #0x20 + lda.w #0x300 + sta.b 0x0e + sep #0x20 + lda.b #0x70 jsr.w _sram_dma_transfer_7 - lda #0x00 +_chr_no_msg: + lda.l battle_render.pending_transfer_mask + bit.b #battle_render.CHR_REGION_MONSTERS + beq _chr_no_mon + ldy.w #( 0xb000 + 0x300 ) >> 1 + ldx.w #battle_render.buffer_ptr + 0x300 + rep #0x20 + lda.w #0x300 + sta.b 0x0e + sep #0x20 + lda.b #0x70 + jsr.w _sram_dma_transfer_7 +_chr_no_mon: + lda.l battle_render.pending_transfer_mask + bit.b #battle_render.CHR_REGION_NAMES + beq _chr_no_names + ldy.w #( 0xb000 + 0x600 ) >> 1 + ldx.w #battle_render.buffer_ptr + 0x600 + rep #0x20 + lda.w #0x300 + sta.b 0x0e + sep #0x20 + lda.b #0x70 + jsr.w _sram_dma_transfer_7 +_chr_no_names: + lda.l battle_render.pending_transfer_mask + bit.b #battle_render.CHR_REGION_COMMANDS + beq _chr_no_cmds + ldy.w #( 0xb000 + 0x900 ) >> 1 + ldx.w #battle_render.buffer_ptr + 0x900 + rep #0x20 + lda.w #0x300 + sta.b 0x0e + sep #0x20 + lda.b #0x70 + jsr.w _sram_dma_transfer_7 +_chr_no_cmds: + lda.b #0x00 sta.l battle_render.pending_transfer_mask +_chr_dma_skip: + plp _no_transfer: ; --- Per-region tilemap DMA pass --- ; Reads `tilemap_pending_mask` (set by `init_*_gated` on render paths