# 환세취호전 (Hwanse Chwiho-jeon) — Data File Formats

게임 실행파일: `Hwanse2.exe` (PE32, MSVC, 1999-02-11 빌드, DirectX 사용 — DDRAW/DSOUND/DINPUT/WINMM/MSVFW32).
`out/exe_imports.json` 에 import table 요약을 생성한다. 확인된 DirectX entry point 는
`DDRAW.dll!DirectDrawCreate`, `DINPUT.dll!DirectInputCreateA`, `DSOUND.dll!DirectSoundCreate` 다.
DirectInput 세부 추적은 `docs/DIRECTINPUT_NOTES.md` 에 기록하고, 원본 16-action 키 매핑
테이블은 `out/input_keymap.md` 에 생성한다.

데이터 아카이브는 세 종류이며, 모두 little-endian.
세이브 데이터 `savedat*.dat` 의 현재 외부 근거는 `docs/SAVEDATA_NOTES.md` 에 정리한다.

## 1. GENSE.FLD — 그래픽/스크립트 아카이브

```
magic   "FLDF0100"              (8 bytes)
count   u32                     (e.g. 377)
entries count * 20 bytes:
    char  name[12]              NUL-padded, lowercase "xxx.cns"
    u32   offset                absolute, from start of file
    u32   size
body    raw payloads
```

내부 파일은 모두 확장자 `.cns` (377개). 추출기: `tools/extract_fld.py`.

prefix별 카테고리 추정:
- `map_*`, `mapN_*` — 맵 데이터 / 타일 (219개, 가장 많음)
- `cara_*`, `face_*` — 캐릭터 / 얼굴 스프라이트
- `btl_*`, `boss_*` — 전투 화면 / 보스
- `zg_/zh_/zi_/zj_/zk_/zm_/zs_/zy_/zak_/...` — 몬스터/적 스프라이트
- `title`, `logo`, `ed`, `ed_compi`, `compile` — 타이틀/엔딩/제작사 로고
- `icon`, `frame`, `window`, `num`, `item`, `spot`, `status`, `sun` — UI 리소스
- `aaa.cns`, `01234567.cns` — 폰트 또는 디버그?

### .cns 내부 포맷

`.cns` 는 파일 앞 4바이트가 magic 이 아니라 CNS 압축 스트림의 첫 명령일 수 있다.
`tools/decode_cns.py` 의 `decompress_cns()` 로 압축을 푼 뒤 페이로드 종류를 판별한다.

확인된 페이로드:

```
이미지:
u16 unknown
u16 width
u16 height
u16 palette_count_minus_1
u32 palette[palette_count]      BMP 순서: B, G, R, reserved
u8  indexed_pixels[]            BMP 스타일 bottom-up, 4바이트 row padding

맵 레이아웃:
u16 width_in_tiles
u16 height_in_tiles
u16 layer0[width * height]
u16 layer1[width * height]
```

이미지 bit depth 는 `cns110.exe` 와 동일하게 팔레트가 16색 이하이면 4bpp, 그보다 크면 8bpp 로 처리하면
현재 확인된 UI/타이틀/타일셋/스프라이트 리소스가 정상 PNG 로 변환된다.

맵 타일 ID 는 현재 16x16 타일셋의 0-based index 로 취급한다. 렌더러에서는 `0` 을 빈 칸으로
남겨 두고, non-zero 값은 그대로 타일셋 좌표로 변환한다. `map_*1`, `map_*2` 계열의
640x192 타일셋은 40x12 한 장으로 읽는 구조다. non-zero 값에서 1을 빼는 후보는
길/벽 타일이 서로 밀리는 결과를 만들었고, 20x6 페이지 4장이 2x2로 들어있는 atlas 로
쪼개는 후보는 같은 큰 구조가 사분면처럼 반복되는 결과를 만들어 폐기했다.

헤더의 width/height 는 실제 맵 크기다. 본문을 `(width * 2) * height` 또는
`width * (height * 2)` 단일 스트림으로 해석하는 후보는 2배 크기의 반복 맵을 만들어서
진단용으로만 둔다. 현재는 본문 앞 절반을 `layer0`, 뒤 절반을 `layer1` 로 보는 planar
2레이어 해석에 40열 선형 타일셋 매핑을 적용한다. 인접 u16 을 칸 단위 pair 로 보는 후보는
하나의 연속 맵을 좌우 반복 덩어리처럼 쪼개서 폐기했다.

