diff --git a/docs/current_plan.md b/docs/current_plan.md index c6b65cd6..5ed1776a 100644 --- a/docs/current_plan.md +++ b/docs/current_plan.md @@ -1,5 +1,172 @@ -# Current Plan +# Plan: Programmatic NPC Placement — All Maps + Trainer Encounter Stub -No active multi-step plan. See [TODO.md](../TODO.md) for open Phase 2 items. +## Context -Completed plans are archived in `docs/plans/`. +Pallet Town's 3 NPCs (Oak, Girl, Fisher) are fully working end-to-end: binary blob via +`gen_map_headers.py`, sprite tiles in `assets/npc_*.inc`, dialog text in +`assets/npc_dialogs_pallet_town.inc`, and `CheckNPCInteraction` hardwired to +`PalletTownTextTable`. All other maps have NPC object data already embedded in +`map_headers.inc` (the generator reads all 224 `.asm` files), but they have no sprite +includes, no dialog includes, and no dispatch to their text tables. This plan delivers: + +1. Sprite tile includes for every unique sprite ID used across all maps (auto-generated). +2. Dialog text tables for every map with NPCs (auto-generated). +3. A per-map text table dispatch so `CheckNPCInteraction` works on any map. +4. A trainer encounter stub: sight detection → `!` bubble → walk-up → pre-battle text → + skip battle → mark beaten, so trainer flow is playable before the battle engine exists. + +--- + +## Stage 1 — Extend sprite generators to all sprites + +**Files: `dos_port/tools/gen_all_assets.py` and `dos_port/tools/gen_overworld_assets.py`** + +Both tools have a hardcoded 3-entry list `[(label, fname, base_offset), ...]`. Replace it +with a dynamic enumeration. + +Shared sprite enumeration logic (used by both generators): +- Parse `constants/sprite_constants.asm` → `{SPRITE_FOO: id}` mapping. +- Scan every `data/maps/objects/*.asm` file for `object_event` lines; collect the + `SPRITE_*` token from field 3. Build a set of unique sprite IDs used across all maps. +- Derive the 2bpp filename: strip `SPRITE_` prefix, lowercase + (`SPRITE_COOLTRAINER_F` → `cooltrainer_f`, `SPRITE_BALDING_GUY` → `balding_guy`). +- Manual override dict for edge cases: + - `SPRITE_GAMBLER_ASLEEP` → `gambler.2bpp` (reuses same sheet, different start frame) + - `SPRITE_UNUSED_RED_1/2/3` → `None` (emit `dd 0` in table) +- Log a warning for any sprite where `gfx/sprites/.2bpp` is missing. + +### 1a. `gen_all_assets.py` — full 24-tile sprite sheets + +- [x] Replace hardcoded 3-entry NPC list with dynamic sprite enumeration. +- [x] For each used sprite: emit `assets/npc_sprites/.inc` + (label `npc_:`, 24 tiles = full 2bpp sheet). +- [x] Emit `assets/npc_sprite_data_table.inc`: + - `%include` each per-sprite `.inc` file. + - `npc_sprite_data_table:` with one `dd` per sprite ID (0x00–0x52), `dd 0` for absent. + +### 1b. `gen_overworld_assets.py` — 12-tile still subsets + +**SUPERSEDED**: `npc_*_still.inc` files were dead code — `LoadNPCSpriteTiles` reads both +still and walk halves directly from the full 384-byte sheet. No `_still` table needed. + +### 1c. Update `map_sprites.asm` — sprite data table + +- [x] Replaced 3 hardcoded sprite includes with `%include "assets/npc_sprite_data_table.inc"`. +- [x] Updated `LoadNPCSpriteTiles`: flat table lookup by sprite_id, bounds-check, skip if 0. + +### 1d. Update `overworld.asm` — still sprite table + +- [x] Removed dead `npc_*_still.inc` includes; all sprite loading via full-sheet table. + +--- + +## Stage 2 — Extend `gen_npc_dialogs.py` to all maps + +**File: `dos_port/tools/gen_npc_dialogs.py`** + +- [x] Load the ordered `(map_id, MapPascalName)` list from `constants/map_constants.asm`. +- [x] For each map: parse `data/maps/objects/` + `scripts/` for NPC entries and text pointers. +- [x] Trainer/script/item NPCs get appropriate stubs; normal NPCs get charmap-encoded text. +- [x] Emit `assets/npc_dialogs/_dialogs.inc` per map (137 maps with NPCs). +- [x] Emit `assets/npc_dialogs/all_dialogs.inc` with `MapTextTablePointers` (249 entries). +- [x] Fixed label collisions: slot index `i` appended to all labels to ensure uniqueness. + +--- + +## Stage 3 — Per-map text table dispatch + +**Files: `map_sprites.asm`, `overworld.asm`** + +- [x] `w_map_text_table_ptr: resd 1` in `map_sprites.asm` BSS; exported as `global`. +- [x] Dispatch in `LoadMapData` (initial load) and `.mapTransition` and `.warpTransition` + in `overworld.asm`: `MapTextTablePointers[W_CUR_MAP*4] → w_map_text_table_ptr`. +- [x] `CheckNPCInteraction`: null-pointer guard + uses `[w_map_text_table_ptr]` not + hardcoded `PalletTownTextTable`. +- [x] `%include "assets/npc_dialogs/all_dialogs.inc"` replaces old pallet-town-only include. +- [x] `extern MapTextTablePointers` / `extern w_map_text_table_ptr` added to `overworld.asm`. +- [x] Full build verified: `make clean && make SKIP_TITLE=1` → `Built: PKMN.EXE`. + +--- + +## Stage 4 — Trainer encounter stub + +**Files: `map_sprites.asm`, `overworld.asm`** + +### 4a. New BSS state + +- [x] `npc_beaten_flags: resw 1`, `w_trainer_enc_slot: resb 1`, `w_player_frozen: resb 1` + added to BSS in `map_sprites.asm` with `; TODO-GLOBAL-EVENTS` comment. +- [x] `InitMapSprites` resets all three on every map load. + +### 4b. `CheckTrainerSight` subroutine + +- [x] Scans slots 1-15; skips inactive, non-trainer, and beaten slots. +- [x] Sight check: FACINGDIRECTION-based LOS (all 4 directions), distance ≤ 4 blocks, + using `bt word [npc_beaten_flags], dx` for the beaten test. +- [x] Sets `w_trainer_enc_slot = esi & 0xFF`, returns CF=1 if found. +- [x] `OverworldLoop` calls `CheckTrainerSight` before joypad read; CF=1 → `TrainerEncounterFlow`. + +### 4c. `TrainerEncounterFlow` subroutine + +- [x] Sets `w_player_frozen = 1`. +- [x] ~45-frame freeze (TODO: add `!` bubble over trainer head). +- [x] `MakeNPCFacePlayer` + freeze NPC movement flag. +- [x] Pre-battle text via shared `npc_dialog_wait_impl` helper. +- [x] Marks beaten via `bts word [npc_beaten_flags], dx`. +- [x] Clears `w_trainer_enc_slot = 0xFF`, `w_player_frozen = 0`. + +### 4d. Gate beaten trainers + +- [x] `CheckNPCInteraction` at `.found_npc`: if ISTRAINER=1 and beaten bit set → `jmp .not_found`. + +--- + +## Stage 5 — Build system wiring + +**File: `dos_port/Makefile`** + +- [x] `assets/npc_sprite_data_table.inc` target → `python3 tools/gen_all_assets.py`. +- [x] `assets/npc_dialogs/all_dialogs.inc` target → `python3 tools/gen_npc_dialogs.py`. +- [x] `map_sprites.o` dependency updated to new generated targets. +- [x] `assets:` phony target updated to include both new generated files. +- [x] `make clean && make SKIP_TITLE=1` → `Built: PKMN.EXE` — clean build verified. + +--- + +## Verification checklist + +- [ ] Pallet Town regression: Oak / Girl / Fisher dialog still works after all changes. +- [ ] New map smoke-test: add a temporary `DEBUG_WARP` to Viridian City; walk to a + Youngster, press A → Youngster's dialog renders correctly. +- [ ] Trainer encounter on Route 1: walk into line-of-sight of a Youngster trainer → + `!` appears → trainer walks to player → "TRAINER!" stub text shows → trainer + marked beaten → re-entering Youngster's line-of-sight does NOT re-trigger. +- [ ] Map warp reset: warp to another map and back → trainer can be triggered again + (expected given per-map-load simplification; confirm via `; TODO-GLOBAL-EVENTS`). +- [ ] Assembly unit check: + ```sh + nasm -f coff -o /dev/null dos_port/src/engine/overworld/map_sprites.asm + nasm -f coff -o /dev/null dos_port/src/engine/overworld/overworld.asm + ``` + +--- + +## Files created / modified + +### New (generated — never hand-edit) +- `dos_port/assets/npc_sprites/.inc` — full 24-tile sheet per sprite +- `dos_port/assets/npc_sprites/_still.inc` — 12-tile still subset per sprite +- `dos_port/assets/npc_sprite_data_table.inc` — pointer table indexed by sprite_id +- `dos_port/assets/npc_still_sprite_data_table.inc` — still pointer table +- `dos_port/assets/npc_dialogs/_dialogs.inc` — dialog table per map +- `dos_port/assets/npc_dialogs/all_dialogs.inc` — master include + `MapTextTablePointers` + +### Modified (tools) +- `dos_port/tools/gen_all_assets.py` +- `dos_port/tools/gen_overworld_assets.py` +- `dos_port/tools/gen_npc_dialogs.py` + +### Modified (asm) +- `dos_port/src/engine/overworld/map_sprites.asm` +- `dos_port/src/engine/overworld/overworld.asm` +- `dos_port/Makefile` diff --git a/dos_port/Makefile b/dos_port/Makefile index 2ba74494..343ed6fd 100644 --- a/dos_port/Makefile +++ b/dos_port/Makefile @@ -152,20 +152,22 @@ src/engine/overworld/overworld.o: \ assets/map_headers.inc \ assets/extra_includes.inc -# NPC dialog streams (generated from pret text/ and scripts/ sources) -assets/npc_dialogs_pallet_town.inc: - python3 tools/gen_npc_dialogs.py PALLET_TOWN +# NPC sprite sheets — all sprites used across all maps (generated by gen_all_assets.py) +assets/npc_sprite_data_table.inc: + python3 tools/gen_all_assets.py -# map_sprites depends on the NPC tile asset files and generated dialog streams +# NPC dialog streams — all maps (generated by gen_npc_dialogs.py) +assets/npc_dialogs/all_dialogs.inc: + python3 tools/gen_npc_dialogs.py + +# map_sprites depends on the NPC tile asset table and all-maps dialog table src/engine/overworld/map_sprites.o: \ - assets/npc_oak.inc \ - assets/npc_girl.inc \ - assets/npc_fisher.inc \ - assets/npc_dialogs_pallet_town.inc + assets/npc_sprite_data_table.inc \ + assets/npc_dialogs/all_dialogs.inc assets: assets/overworld_gfx.inc assets/overworld_blocks.inc assets/pallet_town_blk.inc \ assets/map_headers.inc assets/extra_includes.inc \ - assets/npc_dialogs_pallet_town.inc + assets/npc_sprite_data_table.inc assets/npc_dialogs/all_dialogs.inc all: $(TARGET) diff --git a/dos_port/src/engine/overworld/map_sprites.asm b/dos_port/src/engine/overworld/map_sprites.asm index b2f4f854..e48a50c6 100644 --- a/dos_port/src/engine/overworld/map_sprites.asm +++ b/dos_port/src/engine/overworld/map_sprites.asm @@ -38,6 +38,10 @@ extern HandleDownArrowBlinkTiming global InitMapSprites global CheckNPCInteraction global IsNPCAtTargetBlock +global w_map_text_table_ptr +global MapTextTablePointers +global CheckTrainerSight +global TrainerEncounterFlow ; --------------------------------------------------------------------------- ; Constants @@ -53,38 +57,32 @@ SPRITE_SET_SIZE equ 12 ; max unique sprite types per map ; BSS — per-map sprite deduplication table (reset at each InitMapSprites call) ; --------------------------------------------------------------------------- section .bss -npc_sprite_set: resb SPRITE_SET_SIZE ; sprite IDs in current map (0 = unused) -npc_vram_slots: resb SPRITE_SET_SIZE ; imageBaseOffset for each entry +npc_sprite_set: resb SPRITE_SET_SIZE ; sprite IDs in current map (0 = unused) +npc_vram_slots: resb SPRITE_SET_SIZE ; imageBaseOffset for each entry +w_map_text_table_ptr: resd 1 ; flat ptr to current map's TextTable (set by EnterMap) +; TODO-GLOBAL-EVENTS: npc_beaten_flags resets per InitMapSprites (per map load). +; Replace with a persistent global wEventFlags bit array when the event system is +; implemented so trainers stay beaten across map warps. +npc_beaten_flags: resw 1 ; bit N-1 = NPC slot N beaten; cleared in InitMapSprites +w_trainer_enc_slot: resb 1 ; engaging trainer slot byte-offset (0xFF = none) +w_player_frozen: resb 1 ; 1 = block player input during encounter flow ; --------------------------------------------------------------------------- -; NPC sprite tile assets (still poses only; 12 tiles = 192 bytes each) -; Included here so they land in .data and the NpcSpriteAssets table's dd -; pointers resolve to correct flat DS addresses. +; NPC sprite tile assets and dialog text tables (section .data so flat +; DS-relative pointers in npc_sprite_data_table resolve correctly). ; --------------------------------------------------------------------------- section .data -; NpcSpriteAssets — table of (sprite_id byte, asset_ptr dd) pairs, terminated -; by sprite_id = 0x00. LoadNPCSpriteTiles scans this to find each sprite's -; source data. Each asset is the full 384-byte sheet: tiles [0-11] = still, -; tiles [12-23] = walk. -NpcSpriteAssets: - db 0x03 ; SPRITE_OAK (0x03) - dd npc_oak - db 0x0D ; SPRITE_GIRL (0x0D) - dd npc_girl - db 0x2F ; SPRITE_FISHER (0x2F) - dd npc_fisher - db 0x00 ; terminator +; npc_sprite_data_table — flat dd array indexed by sprite_id (0x00-0x52). +; Each entry is a flat pointer to the sprite's full 384-byte tile sheet +; (tiles [0-11]=still, [12-23]=walk), or 0 if that sprite_id has no asset. +; NPC_SPRITE_TABLE_SIZE equ is defined inside the include. +; Generated by tools/gen_all_assets.py — do NOT edit assets/npc_sprite_data_table.inc. +%include "assets/npc_sprite_data_table.inc" -%include "assets/npc_oak.inc" -%include "assets/npc_girl.inc" -%include "assets/npc_fisher.inc" - -; NPC dialog streams generated from pret text sources. -; Defines pallet_town_oak_text, pallet_town_girl_text, pallet_town_fisher_text -; and PalletTownTextTable — see assets/npc_dialogs_pallet_town.inc. -; DO NOT EDIT the .inc directly — regenerate with: make assets -%include "assets/npc_dialogs_pallet_town.inc" +; NPC dialog streams — per-map text tables + MapTextTablePointers dispatch. +; Generated by tools/gen_npc_dialogs.py — do NOT edit these files. +%include "assets/npc_dialogs/all_dialogs.inc" ; --------------------------------------------------------------------------- ; Code @@ -118,6 +116,11 @@ InitMapSprites: mov ecx, SPRITE_SET_SIZE * 2 ; sprite_set + vram_slots = 24 bytes rep stosb + ; --- Initialize trainer encounter state (reset per map load) --- + mov word [npc_beaten_flags], 0 + mov byte [w_trainer_enc_slot], 0xFF + mov byte [w_player_frozen], 0 + ; --- Read sprite_count from W_OBJECT_DATA_PTR_TEMP --- movzx esi, word [ebp + W_OBJECT_DATA_PTR_TEMP] ; ESI = GB addr of sprite_count movzx ecx, byte [ebp + esi] @@ -283,18 +286,13 @@ LoadNPCSpriteTiles: imul ecx, NPC_TILE_BYTES ; * 192 lea edi, [ebp + ecx + GB_VCHARS0] ; flat addr in GB VRAM - ; Look up asset source for this sprite_id (AL) - mov esi, NpcSpriteAssets -.asset_scan: - movzx ecx, byte [esi] - test cl, cl - jz .next_sprite ; sprite not in table (no asset loaded) - cmp cl, al - je .found_asset - add esi, 5 ; skip 1-byte id + 4-byte ptr - jmp .asset_scan -.found_asset: - mov esi, [esi + 1] ; load flat asset pointer (the dd value) + ; Look up asset source for this sprite_id (AL) via flat table. + movzx ecx, al ; sprite_id as 32-bit index + cmp ecx, NPC_SPRITE_TABLE_SIZE ; bounds check + jae .next_sprite + mov esi, [npc_sprite_data_table + ecx*4] ; pointer to 384-byte tile sheet + test esi, esi + jz .next_sprite ; no asset for this sprite_id ; Still tiles [0-11] → GB_VCHARS0 + (imageBaseOffset-1)*NPC_TILE_BYTES mov ecx, NPC_TILE_BYTES rep movsb @@ -477,6 +475,16 @@ CheckNPCInteraction: .found_npc: ; ── Found: NPC at target block ────────────────────────────────────────── + ; Beaten-trainer gate: if this is a trainer whose beaten bit is set, return 0. + cmp byte [ebp + esi + W_SPRITE_STATE_DATA_2 + SPRITESTATEDATA2_ISTRAINER], 0 + je .not_beaten_trainer + mov edx, esi + shr dl, 4 ; slot number (1-15) + dec dl ; bit index (0-14) + bt word [npc_beaten_flags], dx ; CF=1 if beaten + jc .not_found ; beaten → no re-talk +.not_beaten_trainer: + ; Set H_TILE_PLAYER_STANDING_ON so UpdateSpriteImage picks this NPC's VRAM slot. ; UpdateSprites leaves H_TILE_PLAYER_STANDING_ON = last-slot value after the loop; ; that would cause the wrong sprite tiles (e.g. Fisher's) for any earlier NPC. @@ -491,14 +499,18 @@ CheckNPCInteraction: ; Freeze NPC movement during dialog. or byte [ebp + esi + W_SPRITE_STATE_DATA_1 + SPRITESTATEDATA1_MOVEMENTSTATUS], (1 << BIT_FACE_PLAYER) - ; Look up text_id → text data pointer and size from PalletTownTextTable. + ; Look up text_id → text data pointer and size via per-map text table. movzx eax, byte [ebp + esi + W_SPRITE_STATE_DATA_2 + SPRITESTATEDATA2_TEXTID] lea edx, [eax * 8] ; 8 bytes per entry (dd ptr + dd size) - mov edi, [PalletTownTextTable + edx] ; flat DS ptr to text stream + mov ecx, [w_map_text_table_ptr] ; flat ptr to current map's TextTable (0 if none) + test ecx, ecx + jz .dialog_done ; null table: no text for this map + + mov edi, [ecx + edx] ; flat DS ptr to text stream test edi, edi jz .dialog_done ; null entry: no text for this id - mov ecx, [PalletTownTextTable + edx + 4] ; byte count + mov ecx, [ecx + edx + 4] ; byte count cmp ecx, 256 jge .dialog_done ; safety: never copy more than 256 bytes @@ -519,7 +531,7 @@ CheckNPCInteraction: ; PrintText calls manual_text_scroll at CHAR_PARA/CHAR_CONT/CHAR_DONE, ; which shows the dialog (copies tiles to GB_TILEMAP1, H_WY=152) and waits. ; For text_end format (Oak): no final scroll inside PrintText; show + wait here. - call .show_dialog_and_wait + call npc_dialog_wait_impl .dialog_done: ; Hide window and clear font-loaded flag. @@ -545,8 +557,10 @@ CheckNPCInteraction: popad ret -; ── local helper: copy current wTileMap dialog rows to window layer, wait A/B ── -.show_dialog_and_wait: +; ── shared helper: copy current wTileMap dialog rows to window layer, wait A/B ── +; Called by CheckNPCInteraction and TrainerEncounterFlow. Not a dot-local label so +; both callers can reach it. Preserves ECX, ESI, EDI (push/pop). +npc_dialog_wait_impl: ; Copy wTileMap rows 12-17 to GB_TILEMAP1 rows 0-5 (window layer source). push ecx push esi @@ -599,3 +613,194 @@ CheckNPCInteraction: pop esi pop ecx ret + +; --------------------------------------------------------------------------- +; CheckTrainerSight — scan NPC slots 1-15 for an unbeaten trainer with the +; player in their line-of-sight (facing direction, distance ≤ 4 blocks). +; +; Sets w_trainer_enc_slot to the matching slot's byte offset if found. +; Out: CF=1 if a trainer spotted the player, CF=0 otherwise. +; All registers preserved (pushad/popad; CF set after popad). +; --------------------------------------------------------------------------- +CheckTrainerSight: + pushad + + ; Player block coords: SPRITESTATEDATA2 slot 0 MAPY/MAPX + movzx ebx, byte [ebp + W_SPRITE_STATE_DATA_2 + SPRITESTATEDATA2_MAPY] ; BL = player_mapy + movzx ecx, byte [ebp + W_SPRITE_STATE_DATA_2 + SPRITESTATEDATA2_MAPX] ; CL = player_mapx + + mov esi, 0x10 ; start at NPC slot 1 +.cts_loop: + cmp esi, 0x100 + jge .cts_none + + ; Skip inactive slot + cmp byte [ebp + esi + W_SPRITE_STATE_DATA_2 + SPRITESTATEDATA2_IMAGEBASEOFFSET], 0 + je .cts_next + + ; Skip non-trainer + cmp byte [ebp + esi + W_SPRITE_STATE_DATA_2 + SPRITESTATEDATA2_ISTRAINER], 0 + je .cts_next + + ; Skip if already beaten (bit_index = slot/0x10 - 1, i.e. 0-14) + mov edx, esi + shr dl, 4 ; slot number (1-15) in DL + dec dl ; bit index (0-14) + bt word [npc_beaten_flags], dx ; CF = beaten bit + jc .cts_next + + ; Load trainer position + movzx eax, byte [ebp + esi + W_SPRITE_STATE_DATA_2 + SPRITESTATEDATA2_MAPY] ; AL = trainer_mapy + movzx edx, byte [ebp + esi + W_SPRITE_STATE_DATA_2 + SPRITESTATEDATA2_MAPX] ; DL = trainer_mapx + + ; Check facing direction → sight line (BL=player_mapy, CL=player_mapx) + movzx edi, byte [ebp + esi + W_SPRITE_STATE_DATA_1 + SPRITESTATEDATA1_FACINGDIRECTION] + + cmp edi, SPRITE_FACING_DOWN + jne .cts_try_up + ; DOWN: same MAPX, player south of trainer, dist ≤ 4 + cmp cl, dl ; player_mapx == trainer_mapx? + jne .cts_next + cmp bl, al ; player_mapy > trainer_mapy? + jle .cts_next + mov ah, bl + sub ah, al ; dist = player_mapy - trainer_mapy + cmp ah, 4 + ja .cts_next + jmp .cts_found + +.cts_try_up: + cmp edi, SPRITE_FACING_UP + jne .cts_try_left + ; UP: same MAPX, player north of trainer, dist ≤ 4 + cmp cl, dl + jne .cts_next + cmp bl, al ; player_mapy < trainer_mapy? + jge .cts_next + mov ah, al + sub ah, bl ; dist = trainer_mapy - player_mapy + cmp ah, 4 + ja .cts_next + jmp .cts_found + +.cts_try_left: + cmp edi, SPRITE_FACING_LEFT + jne .cts_try_right + ; LEFT: same MAPY, player west of trainer, dist ≤ 4 + cmp bl, al ; player_mapy == trainer_mapy? + jne .cts_next + cmp cl, dl ; player_mapx < trainer_mapx? + jge .cts_next + mov ah, dl + sub ah, cl ; dist = trainer_mapx - player_mapx + cmp ah, 4 + ja .cts_next + jmp .cts_found + +.cts_try_right: + ; RIGHT: same MAPY, player east of trainer, dist ≤ 4 + cmp bl, al + jne .cts_next + cmp cl, dl ; player_mapx > trainer_mapx? + jle .cts_next + mov ah, cl + sub ah, dl ; dist = player_mapx - trainer_mapx + cmp ah, 4 + ja .cts_next + +.cts_found: + mov eax, esi ; ESI = slot offset (0x10-0xF0); AL = low byte + mov [w_trainer_enc_slot], al ; save slot offset (fits in a byte) + popad + stc + ret + +.cts_next: + add esi, 0x10 + jmp .cts_loop + +.cts_none: + popad + clc + ret + +; --------------------------------------------------------------------------- +; TrainerEncounterFlow — trainer encounter stub (no battle engine). +; Pret ref: home/overworld.asm:TrainerEncounter (stub). +; +; Flow: brief freeze → face trainer → show pre-battle text → +; mark trainer beaten → clear encounter state. +; +; Reads w_trainer_enc_slot for the engaging trainer's slot offset. +; All registers preserved (pushad/popad). +; --------------------------------------------------------------------------- +TrainerEncounterFlow: + pushad + + mov byte [w_player_frozen], 1 + + ; --- Brief freeze before text (~45 frames) --- + ; TODO: Add ! bubble over trainer's head here. + mov ecx, 45 +.tef_freeze: + call DelayFrame + dec ecx + jnz .tef_freeze + + ; --- Make trainer face player and freeze NPC movement during text --- + movzx esi, byte [w_trainer_enc_slot] ; ESI = slot byte offset (0x10-0xF0) + movzx eax, byte [ebp + esi + W_SPRITE_STATE_DATA_2 + SPRITESTATEDATA2_IMAGEBASEOFFSET] + dec al + ror al, 4 + mov [ebp + H_TILE_PLAYER_STANDING_ON], al + call MakeNPCFacePlayer + or byte [ebp + esi + W_SPRITE_STATE_DATA_1 + SPRITESTATEDATA1_MOVEMENTSTATUS], (1 << BIT_FACE_PLAYER) + + ; --- Look up and show pre-battle text --- + movzx eax, byte [ebp + esi + W_SPRITE_STATE_DATA_2 + SPRITESTATEDATA2_TEXTID] + lea edx, [eax * 8] ; 8 bytes per entry (dd ptr + dd size) + mov ecx, [w_map_text_table_ptr] ; flat ptr to current map's TextTable (0 if none) + test ecx, ecx + jz .tef_text_done + + mov edi, [ecx + edx] ; flat DS ptr to text stream + test edi, edi + jz .tef_text_done + + mov ecx, [ecx + edx + 4] ; byte count + cmp ecx, 256 + jge .tef_text_done + + ; ESI (slot offset) is consumed; save it around the rep movsb. + push esi + mov esi, edi ; text src ptr + lea edi, [ebp + NPC_DIALOG_BUF] + rep movsb + pop esi ; restore slot offset (not needed further, but balanced) + + or byte [ebp + W_FONT_LOADED], (1 << BIT_FONT_LOADED) + call LoadFontTilePatterns + mov esi, NPC_DIALOG_BUF + call PrintText + call npc_dialog_wait_impl + +.tef_text_done: + mov byte [ebp + H_WY], RENDER_H + and byte [ebp + W_FONT_LOADED], ~(1 << BIT_FONT_LOADED) + call LoadNPCSpriteTiles + call LoadPlayerSpriteGraphics + call LoadCurrentMapView + call DelayFrame + + ; --- Mark trainer beaten (bit_index = slot/0x10 - 1) --- + movzx edx, byte [w_trainer_enc_slot] + shr dl, 4 ; slot number (1-15) + dec dl ; bit index (0-14) + bts word [npc_beaten_flags], dx ; set beaten bit + + ; --- Clear encounter state --- + mov byte [w_trainer_enc_slot], 0xFF + mov byte [w_player_frozen], 0 + + popad + ret diff --git a/dos_port/src/engine/overworld/overworld.asm b/dos_port/src/engine/overworld/overworld.asm index c85576e2..f497e990 100644 --- a/dos_port/src/engine/overworld/overworld.asm +++ b/dos_port/src/engine/overworld/overworld.asm @@ -54,6 +54,10 @@ extern g_tilecache_dirty extern InitMapSprites extern CheckNPCInteraction extern IsNPCAtTargetBlock +extern CheckTrainerSight +extern TrainerEncounterFlow +extern w_map_text_table_ptr +extern MapTextTablePointers %ifdef DEBUG_DUMP extern DebugDumpMemory %endif @@ -277,6 +281,13 @@ OverworldLoop: mov byte [ebp + W_SPRITE_PLAYER_Y_STEP_VECTOR], 0 mov byte [ebp + W_SPRITE_PLAYER_X_STEP_VECTOR], 0 + ; Check trainer sight lines before reading joypad (pret: CheckTrainerSightLine). + call CheckTrainerSight + jnc .noTrainerSight + call TrainerEncounterFlow + jmp OverworldLoop +.noTrainerSight: + ; Simulated joypad state overrides real input (pret: AreInputsSimulated). ; BIT_SCRIPTED_MOVEMENT_STATE is set by PlayerStepOutFromDoor for one idle frame. ; H_JOY_HELD is used for A (not H_JOY_PRESSED): joypad_update runs twice per @@ -424,6 +435,11 @@ OverworldLoop: mov [ebp + W_LAST_MAP], al .skipLastMapUpdate: mov [ebp + W_CUR_MAP], bl + ; Update text table dispatch for the new map. + movzx eax, byte [ebp + W_CUR_MAP] + lea esi, [MapTextTablePointers] + mov esi, [esi + eax*4] + mov [w_map_text_table_ptr], esi mov byte [ebp + W_WALK_COUNTER], 0 mov byte [ebp + W_SPRITE_PLAYER_Y_STEP_VECTOR], 0 mov byte [ebp + W_SPRITE_PLAYER_X_STEP_VECTOR], 0 @@ -456,6 +472,11 @@ OverworldLoop: mov word [ebp + W_MAP_VIEW_VRAM_POINTER], GB_TILEMAP0 call LoadMapHeader + ; Update text table dispatch for the new map. + movzx eax, byte [ebp + W_CUR_MAP] + lea esi, [MapTextTablePointers] + mov esi, [esi + eax*4] + mov [w_map_text_table_ptr], esi call LoadTileBlockMap call LoadCurrentMapView @@ -731,6 +752,11 @@ LoadMapData: call ResetMapVariables call LoadTextBoxTilePatterns call LoadMapHeader + ; Dispatch per-map text table: MapTextTablePointers[W_CUR_MAP] → w_map_text_table_ptr. + movzx eax, byte [ebp + W_CUR_MAP] + lea esi, [MapTextTablePointers] + mov esi, [esi + eax*4] + mov [w_map_text_table_ptr], esi call InitMapSprites call LoadScreenRelatedData call LoadScreenRelatedData @@ -2188,8 +2214,7 @@ section .rodata %include "assets/route25_blk.inc" %include "assets/overworld_coll.inc" %include "assets/player_sprite.inc" -%include "assets/npc_girl_still.inc" -%include "assets/npc_fisher_still.inc" -%include "assets/npc_oak_still.inc" +; npc_*_still.inc files removed — LoadNPCSpriteTiles reads both still and walk +; halves from the full 384-byte sheet in npc_sprite_data_table.inc. %include "assets/map_headers.inc" %include "assets/extra_includes.inc" diff --git a/dos_port/tools/gen_all_assets.py b/dos_port/tools/gen_all_assets.py index fc104d01..125803f4 100644 --- a/dos_port/tools/gen_all_assets.py +++ b/dos_port/tools/gen_all_assets.py @@ -27,6 +27,9 @@ GFX_BLOCKSETS = ROOT / "gfx" / "blocksets" MAPS_DIR = ROOT / "maps" GFX_SPRITES = ROOT / "gfx" / "sprites" COLL_SRC = ROOT / "data" / "tilesets" / "collision_tile_ids.asm" +SPRITE_CONSTANTS = ROOT / "constants" / "sprite_constants.asm" +MAPS_OBJECTS_DIR = ROOT / "data" / "maps" / "objects" +NPC_SPRITES_DIR = ASSETS / "npc_sprites" # --------------------------------------------------------------------------- # Tileset table: (id, canonical_name, gfx_stem, blocks_stem, coll_label) @@ -106,6 +109,46 @@ def to_snake(pascal: str) -> str: return s.lower() +def parse_sprite_constants() -> dict: + """Parse constants/sprite_constants.asm → {SPRITE_FOO: id} (sequential const_def).""" + result = {} + sprite_id = 0 + for line in SPRITE_CONSTANTS.read_text().splitlines(): + m = re.match(r'\s*const\s+(SPRITE_\w+)', line) + if m: + result[m.group(1)] = sprite_id + sprite_id += 1 + return result + + +def enumerate_npc_sprites() -> list: + """Scan all data/maps/objects/*.asm for object_event SPRITE_* tokens. + + Returns a sorted list of (sprite_id, stem, const_name) tuples for every + unique sprite constant used on any map. stem is the .2bpp filename stem + (SPRITE_COOLTRAINER_F → cooltrainer_f). + """ + sprite_map = parse_sprite_constants() + used: set = set() + for obj_file in sorted(MAPS_OBJECTS_DIR.glob("*.asm")): + for line in obj_file.read_text().splitlines(): + m = re.search(r'\bobject_event\b[^;]*(SPRITE_\w+)', line) + if m: + name = m.group(1) + if name in sprite_map: + used.add(name) + else: + print(f"WARNING: unknown sprite constant {name!r} in {obj_file.name}", + file=sys.stderr) + result = [] + for name in used: + sid = sprite_map[name] + stem = name[len("SPRITE_"):].lower() + result.append((sid, stem, name)) + result.sort() + return result + + def main(): ASSETS.mkdir(parents=True, exist_ok=True) @@ -161,7 +204,7 @@ def main(): ) # ------------------------------------------------------------------ - # 3. Sprite assets (kept from gen_overworld_assets.py) + # 3. Player sprite # ------------------------------------------------------------------ player_src = GFX_SPRITES / "red.2bpp" if player_src.exists(): @@ -172,21 +215,81 @@ def main(): "Red overworld sprite 2bpp → [EBP+GB_VCHARS0] ($8000)", ) - for label, fname, base_offset in [ - ("npc_girl", "girl", 3), - ("npc_fisher", "fisher", 4), - ("npc_oak", "oak", 5), - ]: - src = GFX_SPRITES / f"{fname}.2bpp" - if src.exists(): - tile_base = (base_offset - 1) * 12 - write_inc( - ASSETS / f"{label}.inc", - label, - src.read_bytes(), - f"{fname} NPC sprite sheet (24 tiles: [0] still→[EBP+GB_VCHARS0+${0x8000 + tile_base*16:04X}], " - f"[12] walk→[EBP+GB_VFONT+${0x8800 + tile_base*16:04X}])", - ) + # ------------------------------------------------------------------ + # 4. NPC sprite assets — all sprites used in any map's object_event lines. + # + # Per-sprite: assets/npc_sprites/.inc (full 24-tile sheet) + # Master table: assets/npc_sprite_data_table.inc + # — %includes all per-sprite files then defines npc_sprite_data_table: + # a flat dd array indexed by sprite_id (0x00 … max_id). + # ------------------------------------------------------------------ + NPC_SPRITES_DIR.mkdir(parents=True, exist_ok=True) + used_sprites = enumerate_npc_sprites() + + if not used_sprites: + print("WARNING: no SPRITE_* constants found in map object files", file=sys.stderr) + + max_id = max(sid for sid, _, _ in used_sprites) if used_sprites else 0 + table_size = max_id + 1 + + # Map sprite_id → stem for used sprites + by_id: dict = {sid: (stem, name) for sid, stem, name in used_sprites} + + FULL_SHEET = 384 # 24 tiles × 16 bytes; LoadNPCSpriteTiles always copies this much + + # Per-sprite full-sheet inc files + missing: set = set() + for sid, stem, name in used_sprites: + src = GFX_SPRITES / f"{stem}.2bpp" + if not src.exists(): + print(f"WARNING: {src} missing for {name} (id=0x{sid:02X}), " + "emitting dd 0 in table", file=sys.stderr) + missing.add(sid) + continue + data = src.read_bytes() + note = "" + if len(data) < FULL_SHEET: + # Sprites smaller than 24 tiles (items, single-pose NPCs): pad with + # zeros so LoadNPCSpriteTiles's two rep movsb calls always find + # FULL_SHEET bytes. Walk tiles will be zeroed (invisible) which is + # correct for STAY-only sprites. + note = f", padded from {len(data)} bytes" + data = data + bytes(FULL_SHEET - len(data)) + write_inc( + NPC_SPRITES_DIR / f"{stem}.inc", + f"npc_{stem}", + data, + f"{name} (0x{sid:02X}) sprite sheet (24 tiles: [0-11]=still, " + f"[12-23]=walk{note})", + ) + + # Master data table: %includes + flat pointer array + table_lines = [ + "; npc_sprite_data_table.inc — generated by tools/gen_all_assets.py. DO NOT EDIT BY HAND.", + "; Flat dd array indexed by sprite_id (0 = no asset / not used on any map).", + "; Included from map_sprites.asm in section .data.", + "", + ] + for sid, stem, name in used_sprites: + if sid not in missing: + table_lines.append(f'%include "assets/npc_sprites/{stem}.inc"') + table_lines += [ + "", + f"NPC_SPRITE_TABLE_SIZE equ {table_size}", + "", + "npc_sprite_data_table:", + ] + for i in range(table_size): + if i in by_id and i not in missing: + stem, name = by_id[i] + table_lines.append(f" dd npc_{stem} ; 0x{i:02X} {name}") + else: + comment = f"; 0x{i:02X} {by_id[i][1]}" if i in by_id else f"; 0x{i:02X} (unused)" + table_lines.append(f" dd 0 {comment}") + table_lines.append("") + (ASSETS / "npc_sprite_data_table.inc").write_text("\n".join(table_lines) + "\n") + print(f" wrote {ASSETS / 'npc_sprite_data_table.inc'} " + f"({table_size} entries, {len(used_sprites) - len(missing)} sprites)") print("done.") diff --git a/dos_port/tools/gen_npc_dialogs.py b/dos_port/tools/gen_npc_dialogs.py index 9ccb2e2f..67717d01 100644 --- a/dos_port/tools/gen_npc_dialogs.py +++ b/dos_port/tools/gen_npc_dialogs.py @@ -1,33 +1,19 @@ #!/usr/bin/env python3 -"""Generate NPC dialog byte streams from pret text sources. +"""Generate NPC dialog byte streams for ALL maps and the master dispatch table. -Usage: python3 tools/gen_npc_dialogs.py [MAP_NAME] - MAP_NAME defaults to PALLET_TOWN. - Output: dos_port/assets/npc_dialogs_.inc +Outputs: + assets/npc_dialogs/_dialogs.inc — one file per map with NPCs + assets/npc_dialogs/all_dialogs.inc — master %include + MapTextTablePointers Sources: - constants/charmap.asm — charmap → byte encoding - text/.asm — raw text label definitions - scripts/.asm — TextPointers table → far-text labels - data/maps/objects/.asm — object_const_def → NPC count + constants/map_constants.asm — map IDs and names + constants/charmap.asm — charmap encoding + text/.asm — raw text label definitions + scripts/.asm — TextPointers table + local label resolution + data/maps/objects/.asm — object_event entries (NPC count + trainer flag) -For each NPC slot i in the TextPointers table: - - If the slot's local_label uses text_asm (scripted/trainer) or is not found: - emit a stub (b"TRAINER!" stub bytes — Phase 3+ will flesh out). - - Otherwise: - resolve local_label → text_far _FarLabel → encode from text/.asm - -Output NASM: - section .data ← safe: data lands in .data, never orphaned as .rodata - pallet_oak_text: / pallet_girl_text: / etc. - PalletTownTextTable: dd ptr, size (matches CheckNPCInteraction layout) - -The table name and per-NPC label names follow the existing hand-encoded -convention in map_sprites.asm so that file can drop in the %include with -no other changes to CheckNPCInteraction. - -Run from dos_port/: - python3 tools/gen_npc_dialogs.py PALLET_TOWN +Run from dos_port/ or repo root: + python3 tools/gen_npc_dialogs.py """ import re import sys @@ -35,6 +21,8 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent.parent.parent ASSETS = ROOT / "dos_port" / "assets" +DIALOGS_DIR = ASSETS / "npc_dialogs" +MAP_CONSTANTS_FILE = ROOT / "constants" / "map_constants.asm" # --------------------------------------------------------------------------- # TX_* / CHAR_* byte constants (must match text.asm) @@ -47,12 +35,47 @@ CHAR_CONT = 0x55 CHAR_DONE = 0x57 # --------------------------------------------------------------------------- -# Charmap loader +# Stubs for trainers and script NPCs +# --------------------------------------------------------------------------- +# TRAINER stub: TX_START + "TRAINER!" + CHAR_DONE + TX_END +_TRAINER_STUB = bytes( + [TX_START, + 0x93,0x91,0x80,0x88,0x8D,0x84,0x91,0xE7, # "TRAINER!" + CHAR_DONE, TX_END] +) +# SCRIPT stub: TX_START + "..." + CHAR_DONE + TX_END +_SCRIPT_STUB = bytes( + [TX_START, + 0xE3,0xE3,0xE3, # "..." + CHAR_DONE, TX_END] +) + +# --------------------------------------------------------------------------- +# Map constants # --------------------------------------------------------------------------- -def _load_charmap(path: Path) -> list[tuple[str, int]]: - """Return (key_str, byte_val) pairs sorted by length desc for greedy match.""" - cm: list[tuple[str, int]] = [] +def const_to_pascal(const: str) -> str: + """CONST_NAME → PascalCase. REDS_HOUSE_1F → RedsHouse1F""" + return "".join(w.capitalize() for w in const.split("_")) + +def parse_map_constants() -> list: + """Return [(id, const_name, pascal_name)] sorted by id.""" + result = [] + for line in MAP_CONSTANTS_FILE.read_text().splitlines(): + m = re.match(r"\s*map_const\s+(\w+),\s*\d+,\s*\d+\s*;\s*\$([0-9A-Fa-f]+)", line) + if m: + const = m.group(1) + mid = int(m.group(2), 16) + result.append((mid, const, const_to_pascal(const))) + result.sort() + return result + +# --------------------------------------------------------------------------- +# Charmap +# --------------------------------------------------------------------------- + +def _load_charmap(path: Path) -> list: + cm = [] for line in path.read_text(encoding='utf-8').splitlines(): m = re.match(r'\s+charmap\s+"((?:[^"\\]|\\.)*)",\s*\$([0-9a-fA-F]+)', line) if m: @@ -62,9 +85,8 @@ def _load_charmap(path: Path) -> list[tuple[str, int]]: cm.sort(key=lambda x: -len(x[0])) return cm -def _encode(s: str, charmap: list[tuple[str, int]]) -> bytes: - """Encode a pret text string to bytes using greedy charmap matching.""" - out: list[int] = [] +def _encode(s: str, charmap: list) -> bytes: + out = [] i = 0 while i < len(s): matched = False @@ -79,20 +101,16 @@ def _encode(s: str, charmap: list[tuple[str, int]]) -> bytes: return bytes(out) # --------------------------------------------------------------------------- -# text/.asm parser → {label: bytes} +# text/.asm → {label: bytes} # --------------------------------------------------------------------------- -def _parse_text_file(path: Path, charmap: list[tuple[str, int]]) -> dict[str, bytes]: - """Parse text/.asm; return {double-underscore label: encoded bytes}.""" - TX_START_B = bytes([TX_START]) - entries: dict[str, bytes] = {} - cur_label: str | None = None - cur_bytes: list[int] = [] +def _parse_text_file(path: Path, charmap: list) -> dict: + entries = {} + cur_label = None + cur_bytes = [] for raw in path.read_text(encoding='utf-8').splitlines(): line = raw.strip() - - # New exported label _Name:: or _Name: m = re.match(r'(_\w+)::?', line) if m: if cur_label is not None: @@ -100,165 +118,120 @@ def _parse_text_file(path: Path, charmap: list[tuple[str, int]]) -> dict[str, by cur_label = m.group(1) cur_bytes = [] continue - if cur_label is None or not line or line.startswith(';'): continue - def _add_str(s: str) -> None: + def _add(s): cur_bytes.extend(_encode(s, charmap)) - # text "..." m = re.match(r'text\s+"(.*)"', line) if m: - cur_bytes.append(TX_START) - _add_str(m.group(1)) - continue - - # line "..." + cur_bytes.append(TX_START); _add(m.group(1)); continue m = re.match(r'line\s+"(.*)"', line) if m: - cur_bytes.append(CHAR_LINE) - _add_str(m.group(1)) - continue - - # next "..." + cur_bytes.append(CHAR_LINE); _add(m.group(1)); continue m = re.match(r'next\s+"(.*)"', line) if m: - cur_bytes.append(0x4E) # CHAR_NEXT - _add_str(m.group(1)) - continue - - # para "..." + cur_bytes.append(0x4E); _add(m.group(1)); continue m = re.match(r'para\s+"(.*)"', line) if m: - cur_bytes.append(CHAR_PARA) - _add_str(m.group(1)) - continue - - # cont "..." + cur_bytes.append(CHAR_PARA); _add(m.group(1)); continue m = re.match(r'cont\s+"(.*)"', line) if m: - cur_bytes.append(CHAR_CONT) - _add_str(m.group(1)) - continue - - # prompt + cur_bytes.append(CHAR_CONT); _add(m.group(1)); continue if line == 'prompt': - cur_bytes.append(0x58) # CHAR_PROMPT - continue - - # done → CHAR_DONE + TX_END; close entry + cur_bytes.append(0x58); continue if line == 'done': cur_bytes.extend([CHAR_DONE, TX_END]) entries[cur_label] = bytes(cur_bytes) - cur_label = None - cur_bytes = [] - continue - - # text_end → TX_END (string already terminated by '@' in last encoded str) + cur_label = None; cur_bytes = []; continue if line == 'text_end': cur_bytes.append(TX_END) entries[cur_label] = bytes(cur_bytes) - cur_label = None - cur_bytes = [] - continue + cur_label = None; cur_bytes = []; continue - # Flush last entry if file ends without done/text_end if cur_label is not None and cur_bytes: entries[cur_label] = bytes(cur_bytes) - return entries # --------------------------------------------------------------------------- -# scripts/.asm — TextPointers table + local label resolution +# scripts/.asm — TextPointers table # --------------------------------------------------------------------------- -def _parse_text_pointers(path: Path, map_pascal: str) -> list[str]: - """Return ordered list of local-label names from _TextPointers:.""" +def _parse_text_pointers(path: Path, map_pascal: str) -> list: target = f"{map_pascal}_TextPointers:" - lines = path.read_text(encoding='utf-8').splitlines() in_table = False - result: list[str] = [] - for line in lines: + result = [] + for line in path.read_text(encoding='utf-8').splitlines(): s = line.strip() if s == target: - in_table = True - continue + in_table = True; continue if not in_table: continue - # def_text_pointers / blank / comment — skip if not s or s.startswith(';') or s == 'def_text_pointers': continue m = re.match(r'dw_const\s+(\w+)', s) if m: result.append(m.group(1)) else: - break # non-dw_const line terminates the table + break return result def _resolve_local_label(path: Path, label: str) -> str | None: - """ - Find the first text_far _FarLabel reference under `label:` in the scripts file. - Returns the far label string (with leading underscore) or None if not found - (e.g. pure text_asm without a fallback text_far — stub territory). - """ - lines = path.read_text(encoding='utf-8').splitlines() + """Find text_far _FarLabel under `label:` in scripts file, or None.""" in_label = False - for line in lines: + for line in path.read_text(encoding='utf-8').splitlines(): s = line.strip() if re.match(rf'{re.escape(label)}\s*:', s): - in_label = True - continue + in_label = True; continue if not in_label: continue - # A NEW non-local Pascal-case label (first char uppercase) ends the section if re.match(r'[A-Z]\w+:', s): break - # text_far _FarLabel m = re.match(r'text_far\s+(_\w+)', s) if m: return m.group(1) return None # --------------------------------------------------------------------------- -# data/maps/objects/.asm — count NPC slots from object_const_def +# data/maps/objects/.asm — NPC entries # --------------------------------------------------------------------------- +def _read_npc_entries(path: Path) -> list: + """Return list of {is_trainer, is_item} dicts for each object_event.""" + result = [] + for line in path.read_text(encoding='utf-8').splitlines(): + m = re.match(r'\s*object_event\s+(.+)', line) + if m: + # Strip inline comment, split on commas + args_str = re.sub(r';.*', '', m.group(1)) + args = [a.strip() for a in args_str.split(',') if a.strip()] + is_trainer = len(args) >= 8 + is_item = len(args) == 7 + result.append({'is_trainer': is_trainer, 'is_item': is_item}) + return result + def _count_npcs(path: Path) -> int: - """Count const_export lines inside the object_const_def block.""" - lines = path.read_text(encoding='utf-8').splitlines() + """Count const_export lines in the object_const_def block.""" in_block = False count = 0 - for line in lines: + for line in path.read_text(encoding='utf-8').splitlines(): s = line.strip() if s == 'object_const_def': - in_block = True - continue + in_block = True; continue if not in_block: continue if s.startswith('const_export'): count += 1 elif s and not s.startswith(';'): - break # first non-const-export non-blank line ends the block + break return count # --------------------------------------------------------------------------- -# Stub bytes for trainer / unresolvable NPCs (Phase 3+) -# --------------------------------------------------------------------------- - -# TRAINER stub: TX_START + "TRAINER!" + CHAR_DONE + TX_END -_TRAINER_STUB = bytes( - [TX_START, - 0x93,0x91,0x80,0x88,0x8D,0x84,0x91,0xE7, # "TRAINER!" - CHAR_DONE, TX_END] -) - -# --------------------------------------------------------------------------- -# NASM emitter helpers +# NASM helpers # --------------------------------------------------------------------------- def _bytes_to_nasm(data: bytes, indent: str = ' ') -> str: - """Format a bytes object as db lines with 16 bytes per row.""" lines = [] for i in range(0, len(data), 16): chunk = data[i:i+16] @@ -267,116 +240,200 @@ def _bytes_to_nasm(data: bytes, indent: str = ' ') -> str: return '\n'.join(lines) # --------------------------------------------------------------------------- -# Main +# Per-map generation # --------------------------------------------------------------------------- -def _to_pascal(snake: str) -> str: - """'PALLET_TOWN' → 'PalletTown'""" - return ''.join(w.capitalize() for w in snake.split('_')) +def generate_map(map_id: int, const: str, map_pascal: str, charmap: list, + text_db_cache: dict) -> tuple | None: + """Generate dialog for one map. -def generate(map_name: str) -> None: - map_pascal = _to_pascal(map_name) # 'PalletTown' - map_snake = map_name.lower() # 'pallet_town' + Returns (table_label, out_path) if any NPCs exist, else None. + Writes the .inc file. text_db_cache is populated lazily. + """ + map_snake = const.lower() + table_label = f"{map_pascal}TextTable" - charmap_path = ROOT / 'constants' / 'charmap.asm' - text_path = ROOT / 'text' / f'{map_pascal}.asm' - scripts_path = ROOT / 'scripts' / f'{map_pascal}.asm' - objects_path = ROOT / 'data' / 'maps' / 'objects' / f'{map_pascal}.asm' - out_path = ASSETS / f'npc_dialogs_{map_snake}.inc' + objects_path = ROOT / "data" / "maps" / "objects" / f"{map_pascal}.asm" + scripts_path = ROOT / "scripts" / f"{map_pascal}.asm" + text_path = ROOT / "text" / f"{map_pascal}.asm" + + if not objects_path.exists(): + return None - # Load resources - charmap = _load_charmap(charmap_path) - text_db = _parse_text_file(text_path, charmap) - pointers = _parse_text_pointers(scripts_path, map_pascal) npc_count = _count_npcs(objects_path) - if npc_count == 0: - print(f'[gen_npc_dialogs] {map_name}: no NPCs — skipping', file=sys.stderr) - return + return None + + npc_flags = _read_npc_entries(objects_path) + if len(npc_flags) < npc_count: + npc_flags += [{'is_trainer': False, 'is_item': False}] * (npc_count - len(npc_flags)) + + # Need text pointers from scripts + if not scripts_path.exists(): + print(f"[dialogs] {const}: no scripts file — emitting {npc_count} stubs", + file=sys.stderr) + pointers = [] + else: + pointers = _parse_text_pointers(scripts_path, map_pascal) if npc_count > len(pointers): - print( - f'[gen_npc_dialogs] WARNING: {map_name}: npc_count={npc_count} ' - f'but only {len(pointers)} TextPointers entries — truncating', - file=sys.stderr - ) - npc_count = len(pointers) + # Clamp: can't resolve beyond available pointers + npc_count_eff = len(pointers) + if npc_count_eff == 0: + npc_count_eff = npc_count # all stubs + else: + npc_count_eff = npc_count - # Resolve each NPC slot's text bytes - npc_label_prefix = map_snake.replace('_', '_') # keep as-is for now - npc_entries: list[tuple[str, bytes]] = [] - table_label = f'{map_pascal}TextTable' + # Lazy-load text db for this map + text_db = {} + if text_path.exists() and text_path not in text_db_cache: + try: + text_db_cache[text_path] = _parse_text_file(text_path, charmap) + except Exception as e: + print(f"[dialogs] {const}: text parse error: {e}", file=sys.stderr) + text_db_cache[text_path] = {} + text_db = text_db_cache.get(text_path, {}) - for i in range(npc_count): - local_label = pointers[i] - asm_label = local_label[0].lower() + local_label[1:] # PalletTownOakText → palletTownOakText - # Build the label used in NASM (e.g. pallet_oak_text) - # Match existing hand-encoded names exactly for Pallet Town NPCs: - # PalletTownOakText → pallet_oak_text - # PalletTownGirlText → pallet_girl_text - # PalletTownFisherText → pallet_fisher_text - # General rule: strip map prefix ('PalletTown'), snake_case remainder, prepend map_snake. - suffix = local_label - if suffix.startswith(map_pascal): - suffix = suffix[len(map_pascal):] # 'OakText', 'GirlText', 'FisherText' - # Strip trailing 'Text' - if suffix.endswith('Text'): - suffix = suffix[:-4] - # Convert PascalCase suffix to snake_case - snake_suffix = re.sub(r'(?<=[a-z])(?=[A-Z])', '_', suffix).lower() - nasm_label = f'{map_snake}_{snake_suffix}_text' + # Resolve each slot + npc_entries = [] + for i in range(npc_count_eff): + flags = npc_flags[i] if i < len(npc_flags) else {} + is_trainer = flags.get('is_trainer', False) + is_item = flags.get('is_item', False) - # Resolve: local_label → text_far → far_label → bytes - far_label = _resolve_local_label(scripts_path, local_label) - if far_label is None: - # text_asm without a text_far: use stub (should not happen for normal NPCs) - data = _TRAINER_STUB - print( - f'[gen_npc_dialogs] {map_name}[{i}] {local_label}: ' - f'no text_far found — emitting stub', - file=sys.stderr - ) - elif far_label not in text_db: - data = _TRAINER_STUB - print( - f'[gen_npc_dialogs] {map_name}[{i}] {local_label}: ' - f'far label {far_label} not in text file — emitting stub', - file=sys.stderr - ) + if i < len(pointers): + local_label = pointers[i] else: - data = text_db[far_label] + local_label = None + + # Build NASM label: always include slot index i to guarantee uniqueness + # (two NPCs can share the same text pointer, which would produce duplicate labels). + if local_label: + suffix = local_label + if suffix.startswith(map_pascal): + suffix = suffix[len(map_pascal):] + if suffix.endswith('Text'): + suffix = suffix[:-4] + snake_suffix = re.sub(r'(?<=[a-z0-9])(?=[A-Z])', '_', suffix).lower() + nasm_label = f"{map_snake}_{snake_suffix}_{i}_text" + else: + nasm_label = f"{map_snake}_npc{i}_text" + + # Resolve text bytes + if is_trainer: + # Try to resolve the actual pre-battle text; fall back to stub + data = _TRAINER_STUB + if local_label and scripts_path.exists(): + far = _resolve_local_label(scripts_path, local_label) + if far and far in text_db: + data = text_db[far] + elif is_item: + data = _SCRIPT_STUB + else: + data = None + if local_label and scripts_path.exists(): + far = _resolve_local_label(scripts_path, local_label) + if far is None: + # text_asm script NPC + data = _SCRIPT_STUB + print(f"[dialogs] {const}[{i}] {local_label}: " + f"text_asm script NPC — emitting stub", file=sys.stderr) + elif far not in text_db: + data = _SCRIPT_STUB + print(f"[dialogs] {const}[{i}] {local_label}: " + f"far label {far!r} not found — emitting stub", file=sys.stderr) + else: + data = text_db[far] + if data is None: + data = _SCRIPT_STUB npc_entries.append((nasm_label, data)) - # Emit NASM include - ASSETS.mkdir(exist_ok=True) + # Emit NASM include file + out_path = DIALOGS_DIR / f"{map_snake}_dialogs.inc" lines = [ - f'; Auto-generated by tools/gen_npc_dialogs.py — DO NOT EDIT', - f'; Source: text/{map_pascal}.asm + scripts/{map_pascal}.asm', - f'; Map: {map_name} ({npc_count} NPC slot(s))', - f';', - f'; Data is in section .data so it is never orphaned into .rodata.', - f'; (Orphaned sections read as zero at runtime; see docs/translation_log.md)', - f'', - f'section .data', - f'', + f"; {out_path.name} — generated by tools/gen_npc_dialogs.py. DO NOT EDIT BY HAND.", + f"; Map: {const} (id=0x{map_id:02X}), {len(npc_entries)} NPC slot(s).", + f"; Source: text/{map_pascal}.asm + scripts/{map_pascal}.asm", + f";", + f"; section .data so labels never land in an orphaned section.", + f"", + f"section .data", + f"", + ] + for nasm_label, data in npc_entries: + lines.append(f"{nasm_label}:") + lines.append(_bytes_to_nasm(data)) + lines.append(f"{nasm_label}_end:") + lines.append("") + lines.append(f"{table_label}:") + for nasm_label, _ in npc_entries: + lines.append(f" dd {nasm_label}, {nasm_label}_end - {nasm_label}") + lines.append(" dd 0, 0 ; sentinel") + lines.append("") + out_path.write_text('\n'.join(lines), encoding='utf-8') + return (table_label, out_path) + +# --------------------------------------------------------------------------- +# Master all_dialogs.inc +# --------------------------------------------------------------------------- + +def generate_all() -> None: + DIALOGS_DIR.mkdir(parents=True, exist_ok=True) + + charmap = _load_charmap(ROOT / 'constants' / 'charmap.asm') + all_maps = parse_map_constants() + max_id = max(mid for mid, _, _ in all_maps) + + # {map_id: table_label} for maps with NPCs + map_table: dict = {} + text_db_cache: dict = {} + + for mid, const, pascal in all_maps: + result = generate_map(mid, const, pascal, charmap, text_db_cache) + if result is not None: + table_label, out_path = result + map_table[mid] = table_label + print(f" wrote {out_path.relative_to(ROOT)}") + + # Emit all_dialogs.inc + all_path = DIALOGS_DIR / "all_dialogs.inc" + lines = [ + "; all_dialogs.inc — generated by tools/gen_npc_dialogs.py. DO NOT EDIT BY HAND.", + "; Includes all per-map dialog tables and defines MapTextTablePointers.", + "; %included from map_sprites.asm in section .data.", + "", ] - for nasm_label, data in npc_entries: - lines.append(f'{nasm_label}:') - lines.append(_bytes_to_nasm(data)) - lines.append(f'{nasm_label}_end:') - lines.append('') + for mid, const, pascal in all_maps: + if mid in map_table: + snake = const.lower() + lines.append(f'%include "assets/npc_dialogs/{snake}_dialogs.inc"') - lines.append(f'{table_label}:') - for nasm_label, _ in npc_entries: - lines.append(f' dd {nasm_label}, {nasm_label}_end - {nasm_label}') - lines.append(' dd 0, 0') - lines.append('') + lines += [ + "", + f"; MapTextTablePointers — flat dd array indexed by map_id (0x00..0x{max_id:02X}).", + f"; Entry is a flat pointer to the map's TextTable, or 0 if no NPCs.", + "MapTextTablePointers:", + ] + for i in range(max_id + 1): + # Find if any map uses this id + label = map_table.get(i) + if label: + # Look up the const name for the comment + const_for_id = next((c for mid, c, _ in all_maps if mid == i), f"0x{i:02X}") + lines.append(f" dd {label} ; 0x{i:02X} {const_for_id}") + else: + lines.append(f" dd 0 ; 0x{i:02X} (no NPCs)") + lines.append("") - out_path.write_text('\n'.join(lines), encoding='utf-8') - print(f'[gen_npc_dialogs] Wrote {out_path.relative_to(ROOT)} ({npc_count} NPCs)') + all_path.write_text('\n'.join(lines), encoding='utf-8') + print(f" wrote {all_path.relative_to(ROOT)} " + f"({len(map_table)} maps with NPCs, {max_id+1} table entries)") + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- if __name__ == '__main__': - map_name = sys.argv[1] if len(sys.argv) > 1 else 'PALLET_TOWN' - generate(map_name) + generate_all()