Programmatic NPC placement: all-map dialogs, sprite table, trainer sight

Stages 1-5 of the current_plan:
- gen_all_assets.py: dynamic NPC sprite enumeration (75 sprites, 83-entry
  flat pointer table assets/npc_sprite_data_table.inc, sprites padded to 384
  bytes so LoadNPCSpriteTiles's two rep movsb calls always find full data)
- gen_npc_dialogs.py: full rewrite — all 249 maps, charmap text encoding,
  trainer/item/script-NPC stubs, slot-indexed labels for uniqueness, outputs
  assets/npc_dialogs/all_dialogs.inc with MapTextTablePointers[0x00..0xF8]
- map_sprites.asm: w_map_text_table_ptr BSS var + global MapTextTablePointers;
  CheckNPCInteraction uses [w_map_text_table_ptr] instead of hardcoded
  PalletTownTextTable; beaten-trainer gate; CheckTrainerSight + TrainerEncounterFlow
  stubs (45-frame freeze → face → text → mark beaten); ; TODO-GLOBAL-EVENTS on
  npc_beaten_flags (resets per map load; future wEventFlags replacement marked)
- overworld.asm: dispatch MapTextTablePointers[W_CUR_MAP] → w_map_text_table_ptr
  in LoadMapData, .mapTransition, and .warpTransition; CheckTrainerSight called
  in OverworldLoop idle path before joypad read
- Makefile: updated asset targets and map_sprites.o dependencies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UNGMTp7zqMnujSFWDoPiPd
This commit is contained in:
Happyarch
2026-06-24 15:50:04 -04:00
parent 371d716345
commit db7b48d689
6 changed files with 848 additions and 289 deletions

View File

@@ -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/<stem>.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/<stem>.inc`
(label `npc_<stem>:`, 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 (0x000x52), `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/<map_snake>_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/<stem>.inc` — full 24-tile sheet per sprite
- `dos_port/assets/npc_sprites/<stem>_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/<mapname>_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`

View File

@@ -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)

View File

@@ -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

View File

@@ -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"

View File

@@ -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/<stem>.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.")

View File

@@ -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_<map_name>.inc
Outputs:
assets/npc_dialogs/<map_snake>_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/<PascalMapName>.asm — raw text label definitions
scripts/<PascalMapName>.asm — TextPointers table → far-text labels
data/maps/objects/<PascalMapName>.asm — object_const_def → NPC count
constants/map_constants.asm — map IDs and names
constants/charmap.asm — charmap encoding
text/<PascalMapName>.asm — raw text label definitions
scripts/<PascalMapName>.asm — TextPointers table + local label resolution
data/maps/objects/<Pascal>.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/<Map>.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/<Map>.asm parser → {label: bytes}
# text/<Map>.asm → {label: bytes}
# ---------------------------------------------------------------------------
def _parse_text_file(path: Path, charmap: list[tuple[str, int]]) -> dict[str, bytes]:
"""Parse text/<MapName>.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/<Map>.asm — TextPointers table + local label resolution
# scripts/<Map>.asm — TextPointers table
# ---------------------------------------------------------------------------
def _parse_text_pointers(path: Path, map_pascal: str) -> list[str]:
"""Return ordered list of local-label names from <Map>_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/<Map>.asm — count NPC slots from object_const_def
# data/maps/objects/<Map>.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()