필드 맵에서 `layer0` 은 시각 바닥 타일이고, `layer1` 은 시각 타일 ID 가 아니라 메타데이터다.
`layer1` 의 low 4비트는 방향별 통행/충돌 플래그다. high 비트 중 `0x20` 은 같은 칸의
`layer0` 타일 ID 를 `map_*2` 타일셋에서 다시 그려 캐릭터 위에 올리는 확정 foreground
플래그로 본다. `0x10` 은 캐릭터 발 위치에 따라 앞가림 여부가 달라지는 조건부
foreground 플래그이며, `0x40` 은 EXE에서 dirty redraw 대상으로 소비되는 animation redraw
플래그로 별도 추적한다. 현재 불꽃/폭포/물결은 palette-only 로는 움직임이 맞지 않으며,
0x40 dirty redraw 대상에 source-offset/row-cycle 같은 추가 animation stream 이 결합되는 구조로 보는 것이 더 맞다. 따라서
`layer1` 전체를 상층 타일처럼 그리는 방식은 기본 렌더링에서 폐기했다.

현재 기본 시각 렌더링은 맵 이름 접미사 기준 타일셋을 우선한다. 예를 들어
`map1_01a.cns` 는 `map_a1.cns`/`map_a2.cns`, `map1_02b.cns` 는
`map_b1.cns`/`map_b2.cns` 로 렌더링해야 동굴/폭포/다리 타일 의미가 맞는다.
`Hwanse2.exe` 의 장면 로딩 테이블에는 같은 맵을 다른 타일셋 묶음과 연결하는 항목도 있지만,
현재 확인한 일부 항목은 기본 preview 에서 타일 의미가 틀린다.
장면 스크립트의 리소스 로드 블록은 보통 field map record 뒤가 아니라 앞에 온다. 예를 들어
`0x00542ae8` leaf 에서는 `map_a1.cns`/`map_a2.cns` 로드 뒤에 `map1_01a.cns` record 가 나오고,
그 다음 `map_d1.cns`/`map_d2.cns`/`map_d3.cns` 로드 뒤에 `map2_02d.cns` record 가 나온다.
따라서 `tools/extract_scene_manifest.py` 는 앞쪽 리소스 블록을 우선 `resources`/`tilesets` 로 쓰고,
뒤쪽 블록은 `followingResources` 로 보존한다. 이 기준이 아니면 `map1_01a` 를 다음 장면의
`map_d*` 타일셋으로 잘못 묶는 off-by-one 오류가 생긴다.
같은 save-selector leaf 안의 branch dword 는 아직 맵 전환으로 보지 않는다. 현재 frontier 의
`0x00542b0c` branch 는 `secondaryBranchState[selectionBuffer[0x20]] == 1` 조건을 읽지만,
branch target 은 `map2_02d.cns` 가 아니라 `cara_01.cns` 리소스 문자열이다. `map2_02d` 는
뒤쪽 `0x00542bac` field map record 로 나타나므로, 이 패턴은 selector scene list/resource gate
근거로만 취급하고 strict event coordinate 또는 동등한 hotspot 이 나오기 전에는 전환으로 승격하지 않는다.
`tools/build_web_assets.py` 는 모든 `map[0-9]*.cns` 필드 맵 174개와 필요한 타일셋 PNG 를
자동 등록한다. 같은 맵 파일이 여러 타일셋 묶음으로 로드되는 케이스는
`sceneTilesetVariants` 메타데이터에 보존하며, 현재 웹 런타임의 variant 선택으로 즉시 비교할 수 있다.

