mirror of
https://github.com/pret/pokeyellow.git
synced 2026-09-11 12:05:27 -05:00
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:
@@ -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 (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/<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`
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user