diff --git a/.gitignore b/.gitignore
index f1cccbe..fbdc6fc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,13 @@
build/**
*.smc
+*.sfc
oldies
+# a816 build artifacts
+a.out
+a.out.adbg
+*.o
+
# Generated build assets (keep intro source files)
assets/*
!assets/intro.col
diff --git a/a816.toml b/a816.toml
new file mode 100644
index 0000000..fcab65d
--- /dev/null
+++ b/a816.toml
@@ -0,0 +1,3 @@
+entrypoint = "ff4.s"
+module-paths = ["build/obj", "src"]
+include-paths = ["src"]
diff --git a/build.py b/build.py
index 93816a5..2b77f0a 100755
--- a/build.py
+++ b/build.py
@@ -86,7 +86,7 @@ def assets_need_refresh(source, destination):
def build_patch(input, output, lang):
- from a816.module_builder import build_with_imports_direct
+ from a816.module_builder import build_with_imports
obj_dir = Path("build/obj")
if obj_dir.exists():
@@ -97,15 +97,15 @@ def build_patch(input, output, lang):
if out_path.exists():
out_path.unlink()
- result = build_with_imports_direct(
+ result = build_with_imports(
main_source=Path(input),
output_file=Path(output),
output_format="ips",
module_paths=[Path("build/obj"), Path("src")],
output_dir=Path("build/obj"),
symbols={"LANG": lang},
- include_paths=[Path("src")],
- prelude_file=Path("config.i"),
+ include_paths=[Path("src"), Path(".")],
+ overlap_mode="warn",
)
if result.exit_code != 0:
@@ -165,7 +165,9 @@ def build_fixed_asset(table, input_file, binary_text_file):
write_pointers_value_as_binary(pointers, binary_text_file)
-def build_fixed_to_ptr_asset(table, input_file, binary_text_file, pointers_file, buffer_width=None):
+def build_fixed_to_ptr_asset(
+ table, input_file, binary_text_file, pointers_file, buffer_width=None
+):
pointers = read_fixed_from_xml(
input_file, table, formatter=lambda t: t.strip() + "[end]"
)
@@ -184,7 +186,7 @@ def build_fixed_to_ptr_asset(table, input_file, binary_text_file, pointers_file,
print(f"{text} is too long ({ptr_len}px , {math.ceil(ptr_len / 8)} tiles)")
text = table.to_text(max_ptr[1].value)
- print(f"{text} is the largest ({max_length}px ({math.ceil(max_length/8)})")
+ print(f"{text} is the largest ({max_length}px ({math.ceil(max_length / 8)})")
write_pointers_value_as_binary(pointers, binary_text_file)
write_pointers_addresses_as_binary(
@@ -327,8 +329,7 @@ def add_custom_kernings(text: str, advance: int) -> None:
known_pairs_to_kern = [
"Ya",
"Pa",
- "Po"
- "Fa",
+ "PoFa",
"Fe",
"Fo",
"Fu",
@@ -489,6 +490,12 @@ def build_assets(assets):
menu_table,
),
("fixed", menu_table, os.path.join(text_root, "items.xml"), "assets/items.dat"),
+ (
+ "fixed",
+ menu_table,
+ os.path.join(text_root, "items_unleashed.xml"),
+ "assets/items_unleashed.dat",
+ ),
("fixed", menu_table, os.path.join(text_root, "magic.xml"), "assets/magic.dat"),
(
"fixed",
@@ -528,7 +535,7 @@ def build_assets(assets):
os.path.join(text_root, "monsters_long.xml"),
"assets/monsters_long.dat",
"assets/monsters_long.ptr",
- 80
+ 80,
),
(
"nullterminated",
diff --git a/config.i b/config.i
index a3e296a..a185474 100644
--- a/config.i
+++ b/config.i
@@ -13,6 +13,7 @@ ENABLE_BUTTON_DISPLAY := 1
BATTLE_CMD_VWF := 1
BATTLE_NAMES_VWF := 1
BATTLE_MONSTERS_VWF := 1
+BATTLE_ITEMS_VWF := 1
INVENTORY_ROLLING_BUFFER := 1
TREASURE_INVENTORY_ROLLING := 1 ; WIP, treasure rolling-buffer scaffolding only
TREASURE_DEBUG_ALWAYS_DROP := 1 ; force every win to drop an item (testing)
diff --git a/ff4.s b/ff4.s
index 45b4942..7aa4c97 100644
--- a/ff4.s
+++ b/ff4.s
@@ -4,8 +4,35 @@ Final Fantasy IV the new hack.
----------------
"""
-; Forward declaration - conditional_bg1_vofs is at start of relocated region ($208000)
-conditional_bg1_vofs := 0x208000
+; Auto-prepended: imports must precede .include'd patches
+.import "assets"
+.import "battle/commands_reloc"
+.import "battle/equip_window"
+.import "battle/graphics"
+.import "battle/inventory_rolling"
+.import "battle/items_reloc"
+.import "battle/magic_reloc"
+.import "battle/math_reloc"
+.import "battle/monsters_reloc"
+.import "battle/redraw_gates"
+.import "battle/sram"
+.import "dakuten"
+.import "dialog"
+.import "ingame/init_bg_scroll_hdma"
+.import "ingame/items_menu_vwf"
+.import "ingame/places_names_window"
+.import "intro"
+.import "kerning"
+.import "libmz"
+.import "menus/in_game_text"
+.import "menus/start_screen_text"
+.import "menus/system_menus_text"
+.import "menus/tools_shop_text"
+.import "small_vwf/init"
+.import "vwf"
+
+.include "config.i"
+
.include "src/libmz.i"
.include "src/items.i"
@@ -42,7 +69,7 @@ conditional_bg1_vofs := 0x208000
.include "src/ingame/items_menu.s"
; Relocated init_bg_scroll_hdma (was at $01:EBD2, frees 566 bytes in bank $01).
-; Blob with internal absolute references — pinned to offset $EBD2 within an
+; Blob with internal absolute references - pinned to offset $EBD2 within an
; expansion bank. Caller patch retargets the single JSL at $02:818A.
.if INVENTORY_ROLLING_BUFFER {
.include "src/ingame/init_bg_scroll_hdma_patches.s"
@@ -53,42 +80,52 @@ conditional_bg1_vofs := 0x208000
dialog_bank_ptr_base = 0x218000
-*=0xFFC0
- ; patch snes cartridge type
- ; original PCB: SHVC-1A3B
- ; target PCB: SHVC-1A5B
+.alloc at 0x00FFC0 {
+; patch snes cartridge type
+; original PCB: SHVC-1A3B ; target PCB: SHVC-1A5B
.ascii "Final Fantasy IV "
+}
-;FFD5 20H / 30H Map Mode
-
-*=0xFFD6
+.alloc at 0x00FFD6 {
+; FFD5 20H / 30H Map Mode
.db 0x02 ; Cartridge Type
.db 0x0B ; ~ 0BH ROM Size
.db 0x07 ; RAM Size
+}
+
.if ENABLE_BRK_HANDLER {
- *=0x00FFE0
-; JML trampoline in vector-table padding ; native/emu BRK vectors point here.
+; JML trampoline in vector-table padding; native/emu BRK vectors point here.
+ .alloc at 0x00FFE0 {
jmp.l brk_handler
- *=0x00FFE6
+ }
+
+
+ .alloc at 0x00FFE6 {
.dw 0xFFE0
- *=0x00FFFE
+ }
+
+
+ .alloc at 0x00FFFE {
.dw 0xFFE0
+ }
}
-*=0x008031
- ; déroutage pour ajouter le splash screen
-.if ENABLE_INTRO {
+; déroutage pour ajouter le splash screen
+.alloc at 0x008031 {
+ .if ENABLE_INTRO {
jsr.l start_splash_screen
-} else {
+ } else {
jsr.l clear_ram
+ }
}
-*=0x00B463
- ; déroutage pour utiliser la vwf dans les dialogues.
+; déroutage pour utiliser la vwf dans les dialogues.
+.alloc at 0x00B463 {
jsr.l vwfstart
rts
+}
; ============================================================================
; Bank-20 relocated region.
@@ -113,7 +150,8 @@ dialog_bank_ptr_base = 0x218000
; Address is pinned by `conditional_bg1_vofs := 0x208000` at the top of
; this file ; `strategy order` keeps it first in the pool.
.if INVENTORY_ROLLING_BUFFER {
- lda.l 0x7E0000 + menu_hdma_enable
+conditional_bg1_vofs:
+ lda.l field_menu_rolling.hdma_enable
bne _cond_skip_bg1vofs
; HDMA not active - do original BG1VOFS writes
; Menu context: D=$0100, so $93 reads from $0193
@@ -129,8 +167,14 @@ _cond_skip_bg1vofs:
clear_ram:
"""
-Clear the dialog VWF tile buffer at $702000-$706FFF (16-bit zeroes) after letting the boot ROM init at
-$15C9AA.
+Clear the dialog VWF tile buffer + engine scratch at $702000-$7070FF
+(includes VWF_CONFIG_BASE, VWF_CHR_DIRTY / DIRTY_B, VWF_CALLER_CTX, and
+the secondary descriptor fields) after letting the boot ROM init at
+$15C9AA. Range was $5000 bytes pre-secondary-descriptor ; bumped to
+$5100 so the new dirty / vram_word / byte_count / src_offset bytes
+land zero on cold boot instead of inheriting random SRAM and
+triggering a bogus secondary flush on the very first NMI (which trashed
+the save-selection sprite CHR).
"""
@@ -142,7 +186,7 @@ $15C9AA.
_loop:
sta.l 0x702000, x
inx
- cpx.w #0x5000
+ cpx.w #0x5100
bne _loop
}
rtl
@@ -167,6 +211,30 @@ Output: X = offset into ItemName table.
rtl
+multiply_item_index_17:
+"""
+Relocated multiply-by-ITEM_UNLEASHED_RECORD_SIZE for the items_unleashed
+name offset. Called from $019023 via JSL when the field menu is
+wired to the 17-byte assets_items_unleashed_dat table.
+Input: $43 = item ID (16-bit mode active).
+Output: X = offset into ItemName table.
+"""
+
+
+; ITEM_UNLEASHED_RECORD_SIZE = 17 = (id << 4) + id.
+ lda 0x43
+ pha
+ asl
+ asl
+ asl
+ asl ; * 16
+ clc
+ adc 0x01, s ; * 16 + id = * 17
+ tax
+ pla ; balance stack
+ rtl
+
+
multiply_by_12:
"""A: value to multiply ; returns A*12 in A."""
php
@@ -184,6 +252,29 @@ multiply_by_12:
rtl
+multiply_by_17:
+"""
+A: value to multiply ; returns A*17 in A. Mirror of multiply_by_12
+sized for the 17-byte assets_items_unleashed_dat stride.
+"""
+
+
+ php
+ rep #0x20
+ and.w #0x00FF
+ pha
+ asl
+ asl
+ asl
+ asl
+ clc
+ adc 0x01, s ; * 16 + value = * 17
+ sta 0x01, s
+ pla
+ plp
+ rtl
+
+
brk_handler:
"""
BRK trap: mask interrupts, disable NMI, fetch the BRK signature byte
@@ -223,8 +314,7 @@ signature byte sits at PB:(PC - 1).
; and matches the legacy `*=0x208000` chain so .import modules without
; their own `*=` directive land in bank-20 as expected.
-*=0x208100
- ; --- Imported modules ---------------------------------------------------
+; --- Imported modules ---------------------------------------------------
.import "libmz"
.import "dialog"
@@ -253,6 +343,7 @@ signature byte sits at PB:(PC - 1).
}
.import "ingame/places_names_window"
+.import "ingame/items_menu_vwf"
.import "menus/system_menus_text"
.import "dakuten"
.import "menus/start_screen_text"
@@ -265,6 +356,7 @@ signature byte sits at PB:(PC - 1).
.if INVENTORY_ROLLING_BUFFER {
.import "ingame/init_bg_scroll_hdma"
.include "src/ingame/inventory_rolling.s"
+ .include "src/lib/rolling_inventory_engine.s"
}
.if TREASURE_INVENTORY_ROLLING {
@@ -275,33 +367,23 @@ signature byte sits at PB:(PC - 1).
; --- Binary text assets -------------------------------------------------
-.incbin "assets/attack_names.ptr"
-.incbin "assets/attack_names.dat"
-.incbin "assets/monsters_long.ptr"
-.incbin "assets/monsters_long.dat"
-.incbin "assets/battle_commands_nul.ptr"
-.incbin "assets/battle_commands_nul.dat"
-.incbin "assets/magic.dat"
-.incbin "assets/places_names.dat"
-.incbin "assets/classes.ptr"
-.incbin "assets/classes.dat"
-.incbin "assets/items.dat"
-.incbin "assets/item_descriptions.dat"
+
.if TREASURE_INVENTORY_ROLLING {
.include "src/ingame/key_item_picker_patches.s"
}
.if TRIGGER_ENDING_CUTSCENE {
; all effects are the Ending cutscene
- *=0xc436
+ .alloc at 0xc436 {
lda #0x39
nop
+ }
}
.if DEBUG_SHOW_ITEM_WINDOW {
; Hijack ExecEvent to always run F7 (select item) with Baron Key
; EventCmd_f7 at $00ED96 expects: X points to script, $09d5+X+1 = item ID
- *=0x00E1EB
+ .alloc at 0x00E1EB {
lda #0xD1
sta 0x09d6
lda #0xFF
@@ -309,5 +391,15 @@ signature byte sits at PB:(PC - 1).
ldx #0x0000
stx 0xb3
jmp.w 0xED96
+ }
}
-;end
+
+; Park the 17-byte-stride items_unleashed.dat in an empty bank so the
+; full 4352-byte table fits without crossing a LoROM bank boundary
+; (which would otherwise leave the upper half of the table at
+; $21:0xxx, an address LoROM does not map back to ROM data).
+
+.alloc at 0x238000 {
+ .incbin "assets/items_unleashed.dat"
+}
+
diff --git a/plans/battle_items_vwf.md b/plans/battle_items_vwf.md
new file mode 100644
index 0000000..ebe9bb3
--- /dev/null
+++ b/plans/battle_items_vwf.md
@@ -0,0 +1,117 @@
+# Battle items VWF
+
+## What
+
+Replace the fixed-width 12-character item-name rendering in the battle
+inventory with the existing 8x8 VWF blitter (`battle_render.display_char`).
+Lets proportionally-spaced French names fit the 15-tile slot budget that
+the rolling inventory already reserves, removes hard truncation, frees
+the per-glyph padding wasted on narrow characters.
+
+## Why
+
+Vanilla `DrawInventoryItemText` packs item names as 12 fixed tile IDs
+into a 48-byte buffer. Our rolling inventory expanded to 60 bytes (15
+tiles per row × 2 bytes), but the renderer still emits one tile-id per
+character, so a 12-character French name like `Médaille d'Or` either
+overflows or truncates. VWF rendering pulls the same name through
+`battle_render.display_char`, allocating CHR dynamically and packing
+proportional glyphs into 15 tiles of horizontal pixels (~120 px).
+
+## Boundaries
+
+- In scope: battle inventory (the in-battle item menu).
+- In scope: the rolling-buffer slot refresh hook (re-render on
+ scroll edge).
+- Out of scope: equipped-items buffer ($9A00). Equip menu rendering
+ stays fixed-width for this PR; can follow up.
+- Out of scope: item descriptions (already VWF via small_vwf).
+- Out of scope: field inventory VWF. Same approach, different surface.
+
+## Approach
+
+1. **Reserve a battle_render region for inventory**. Current regions
+ (`src/battle/message.s:143-150`):
+ ```
+ 0x00-0x3F messages
+ 0x40-0x7F monster names
+ 0x80-0xAF char names
+ 0xB0-0xEF commands
+ ```
+ Add `0xF0-0xFF` (16 tiles = 512B CHR) for inventory item name. With
+ 6 ring slots and 15 tiles per name, the ring needs ~90 tiles. Either
+ extend the CHR window past $BDE0 (free runway up to roughly
+ $BFE0-ish per the audit) or reuse the commands region during the
+ item-menu mode (commands hide when inventory is open).
+2. **Patch `DrawInventoryItemText`** at `$02:9FA4`. Replace the
+ per-character tile-id copy with a `battle_render.init_inventory`
+ + per-byte `display_char` loop, then write the resulting tile IDs
+ into the existing 60-byte slot at `$97A6 + slot * 60`.
+3. **Quantity digits**. Stay fixed-width for now (the rightmost 2
+ tiles per row in the slot layout). Avoids a special-case digit
+ path in display_char.
+4. **Trash icon**. Existing 2x2 fixed glyph stays. Item id $FF skips
+ the VWF render path.
+5. **Tilemap layout unchanged**. The slot still consumes 30 tiles
+ (15 × 2 rows), still gets DMA'd to VRAM tilemap by the existing
+ rolling-buffer transfer. Only the CHR pointed at by the tile IDs
+ changes.
+
+## Files
+
+- `src/battle/items_patches.s` — hook `DrawInventoryItemText` text
+ copy loop.
+- `src/battle/message.s` — add inventory region constants +
+ `init_inventory` entry.
+- `src/battle/inventory_rolling.s` — slot-refresh callsite. Already
+ pre-renders on swap / scroll; VWF render lands at the same site.
+- `tests/test_battle_init.py` — extend with an "open battle
+ inventory" frame that captures the VWF rendered names.
+
+## Tests
+
+- Battle init smoke unchanged (no item menu open).
+- New `tests/test_battle_inventory_vwf.py`: load a battle-with-items
+ kss, open the item menu, capture screen golden showing
+ VWF-rendered names.
+- `tests/_profile/profile_battle_vwf.py` exercised with the inventory
+ open; confirm `_display_char` calls fit within budget on edge
+ frames (re-render is one slot, ~15 chars × 4.4k cy = 66k cy ≈ 18%
+ of frame).
+
+## Risks
+
+- **CHR overflow**: 90 tiles for the ring needs $A000-base allocation
+ past current region. Audit the $BDE0-? runway carefully before
+ expanding.
+- **Tile-id collision with commands**: if commands region 0xB0-0xEF
+ is the chosen reuse target, items render must wait for cmd window
+ to be hidden. The existing battle inventory open path already
+ hides the cmd window via the slice-2 gates, but verify.
+- **Edge-slot render time**: VWF blit of one item name on scroll
+ edge must fit in ~1 vblank. Item names are ~12-15 chars × 4.4k cy
+ = 53-66k cy ≈ 15-18% of frame budget. Headroom OK.
+- **dakuten/special chars**: item names with accents flow through
+ `battle_display_dakuten_char`. Already handled by the existing VWF
+ path. Verify on `Médaille` etc.
+
+## Phasing
+
+| phase | scope | size |
+|---|---|---|
+| 1 | Region reservation + region-init helper | ~50 lines asm |
+| 2 | `DrawInventoryItemText` VWF hook + slot write | ~150 lines asm |
+| 3 | Rolling-buffer slot-refresh wiring | ~80 lines asm |
+| 4 | Goldens + smoke test for battle inventory | tests |
+| 5 | Polish: quantity-digit alignment, trash icon | small |
+
+Each phase commits independently. PR can land as either one chunk or
+phase-by-phase depending on review cadence.
+
+## Crack
+
+The 90-tile CHR budget is the load-bearing assumption. If the
+$A000-$BFFF window proves too tight, fall back to reusing commands
+region 0xB0-0xEF during item-menu mode and round-trip the cmd window
+on close. Adds a tilemap clear on transition but keeps the CHR
+footprint where it is.
diff --git a/plans/menu_rewrite.md b/plans/menu_rewrite.md
new file mode 100644
index 0000000..baa4b31
--- /dev/null
+++ b/plans/menu_rewrite.md
@@ -0,0 +1,140 @@
+# Menu system rewrite
+
+## Why
+
+FF4's menu/UI layer accumulated technical debt that costs disproportionate
+effort on every new feature:
+
+- 4 BG3 CHR bases that flip per context (menu `$4000`, town `$4000`-small,
+ dialog `$C000`, battle `$5000`).
+- 4 staging buffers (`$0774` text, `$7E:D600` BG3, `$7E:B600` BG1,
+ `$7E:C600` BG4).
+- Tilemap bases shift per BG mode.
+- Vanilla `$EB=1` NMI dispatch interleaved with custom rolling-engine hooks.
+- Tile-id semantics flip 8-bit / 9-bit by mode.
+- Per-menu HDMA channels (5, 6, 4) each with their own header/footer/signal
+ hook trio just to anchor the window border while items scroll.
+- Save/restore range depends on caller (`$1300` widened to `$2000` then
+ bumped again for the VWF leak).
+
+Every new menu feature is an archaeology dig. The current rolling engine
+landed in 18 commits and still doesn't cover the key-item picker because
+that runs as an overlay on the active map and the existing engine's
+assumptions don't hold there.
+
+## Target architecture (FF6-derived)
+
+Both FF5 and FF6 simplify in two key ways FF4 didn't pursue:
+
+1. **Window borders and item text live on different BG layers.** FF6 puts
+ the item list on BG1 (HDMA-scrollable), window frames on BG2 (static),
+ cursor sprites on BG3. The "anchor the bottom border while items
+ scroll" gymnastics dissolve — borders don't move.
+2. **HDMA scroll uses a mostly-static ROM table** with one runtime patch
+ for the scroll bias. FF6's `LoadItemBG1VScrollHDMATbl`
+ (`ff6decomp/src/menu/item.asm:270`) copies a ROM-resident table into
+ WRAM and `ADC`s the current scroll position into the variable slice.
+ No per-frame buffer_pos / buffer_slots modular math.
+
+### Proposed VRAM map
+
+```
+$0000-$2FFF BG1 + BG2 CHR (mode-1 4bpp, shared map tileset)
+$3000-$3FFF BG1 tilemap (item text — HDMA-scrollable layer)
+$4000-$4FFF ??? (consider relocating sprite CHR here once layout settles)
+$5000-$5FFF BG2 tilemap (window borders — static)
+$6000-$7FFF reserved
+$8000-$BFFF sprite CHR
+$C000-$DFFF BG3 CHR (dialog VWF + description font — shared by all
+ BG3-text contexts incl. picker)
+$E000-$EFFF BG3 tilemap (description / overlay text)
+```
+
+Single CHR window for BG3-text across menu, dialog, picker. No flipping
+BG34NBA per context.
+
+### HDMA strategy (FF6-mirrored)
+
+Per menu:
+- Static ROM table `