일부 장면 레코드에는 `63` 센티널과 좌표 테이블 포인터가 이어진다. `tools/extract_scene_events.py`
는 이 패턴을 찾아 `out/scene_events.json` 으로 저장한다. 현재 확인된 `kind=93/94` 레코드는
16비트 `(x, y)` 타일 좌표 목록처럼 보인다. `extract_scene_events.py` 는 이제 `0` sentinel
까지 raw point table 을 읽고, 그중 현재 맵 크기 안에 들어오는 좌표만 `points` 로 overlay한다.
예를 들어 `map5_38i` 는 raw 24개 중 6개만 맵 내부 좌표다. 같은 raw 좌표 목록이 여러 장면에서
반복되므로, 아직 맵 전환 지점으로 단정하지 않고 hotspot/event 후보로만 취급한다.
웹 프로토타입은 이 좌표 후보가 있는 맵들을 맵 선택 목록에 포함하고, `?events=1` 오버레이로
직접 위치를 확인할 수 있게 한다.
좌표 테이블 뒤의 dword 들은 `tailDwords` 로 함께 저장한다. 여기에는 추가 좌표처럼 보이는 값,
후속 데이터 포인터, ASCII/CNS 문자열 후보가 섞인다. 이 tail 은 hotspot 이 실제로 전환인지
대화/스프라이트 이벤트인지 판별하기 위한 다음 분석 대상이다.
포인터가 확인된 tail 항목은 `tailPointerBlocks` 에 첫 16개 dword 를 함께 저장한다. 관찰상
`kind=93/94` 레코드의 좌표처럼 보이는 값들은 여러 맵에서 같은 `(11, 10..45)` 패턴으로
반복되며 뒤쪽에는 `0x3f`, `0x42`, 포인터 배열, 스프라이트 파일명 인근 데이터가 이어진다.
따라서 현재는 전환 트리거로 확정하지 않고 장면 오브젝트/리소스 인자 후보로 본다.
`tools/dump_scene_event_record.py --record-va 0x00503350` 처럼 개별 레코드의 raw dword,
raw/in-bounds 좌표, tail pointer block 을 사람이 읽기 좋은 형태로 덤프할 수 있다.
`tools/probe_exe_pointer_refs.py` 는 `.text`/`.rdata`/`.data` 안에서 특정 VA 또는 VA 범위를
가리키는 포인터를 찾는다. `kind=93/94` 레코드는 레코드 시작 주소 자체보다 `record + 0x0c`
(`eventDispatchVa`)를 가리키는 `.data` 엔트리가 관찰된다. 예를 들어 `map5_38i`
`0x00449444` 레코드는 `0x00449450` dispatch 주소로 12개 `.data` 참조가 있고,
`map1_02b` `0x00503350` 레코드는 `0x0050335c` dispatch 주소로 2개 참조가 있다.
`extract_scene_events.py` 는 이 역참조를 `eventDispatchRefs` 로 함께 저장한다.
각 역참조의 직전 dword 가 조건/selector block 포인터처럼 보이는 경우가 많아
`conditionVa` 와 첫 8개 dword 도 함께 저장한다. 이 조건 블록의 두 번째 dword 가 payload
포인터일 때는 payload 첫 24개 dword 와 그 안의 CNS 문자열을 `conditionPayloadDwords`,
`conditionLinkedStrings` 로 함께 저장한다. 이 문자열에는 `map_*.cns`, `map[0-9]*.cns`,
`cara_*.cns` 등이 포함되어 장면 로드/전환 후보를 좁히는 근거가 된다.
`tools/summarize_scene_dispatch_refs.py` 는 이 내용을 `out/scene_dispatch_summary.md` 로 요약한다.
`tools/summarize_scene_links.py` 는 이 문자열 중 필드 맵 링크만 따로 모아
`out/scene_links_summary.md`, `out/scene_links.json`, `out/scene_links.js` 에 장면 연결 후보
그래프로 요약한다. 웹 프리뷰는 이 JS를 읽어 현재 맵의 연결 후보 선택 목록을 표시한다.
`tailPointerBlocks.blockKind` 는 첫 dword 기반의 임시 분류다:
`selector(0x3f)`, `pointerTable(0x42)`, `scriptHeader(0x08)`, `scriptEntry(0x01)`,
`prefixedSelector(0x19)`, `scriptLike(0x03/0x16d)`. 이 이름은 아직 포맷 확정이 아니라
반복 구조를 비교하기 위한 라벨이다.
포인터 블록 안에서 발견된 printable 문자열은 `linkedStrings` 에 모아 둔다. 현재는
`zjk_suz.cns`, `zi_ana.cns`, `zsa_iwa.cns` 같은 스프라이트/오브젝트 리소스명이 주로 나오며,
웹 디버그 HUD 에서 `links ...` 로 표시한다. 대응 PNG 는 `out/object_assets.js` 로 노출되고,
웹 `events=1` 오버레이는 이 이미지를 active point 위에 후보 preview 로 그린다. 이 preview 는
원본 object/NPC descriptor 실행이 아니라 리소스와 좌표 후보를 검토하기 위한 표시다.
`tools/probe_scene_coordinate_candidates.py` 는 이 sentinel 패턴에 한정하지 않고 장면 레코드의
여러 dword 필드에서 좌표 테이블처럼 읽히는 포인터를 넓게 찾는다. 현재 101개 레코드가 잡히지만
대부분 `(11, 10..)` 반복 패턴이라, 전환 좌표가 아니라 스크립트/오브젝트 인자일 가능성이 높다.

## 2. PCMDATA.WLK — 효과음 (PCM)

```
magic   "WLKF0200"              (8 bytes)
count   u16                     (54)
flags   u16                     (archive flags, 현재 0)
entries count * 22 bytes:
    u8   status             (관찰값 0xFF)
    u8   flags              (bit5=loop playback, bit6=stereo, bit7=16-bit signed PCM;
                             bit7 clear=8-bit unsigned PCM)
    u32  offset             absolute
    u32  size               (bytes)
    u32  sample_rate        (0x2B11=11025, 0x5622=22050, 0xAC44=44100)
    i32  gain               (0, -200, -500, -700, -800, -1200, -1800)
    u32  reserved (=0)
body    raw PCM
```

현재 `PCMDATA.WLK` 분포는 flags `0x00` 43개, `0x20` 4개, `0x80` 7개다. `Hwanse2.exe` 의 WLK loader/playback path 는 `0x42a5bb` 에서 entry 를 `status/flags/offset/size/rate/gain` 으로 읽고, `0x42af73` 의 DirectSound buffer 생성에서 `0x80` 을 `wBitsPerSample=16`, `0x40` 을 `nChannels=2`, `0x20` 을 `IDirectSoundBuffer::Play(..., DSBPLAY_LOOPING)` 으로 쓴다. 따라서 `0x80` entry 는 WAV 로 쓸 때 sample width 2를 사용해야 하며, 모든 추출 결과는 raw PCM 데이터를 그대로 WAV 컨테이너에 넣는다. gain 값은 archive metadata 로 보존하고 raw WAV payload 에 임의 적용하지 않는다.

`out/audio_archive_manifest.*` 는 사용자가 검토한 WLK 54개 표와 MLK 20개 표를 원본 archive table 에 대조한 뒤, active 번호 체계를 EXE zero-based id 로 고정한다. 웹/런타임 파일명은 `extract_wlk/00.wav`..`53.wav`, `extract_mlk/00.mid`..`19.mid` 이며 WLK id `45` 는 `extract_wlk/45.wav` 를 직접 가리킨다. `web/audio_review.html` 은 별도 probe나 audition preview 를 복원 계약에 포함하지 않는다.

추출기: `tools/extract_wlk.py` (직접 .wav 로 변환).

## 3. MIDDATA.MLK — BGM (Standard MIDI)

```
count   u8                      (20). ASCII magic 없음.
entries count * 9 bytes:
    u8   flag                   (1 또는 0; 의미 미상, 모두 정상 MIDI)
    u32  offset
    u32  size
body    Standard MIDI Format 0 (각 페이로드는 "MThd" 로 시작)
```

추출기: `tools/extract_mlk.py`. 브라우저 재생 검증은 `web/engine/audio/midi_bgm_player.js` 의 SMF 파서와 WebAudio GM Lite baseline, 선택형 SF2/SF3/Web MIDI 경로, Web Audio lookahead 스케줄링을 기준으로 한다.
`out/audio_title_provenance.*` 는 `PCMDATA.WLK` 에 effect title field 가 없고,
`MIDDATA.MLK` 는 각 MIDI payload 의 track-name meta event 가 있는 항목만 embedded title 을
갖는다는 legacy aggregate workbench 라벨 출처를 보존한다.

## 다음 단계 (웹 포팅용)

- [x] 3종 아카이브 컨테이너 포맷 확정 및 추출기 작성
- [x] WLK→WAV, MLK→MID 무손실 변환 완료 (브라우저에서 바로 사용 가능)
- [x] `.cns` 압축 해제 구현 (`tools/decode_cns.py`)
- [x] 팔레트 추출 → 첫 화면(title.cns / icon.cns 등) PNG 디코드 검증
- [x] 맵 타일셋과 맵 레이아웃을 Canvas 에 렌더
- [ ] 충돌/트리거/이벤트 데이터 위치 식별
- [ ] 원본 실행 흐름 분석 (`Hwanse2.exe` 디스어셈블; DirectDraw/DInput 호출 패턴은 기초 추적 완료, 이벤트 호출 패턴은 계속 진행)

### 한글 텍스트 후보

`tools/summarize_korean_text_candidates.py` 는 `Hwanse2.exe` 의 `.rdata`/`.data` 섹션에서
CP949 로 해석 가능한 한글 문자열 후보를 찾아 `out/korean_text_candidates.*` 로 요약한다.
각 row 는 문자열 VA, 섹션, 짧은 분류(`label`, `dialogue-like`, `system-ui`)와 `.text`/`.rdata`/
`.data` 에서 해당 VA 를 직접 가리키는 dword 참조를 포함한다. 또한 `.data` 의 문자열 포인터
테이블 cluster 를 묶고, 그 cluster 주변 주소를 immediate 로 참조하는 `.text` 후보를 함께
표시한다. `.text` 참조에는 주변 코드 바이트와 근처 `call rel32` 대상도 붙여 다음 분석에서
텍스트 표시/메뉴 루틴 후보를 좁힐 수 있게 한다. 현재 결과는 아이템/기술/시스템 문자열 위치와
그 테이블을 읽는 코드 후보를 안정적으로 잡지만, 이것은 대화 시스템 구현이 아니라 텍스트 표시
핸들러와 이벤트 스크립트 opcode 를 잇기 위한 색인이다.

`tools/summarize_text_tables.py` 는 위 후보 색인에서 웹 런타임이 바로 쓸 수 있는 이름 테이블만
따로 뽑아 `out/text_tables.json`, `out/text_tables.js`, `out/text_tables.html` 을 만든다.
현재 `0x0048b984` 아이템명 테이블은 `savedat*.dat` 의
여섯 개 아이템 카운트 오프셋(`0x0015..0x001f`)과 순서 매칭해 `/web/index.html` 저장 데이터
표시에도 사용한다. 나머지 장비/기술명 테이블은 메뉴와 전투 핸들러 매핑 전까지 후보 테이블로
보존한다.

`tools/summarize_cns_text_candidates.py` 는 `extract_fld/*.cns` 를 CNS 압축 해제한 뒤
plain CP949 한글 문자열 후보를 `out/cns_text_candidates.*` 로 스캔한다. 이 결과는 EXE
문자열 색인에서 빠진 스토리 대사가 CNS 페이로드 안에 직접 들어 있는지 확인하기 위한 음성/양성
증거다. 후보가 없거나 매우 적으면 대사가 단순 null-delimited CP949 로 저장된 것이 아니라
별도 스크립트 인코딩이나 EXE 쪽 테이블/루틴을 통해 만들어질 가능성이 높다.

`tools/summarize_scene_event_text_refs.py` 는 현재 추출된 `out/scene_events.json` 의 event
record, dispatch, condition, payload 주변 dword window 를 EXE 한글 문자열 후보 및 문자열
포인터 테이블 cluster 와 교차 확인해 `out/scene_event_text_refs.*` 로 기록한다. 이 리포트는
현재 hotspot/전환 event record 가 대사 포인터를 직접 들고 있는지, 아니면 이벤트 VM opcode 나
별도 텍스트 루틴 추적이 필요한지를 구분하는 게이트다.

`tools/summarize_text_routine_callers.py` 는 `out/korean_text_candidates.json` 에서 뽑힌
텍스트 루틴 후보들의 전체 `call rel32` caller 를 instruction-aligned `objdump` direct call 기준으로 다시 스캔해 `out/text_routine_callers.*`
로 묶는다. caller 주변의 문자열/문자열 테이블 포인터를 함께 확인해 현재 직접 호출 문맥이
시스템 UI, 아이템/메뉴, 전투 액션명 중 어디에 가까운지 분류하고, 스토리 대화 caller 가 직접
확인됐는지를 별도 게이트로 남긴다.

`tools/summarize_event_handler_text_refs.py` 는 event/object VM handler table 의 각 handler
range 를 `out/korean_text_candidates.json` 및 텍스트 루틴 caller 후보와 교차 확인해
`out/event_handler_text_refs.*` 로 기록한다. 현재 opcode `0x0b` handler 는 `0x0041b579`
텍스트 루틴 후보를 직접 호출하지만 handler body 안에 정적 한글 문자열/테이블 포인터가 없고,
opcode `0x31`/`0x36`/`0x38` 의 정적 참조는 아이템/액션명 테이블 쪽이다. 따라서 이것도 아직
스토리 대화 opcode 구현 근거가 아니라 다음 이벤트 VM 추적 게이트로 둔다.

`tools/summarize_event_text_source_flow.py` 는 event/object VM 의 텍스트 관련 opcode 흐름을
`out/event_text_source_flow.*` 로 고정한다. opcode `0x0b` 는 `context+0x28` 을 텍스트 루틴
후보 `0x0041b579` 에 넘기고, opcode `0x0d` 는 `stream+4` 의 dword 를 현재 `context+0x28`
로 설정한 뒤 low 16-bit index 로 런타임 테이블 `0x55b134` 를 조회한다. opcode `0x0c` 는
child object 의 `context+0x28` 을 `stream+0x0c` 에서 채우는 handler 형태지만, 현재 정적 scan
에서 잡히는 3건은 모두 aligned pointer byte overlap 이며 confirmed `map1_02b -> map1_01a`
condition block 안의 2건도 `0x00500c40` 포인터 바이트 겹침이다. 이 리포트는 EXE event stream
주변에서 스토리풍 CP949 문장을 찾지만, 아직 route-linked dialogue playback proof 는 아니다.
`tools/summarize_event_vm_opcode_semantics.py` 는 같은 근거를 `out/event_vm_opcode_semantics.*`
및 `out/event_vm_opcode_semantics.html`
로 정리해 `0x0b` length 4, `0x0c` length `0x14`, `0x0d` length 8 과 operand/effect 를
검증 가능한 opcode semantics row 로 고정하고, handler `0x0041b771` 근거의 `0x02` cursor/separator
length 4/effect 도 별도 row 로 보존한다. 같은 report 는 handler table/disassembly 로 확인한
`0x03`/`0x04`/`0x06`/`0x07`/`0x08`/`0x09`/`0x0a`/`0x0e`/`0x15`/`0x1b`/`0x37` control/display opcode 도 일부 length/effect row 로
고정한다. 이 산출물은 text-source opcode subset, cursor/separator trace, 일부 wait/position/style/
cursor/data-bank/control-flow handler semantics 를 좁히는 자료이며, non-text opcode 전체 instruction length/operand
layout 또는 route-linked event VM 실행 증거로는 승격하지 않는다.

`web/format_review.html` 의 `event VM promotion boundary` 표는 같은 자료를 완료 판정 기준과
분리해서 보여준다. 브라우저 partial known-opcode replay 는 branch step/render/wait/style/cursor
적용 증거로 `partial` 이지만, `fullInstructionLengthDecode`, `fullOperandLayoutDecode`,
`routeLinkedEventVmExecution`, `sceneEventDialogueBinding`, `originalStoryFlagMutation`,
`fullBrowserEventVmImplementation`, `eventDrivenBattleEntry` 7개 gate 는 모두 `missing` 으로 유지한다.
따라서 이 표는 바이너리/VM 포맷 진행 상황을 좁히기 위한 경계 자료이며 원본 event VM runtime
구현 완료나 route promotion proof 로 쓰지 않는다.

`tools/summarize_event_dialogue_blocks.py` 는 위 흐름 중 opcode `0x0d` source command 를
기준점으로 삼아 주변 CP949 대사 후보, `0x02` cursor/separator command, `0x0b` render command,
`0x35` literal text payload marker 를 `out/event_dialogue_blocks.*` 로 묶는다. 현재 0x0d-anchored block index 는 다음
대화창 재생 구현의 저장소 후보 자료이며, `out/event_dialogue_blocks_runtime.js` 는 웹 런타임이
요청 시 늦게 불러오는 축약본이다. 각 block `vmTrace` 는 partial replay opcode `0x02`/`0x0d`/`0x0b`/`0x35`,
`literalTextEvents`, 그리고 handler-grounded control/display opcode `0x03`/`0x04`/`0x06`/`0x07`/`0x08`/
`0x09`/`0x0a`/`0x0e`/`0x15`/`0x1b`/`0x37` 의 `groundedControlEvents` 를 보존한다. 현재 런타임 데이터에는
526개 cursor/separator trace, 249개 literal payload, 1007개 grounded control/display metadata 가 있으며
control/display metadata 는 `browserExecutesEffect=false` 로 유지한다. 이 자료와 `/web/game.html?dialogue=1` 의
대화 후보 재생은 route-linked event 실행 증거나 웹 event VM 구현 완료 판정으로 쓰지는 않는다.

`tools/summarize_save_point_candidates.py` 는 원본 저장이 여관/석상/회복 NPC 같은 event
save point 에서만 가능했다는 플레이어 기억을 기준으로 `out/event_dialogue_blocks.*`,
`out/korean_text_candidates.*`, `out/text_tables.*` 를 다시 분류해
`out/save_point_candidates.*` 와 웹 런타임 축약본 `out/save_point_candidates_runtime.js` 를 만든다.
이 리포트는 `데이터 저장`/`데이터 로드` 같은 system/menu 텍스트와 여관/회복/석상 후보 문맥을
분리한다. `/web/index.html` 의 메뉴 `세이브 후보` 는 이 축약본을 읽기 전용 후보 검토 대화로만
보여준다. 현재 exact save-point script proof 는 없으며, 웹의 browser-local `임시 저장` 은 원본
save point 근거가 아니다.

### 확인된 맵 레이아웃

`map1_01a.cns`, `map1_02b.cns`, `btl_a1.cns` 등 일부 파일은 이미지가 아니라 CNS 압축을 푼 뒤 다음 구조를 가진다.

```
u16 width_in_tiles
u16 height_in_tiles
u16 layer0[width * height]
u16 layer1[width * height]
```

`tools/classify_cns_payloads.py` 기준 전체 377개 CNS 중 177개는 이미지, 200개는 타일맵이다.
필드 맵은 `map[0-9]*.cns` 174개이고, 전투 배경 `btl_*` 타일맵은 26개다.
`map0_01n.cns` 만 현재 `u8 width, u8 height` 의 2바이트 compact header 변종으로 확인된다.

`tools/export_map_js.py` 는 확인된 맵 레이아웃을 브라우저에서 바로 읽을 수 있는 `out/maps.js` 로 변환한다.
`tools/build_web_assets.py` 는 현재 웹 프로토타입에 필요한 PNG 와 174개 필드 맵 JS 를 한 번에 생성한다.
`tools/render_map_preview.py` 는 웹 런타임과 같은 레이어별 타일셋 메타데이터로 확인용 PNG 를 만든다.
타일셋 투명색은 계열마다 달라서 각 타일셋 이미지의 좌상단 픽셀을 키 컬러로 사용한다.
필드 충돌은 `layer1` low 4비트로 판정한다. 밝기 기반 `terrainBlockedTiles` 나
`layer0 > 1 && layer1 == 0` 식의 예전 통행 판정은 기본 규칙에서 제외했다. `?collision=1`
또는 `C` 키 오버레이는 현재 low-bit 충돌과 `0x20` foreground, `0x10` 조건부 foreground,
`0x40` animation redraw 영역을 확인하는 용도다.
수동 가장자리 `transitions` 는 원본 이벤트/스폰 좌표를 확인하기 전까지 제거했다.

### 실행파일 내 CNS 파일명 그룹

`Hwanse2.exe` 안에는 NUL 종료 CNS 파일명 배열이 여러 개 들어 있다. `tools/probe_exe_cns_groups.py`
는 이 배열을 gap 기반으로 묶어서 출력한다. 이 그룹은 맵이 어떤 타일셋/스프라이트 묶음과 함께
로드되는지 추정하는 근거로 사용할 수 있다.

예:

```
python3 tools/probe_exe_cns_groups.py --contains map1_01a
```

확인된 주요 그룹:

- offset `0x3a044`: `map_a1`, `map_a2`, `map_b1`, `map_b2`, `map_b3`, `map_f*`, `map_h*`
  타일셋과 `map1_01a`, `map1_02b`, `map5_07h`, `map7_02f`, `map7_03h`, `cara_sm1` 등이 같은 배열에 있다.
- offset `0xfec4c`: `map_a1`, `map_a2`, `map_b1`, `map_b2`, `map_b3`, `map1_01a`, `map1_02b`,
  `cara_at2`, `face_01`, `cara_01`, `cara_02`, `cara_efc`, `cara_etc`.
- offset `0x474e8`: `map_a*`, `map_d*`, `map_g*`, `map_h*`, `map_j*` 타일셋과
  `map2_02d` ... `map2_18d`, `map3_08j`, `map3_09a`, `map3_10j` 가 같은 배열에 있다.
