From 643a462af409d2de5a4fbcf197f2f22abf7a8bd3 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Thu, 23 Jul 2026 08:40:22 -0400 Subject: [PATCH] Decode STINIT enemy spawn semantics --- docs/global-reference.md | 32 ++++---- docs/name-resolution.md | 31 +++++-- docs/phase-b-framework.md | 11 ++- docs/script-inventory.md | 5 +- docs/tools-reference.md | 4 +- docs/vm-mapping-plan.md | 2 +- tools/extract_init.py | 103 +++++++++++++++++++++-- tools/test_extract_init.py | 33 ++++++++ vm-map/globals.toml | 164 +++++++++++++++++++++++++++++++++++++ 9 files changed, 344 insertions(+), 41 deletions(-) diff --git a/docs/global-reference.md b/docs/global-reference.md index 9347ba3..494def4 100644 --- a/docs/global-reference.md +++ b/docs/global-reference.md @@ -1,7 +1,7 @@ # Global Variable Reference (generated) -5030 globals (167 curated, 4863 auto shape-inferred). Source of truth: `vm-map/globals.toml`. +5030 globals (181 curated, 4849 auto shape-inferred). Source of truth: `vm-map/globals.toml`. ## choice-output @@ -115,6 +115,20 @@ | `0xe7483` | stage_object_difficulty_mask | high | investigation | Per-object difficulty inclusion mask. SETOBJ uses check-bit with difficulty_index and rejects the object slot when the selected difficulty bit is absent. | | `0xe74b5` | stage_object_required_story_flags | high | investigation | Seven positive prerequisites per stage object. SETOBJ subtracts one from every populated id and suppresses the object unless the corresponding story_event_flags cell equals 1. Columns: 0=required_flag_1, 1=required_flag_2, 2=required_flag_3, 3=required_flag_4, 4=required_flag_5, 5=required_flag_6, 6=required_flag_7. | | `0xe7613` | stage_object_forbidden_story_flags | high | investigation | Five negative prerequisites per stage object. SETOBJ subtracts one from every populated id and suppresses the object when the corresponding story_event_flags cell equals 1. Columns: 0=forbidden_flag_1, 1=forbidden_flag_2, 2=forbidden_flag_3, 3=forbidden_flag_4, 4=forbidden_flag_5. | +| `0xe773f` | stage_enemy_spawn_tile_x | high | investigation | Direct X coordinate for a current-stage enemy slot. FIELD copies it into the runtime unit position when stage_enemy_spawn_object_slot is zero; object-linked alternatives instead inherit the referenced object's coordinates. | +| `0xe775d` | stage_enemy_spawn_tile_y | high | investigation | Direct Y coordinate paired with stage_enemy_spawn_tile_x. FIELD and ADDEN copy the pair into the runtime unit position; object-linked alternatives instead inherit the referenced object's coordinates. | +| `0xe777b` | stage_enemy_spawn_object_slot | high | investigation | Optional object-placement anchor for an enemy spawn. FIELD resolves nonzero entries through stage_object_type_id and uses the matching stage_object_tile_x/Y location; zero selects stage_enemy_spawn_tile_x/Y directly. | +| `0xe7799` | stage_enemy_faction_id | high | investigation | Per-spawn faction/team selector. FIELD and ADDEN copy it into the runtime unit's faction field, use it to choose the entity-index band, and test the same value against map occupancy masks. | +| `0xe77b7` | stage_enemy_difficulty_mask | high | investigation | Per-spawn difficulty inclusion mask. FIELD uses check-bit with difficulty_index and rejects a slot when the selected difficulty bit is absent. | +| `0xe77f3` | stage_enemy_random_selection_weight | high | investigation | Weight for stage-enemy alternatives. FIELD groups eligible nonzero-weight slots by their stage_enemy_spawn_object_slot, sums the weights, makes a weighted random choice within each eligible group, and directly materializes zero-weight slots. | +| `0xe7811` | stage_enemy_unit_id | high | investigation | Unit-definition id for each current-stage enemy slot. FIELD rejects zero slots, while FIELD and ADDEN pass populated ids to SETEN to copy the corresponding EBINIT definition into a runtime entity. | +| `0xe782f` | stage_enemy_min_level | high | investigation | Scenario-specific minimum level for each stage enemy. After applying party-level auto-scaling, SETEN raises the runtime level to this floor before applying the maximum-level clamp. | +| `0xe784d` | stage_enemy_max_level | high | investigation | Scenario-specific maximum level for each stage enemy. SETEN uses a positive value as the upper clamp unless the debug/config override is active; otherwise it falls back to the unit definition's unit_level_cap. | +| `0xe786b` | stage_enemy_auto_level_scale_divisor | high | investigation | Scenario override for enemy auto-level scaling. SETEN divides the positive gap between the scenario base level and unit_starting_level by this value before adding it to the runtime level; zero would fall back to unit_auto_level_scale_divisor. | +| `0xe7889` | stage_enemy_movement_routine_set_ids | high | investigation | Three difficulty-specific movement-AI routine-set ids per stage enemy. SETEN copies the row into the runtime unit, and MVRTN selects the difficulty_index column as its movement routine table row. Columns: 0=difficulty_0, 1=difficulty_1, 2=difficulty_2. | +| `0xe78e3` | stage_enemy_battle_routine_set_ids | high | investigation | Optional three-column battle-AI routine override per stage enemy. SETEN copies a populated row into the runtime unit or falls back to the unit definition, and BTRTN selects the difficulty_index column as its battle routine table row. Columns: 0=difficulty_0, 1=difficulty_1, 2=difficulty_2. | +| `0xe793d` | stage_enemy_required_story_flags | high | investigation | Seven positive prerequisites per stage enemy. FIELD subtracts one from every populated id and suppresses the spawn unless the corresponding story_event_flags cell equals 1. Columns: 0=required_flag_1, 1=required_flag_2, 2=required_flag_3, 3=required_flag_4, 4=required_flag_5, 5=required_flag_6, 6=required_flag_7. | +| `0xe7a0f` | stage_enemy_forbidden_story_flags | high | investigation | Five negative prerequisites per stage enemy. FIELD subtracts one from every populated id and suppresses the spawn when the corresponding story_event_flags cell equals 1. Columns: 0=forbidden_flag_1, 1=forbidden_flag_2, 2=forbidden_flag_3, 3=forbidden_flag_4, 4=forbidden_flag_5. | | `0x5` | — | low | auto-shape | array | | `0xd2` | — | low | auto-shape | array | | `0xd7` | — | low | auto-shape | array | @@ -265,17 +279,7 @@ | `0xe741f` | — | low | auto-shape | array | | `0xe7451` | — | low | auto-shape | array | | `0xe770d` | — | low | auto-shape | array | -| `0xe773f` | — | low | auto-shape | array | -| `0xe775d` | — | low | auto-shape | array | -| `0xe777b` | — | low | auto-shape | array | -| `0xe7799` | — | low | auto-shape | array | -| `0xe77b7` | — | low | auto-shape | array | | `0xe77d5` | — | low | auto-shape | array | -| `0xe77f3` | — | low | auto-shape | array | -| `0xe7811` | — | low | auto-shape | array | -| `0xe782f` | — | low | auto-shape | array | -| `0xe784d` | — | low | auto-shape | array | -| `0xe786b` | — | low | auto-shape | array | | `0xe7e8d` | — | low | auto-shape | array | | `0xe8275` | — | low | auto-shape | array | | `0xebd0d` | — | low | auto-shape | array | @@ -367,6 +371,7 @@ | `0xa6e59` | current_skill_id | high | investigation | Shared skill-id argument/selection slot. Skill menus and combat scripts write the chosen skill id and use it to index SKINIT arrays; SKMES compares it against all 131 skill ids to select the matching player-facing title and description. | | `0xeff75` | current_entity_index_hi | med | inference | High-purity current-entity row index (purity 0.95 in the auto shape map); dominant 2D-table row selector. | | `0x152616` | current_entity_index | med | investigation | Primary current-entity row index (RECOVER-confirmed; purity 0.51, 363 row-index uses). | +| `0x152617` | current_stage_enemy_spawn_slot | high | investigation | Current STINIT enemy-template slot. FIELD selects slots 1..29 while materializing stage units, SETEN records the slot on the runtime entity and reads every parallel enemy buffer through it, and ADDEN uses slot 0 for its special generated unit. | ## story-flag @@ -412,7 +417,6 @@ | `0xcc9f3` | — | med | auto-shape | TODO: confirm. Branch-read in 0 scenes / 6 scripts; compared against []; writers=['CALCSCOPE.BIN']. | | `0xccc21` | — | med | auto-shape | TODO: confirm. Branch-read in 0 scenes / 4 scripts; compared against [0]; writers=['CHMENU.BIN']. | | `0xeff77` | — | med | auto-shape | TODO: confirm. Branch-read in 0 scenes / 3 scripts; compared against [0, 2, 3]; writers=['BTRTN.BIN', 'MVRTN.BIN', 'RTN_B001.BIN', 'RTN_B002.BIN']. | -| `0x152617` | — | med | auto-shape | TODO: confirm. Branch-read in 0 scenes / 4 scripts; compared against [0]; writers=['CLOSE.BIN', 'FIELD.BIN', 'INFOEN.BIN', 'READY.BIN']. | | `0x1560e7` | — | med | auto-shape | TODO: confirm. Branch-read in 0 scenes / 4 scripts; compared against [0, 1, 2, 3, 4, 5, 6, 7]; writers=['MAGIC.BIN', 'SELACT.BIN', 'SUMMON.BIN']. | | `0x15a094` | — | med | auto-shape | TODO: confirm. Branch-read in 0 scenes / 6 scripts; compared against [0]; writers=['ALCHEMY.BIN', 'EVOLVE.BIN', 'SELSTAGE.BIN', 'STUDY.BIN']. | | `0x15a095` | — | med | auto-shape | TODO: confirm. Branch-read in 0 scenes / 6 scripts; compared against [0]; writers=['INFO.BIN', 'INFOCH.BIN', 'MENU.BIN']. | @@ -5019,10 +5023,6 @@ | `0xe6afe` | — | med | auto-shape | record-table[stride 10] | | `0xe6fe2` | — | med | auto-shape | record-table[stride 3] | | `0xe71d6` | — | med | auto-shape | record-table[stride 3] | -| `0xe7889` | — | med | auto-shape | record-table[stride 3] | -| `0xe78e3` | — | med | auto-shape | record-table[stride 3] | -| `0xe793d` | — | med | auto-shape | record-table[stride 7] | -| `0xe7a0f` | — | med | auto-shape | record-table[stride 5] | | `0xe865d` | — | med | auto-shape | record-table[stride 7] | | `0xea1b5` | — | med | auto-shape | record-table[stride 7] | | `0xee035` | — | med | auto-shape | record-table[stride 3] | diff --git a/docs/name-resolution.md b/docs/name-resolution.md index 925e946..c48ef03 100644 --- a/docs/name-resolution.md +++ b/docs/name-resolution.md @@ -239,7 +239,7 @@ fallbacks are deliberately limited to the still-unresolved voice reactions and S three fields remain wholly anonymous (`0x7843e` and two suspicious sparse writes into runtime table `0x4e693/300`). -### STINIT mixed stage records (2026-07-22) +### STINIT mixed stage records (2026-07-23) STINIT is not a name table. Its preamble allocates 29 fixed global work buffers, then 74 sparse branches compare `scjump_progress_a` with stage ids 1 through 170. Each selected branch populates the same current- @@ -271,14 +271,29 @@ type id. SETOBJ tests the `{3,4,7}` masks in `0xe7483` against GAMESTART's three then applies seven required and five forbidden one-based ids from `0xe74b5`/`0xe7613` against the shared `story_event_flags` bank. +The enemy pass follows the separate 30-cell family through FIELD, SETEN, ADDEN, MVRTN, and BTRTN. Slot +zero is reserved for ADDEN's synthesized special-unit path; the stage table populates slots 1 through 29. +`0xe7811` selects the EBINIT unit, `0xe7799` is its faction, `0xe773f`/`0xe775d` are direct tile +coordinates, and `0xe777b` optionally anchors the unit to a stage-object slot. FIELD checks the +three-bit difficulty mask in `0xe77b7`, uses `0xe77f3` as a weighted-random alternative value, and applies +the seven required plus five forbidden story flags in `0xe793d`/`0xe7a0f`. SETEN proves `0xe782f`, +`0xe784d`, and `0xe786b` are the scenario level floor, cap, and party-level scaling divisor. Finally, +the three-value footer rows in `0xe7889` and optional `0xe78e3` become difficulty-specific movement and +battle routine-set ids selected by MVRTN/BTRTN. + Generated INIT records now retain their raw `fields`/`record_fields`/buffer keys and additionally expose a flat `semantic_fields` projection joined through the top-level `field_semantics` map. For STINIT, the four confirmed parallel buffers plus both prerequisite tables are also assembled into 2,312 `object_placements` across 66 stages. Each placement contains its slot, type, tile coordinates, difficulty mask, and populated positive/negative story prerequisites. The four still-unresolved object-parameter buffers remain attached under `unknown_fields`, so this convenience view loses no evidence or invents names. -The next structural batch is the separate 30-slot enemy-spawn schema and its difficulty-dependent footer -arrays. +The same records now contain 1,378 joined `enemy_spawns` across 66 stages, with unit/faction, direct or +object-linked placement data when present, difficulty and story gates, level rules, random-selection +weight, and movement/battle routine rows. Raw footer metadata stays in `footer_arrays`, while its +`semantic_fields` value is the copied row itself. The one remaining spawn-mode cell (`0xe77d5`, always 2 +when populated) stays under `unknown_fields`: FIELD ties it to already-cleared-stage state, but the precise +player-facing nuance is not yet strong enough to name. The next STINIT batch is the four type-specific +object parameters plus that isolated spawn-mode gate. ### The curated registry — `vm-map/globals.toml` (2026-07-07) @@ -325,11 +340,11 @@ are *not* story flags — the miner over-tags them; they are recategorized `unkn The v1 map labels *shapes and tables*; the next increments add *meaning*, cheapest first: 1. **Continue INIT semantics by evidence density.** ITINIT/SKINIT, the confirmed EBINIT row layouts, and - STINIT's mixed stage records now have machine-readable investigation surfaces. Next assemble STINIT's - 30-slot enemy-spawn schema through FIELD/SETEN/ADDEN, including its three-difficulty footer arrays; trace - the four remaining specialized object parameters only when their type-specific branches distinguish - them. Isolate EBINIT's remaining voice/action slots under the same rule. Preserve explicit joins and do - not infer meaning from column position alone. + STINIT's mixed stage records now have machine-readable investigation surfaces, including joined object + placements and enemy spawns. Next trace STINIT's four remaining specialized object parameters and its + isolated spawn-mode gate only where type-specific/runtime branches distinguish them. Isolate EBINIT's + remaining voice/action slots under the same rule. Preserve explicit joins and do not infer meaning from + column position alone. 2. **Extend message-table joins beyond the completed ITMES/SKMES pair** (`VIMES`, other id dispatchers, …) and fold in other `set-string`/`copy-to-global` writers not covered by the `*INIT` set. 3. **Label 2D record tables by their readers** — cross-reference which scripts read each diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 2fe94d9..e08f029 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -749,11 +749,14 @@ The remaining EBINIT unknowns are now the unread `0x7843e` enum and the signed m enemy AI appears to live outside the static EBINIT schema. STINIT's separate mixed parser is now complete: all 74 sparse stage ids retain their victory/defeat strings, six scalars, fixed-buffer cells, and 1,396 footer-array copies. AIM/FIELD/DRAWCHP consumers establish the four condition slots, stage BGM, turn limit, -and turn-limit outcome. The next pass establishes target clear turns, performance reward, replay behavior, +and turn-limit outcome. Further passes establish target clear turns, performance reward, replay behavior, the 20-slot map-texture override list, object type/tile/difficulty fields, and seven required plus five forbidden story prerequisites. Generated records now carry a joined `semantic_fields` view and assemble 2,312 object placements across 66 stages while retaining four unresolved type-specific parameters by raw -address. The separate 30-slot enemy-spawn family remains the next evidence-driven batch. +address. The separate 30-cell enemy family is also decoded: FIELD/SETEN/ADDEN establish unit/faction, +direct or object-linked placement, difficulty/story gates, level floor/cap/scaling, weighted selection, +and three difficulty-specific movement/battle routine-set ids. The generated view assembles all 1,378 +populated enemy slots across 66 stages; only one replay-linked spawn-mode field remains raw. Once the natural spine and first gameplay loop are trustworthy, broaden in independent tracks: @@ -763,8 +766,8 @@ Once the natural spine and first gameplay loop are trustworthy, broaden in indep - Unit statistics, equipment, inventory, skills, magic, heroine forms, and progression. - Combat resolution presentation, enemy turns/AI services, and win/loss transitions. - A full chapter of ADV and the scene types encountered between gameplay segments. -- Remaining STINIT enemy-spawn and specialized object-parameter semantics, plus other data schemas when their - runtime consumers make them necessary. +- Remaining STINIT specialized object-parameter semantics and the isolated spawn-mode gate, plus other data + schemas when their runtime consumers make them necessary. The high-level Phase B direction remains canonical in `docs/remake-architecture-and-roadmap.md`; this file only provides the execution framework. diff --git a/docs/script-inventory.md b/docs/script-inventory.md index af46602..5d84405 100644 --- a/docs/script-inventory.md +++ b/docs/script-inventory.md @@ -72,7 +72,10 @@ buffer rather than parallel per-id arrays. Its generated records preserve four m six scalars, cells in 29 preallocated buffers, and all 1,396 length-prefixed footer-array copies. Confirmed stage field meanings and the evidence workflow live in `docs/name-resolution.md`. The first semantic join now assembles the confirmed type/X/Y/difficulty and story-prerequisite buffers into 2,312 object placements -across 66 stages; unknown type-specific parameters remain raw, and the enemy-spawn buffers remain separate. +across 66 stages. A second join assembles all 1,378 populated enemy slots across the same 66 stages, +including unit/faction, placement, difficulty/story gates, level rules, weighted alternatives, and +difficulty-specific movement/battle routine sets. Unknown type-specific object parameters and the isolated +enemy spawn-mode field remain raw. ### Message/string tables (`*MES`) `ITMES` (64 KB — item text), `VIMES` (43 KB), `EIMES` (37 KB), `SKMES` (31 KB — skill diff --git a/docs/tools-reference.md b/docs/tools-reference.md index 30f0cd7..f3eebe7 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -55,9 +55,9 @@ All opcode knowledge (ABI, semantics, provenance, `depends_on`) is hand-edited * |---|---|---|---| | `extract_phase2.py` | Batch: disassembly + text corpora for every script. | `extract_phase2.py` | corpus → `build/disasm/*.asm`, `build/text/{dialogue.jsonl,strings.jsonl,*.strings.txt}`, `build/manifest.json` | | `extract_message_table.py` | Discover a repeated global-id dispatch chain such as ITMES/SKMES, reconstruct player-facing title/description lines (including furigana surface text and readings), and emit an ID-keyed message table with bytecode provenance. | `extract_message_table.py [OUTNAME]` | `.BIN` → `build/data/.json` | -| `extract_init.py` | Parse a `*INIT` data table (auto-detects name / numeric / footer / mixed shape). Name tables infer their reserved record span, preserve sparse one-based runtime ids, distinguish lookup bases from first written cells, statically evaluate direct and negative-value writes, and separate parallel `fields` from linked row-major `record_fields`. Mixed tables recover selector-dispatched records, condition strings, scalars, preallocated buffer cells, consumer-confirmed strides, and length-prefixed footer arrays; STINIT additionally assembles confirmed buffers into `object_placements`. ITINIT and SKINIT join ITMES/SKMES messages. Top-level `field_semantics` maps raw keys to canonical global/column names, while each record's `semantic_fields` is the generated name-keyed join; raw keys remain provenance. OUTNAME accepts a stem or one `.json` suffix and rejects paths. Refreshes the generated data index. | `extract_init.py [OUTNAME] [--mode …]` | `
.BIN` plus matching `.BIN` when supported + `build/globals.json` → `build/data/.json`, `build/data/README.md` | +| `extract_init.py` | Parse a `*INIT` data table (auto-detects name / numeric / footer / mixed shape). Name tables infer their reserved record span, preserve sparse one-based runtime ids, distinguish lookup bases from first written cells, statically evaluate direct and negative-value writes, and separate parallel `fields` from linked row-major `record_fields`. Mixed tables recover selector-dispatched records, condition strings, scalars, preallocated buffer cells, consumer-confirmed strides, and length-prefixed footer arrays; STINIT additionally assembles confirmed buffers into `object_placements` and `enemy_spawns`. ITINIT and SKINIT join ITMES/SKMES messages. Top-level `field_semantics` maps raw keys to canonical global/column names, while each record's `semantic_fields` is the generated name-keyed join; complete footer copies map to a row and expose its values without the raw provenance wrapper. Raw keys remain provenance. OUTNAME accepts a stem or one `.json` suffix and rejects paths. Refreshes the generated data index. | `extract_init.py
[OUTNAME] [--mode …]` | `
.BIN` plus matching `.BIN` when supported + `build/globals.json` → `build/data/.json`, `build/data/README.md` | | `init_table_profile.py` | Build the static investigation surface for an extracted name/numeric/mixed table: message coverage, per-scalar/string/array-cell/footer-array population and value distributions, representative records, and direct opcode/script consumers. `--message-query REGEX` shows every matching name/message beside all populated fields for semantic correlation. Findings are evidence only; confirmed meanings go in `vm-map/globals.toml`. | `init_table_profile.py
[--build] [--limit N] [--message-query REGEX]` | `build/data/
.json` + corpus → stdout; with `--build`, `build/data/
-field-profile.{json,md}` | -| `test_extract_init.py`, `test_init_table_profile.py` | Regression checks for sparse one-based and mixed selector-dispatched INIT extraction, MES reconstruction/joins, footer-array accounting, and field/message profiling. | run each directly | — | +| `test_extract_init.py`, `test_init_table_profile.py` | Regression checks for sparse one-based and mixed selector-dispatched INIT extraction, MES reconstruction/joins, footer-array accounting and semantic projection, STINIT object/enemy joins, and field/message profiling. | run each directly | — | | `global_map.py` | Build the partial global-variable name map from static evidence. | `global_map.py` | corpus + `build/data/` → `build/global-var-map.{json,md}` | ## VM diff --git a/docs/vm-mapping-plan.md b/docs/vm-mapping-plan.md index 3f582c2..136d4fa 100644 --- a/docs/vm-mapping-plan.md +++ b/docs/vm-mapping-plan.md @@ -54,7 +54,7 @@ - [x] **2.0 — Project structure.** Established `docs/`, `build/{disasm,text,data,scripts-json}/`, `godot/`; game install stays read-only in place. Also relaxed the loader magic check to the `SYS4` family (`SYS4424` patch scripts now parse — was silently skipping 5 scripts). - [x] **2.1 — Text corpora.** `tools/extract_phase2.py` → 481/481 scripts: full disassembly (`build/disasm/*.asm`), per-script strings, `build/text/dialogue.jsonl` (**30,057 show-text lines** — the translation corpus), `build/text/strings.jsonl` (38,449 strings tagged by source opcode), `build/manifest.json`. - [x] **2.2 — `*INIT` data tables → JSON.** `tools/extract_init.py` auto-detects table shape (`name`/`numeric`/`footer`/`mixed`) → **SKINIT (131 skills), ITINIT (287 items), EBINIT (277 units)** [name: sparse one-based name/description/fields], **CGINIT (379 CG entries)** [numeric: index-keyed columns], **MPINIT (1472 map records)** [footer: length-prefixed arrays], and **STINIT (74 stages)** [mixed: selector-dispatched strings/scalars/buffer cells/footer arrays]. Validated; see `build/data/README.md`. Raw addresses remain bytecode provenance; confirmed semantics come from `vm-map/globals.toml`. -- [x] **2.3 — Extract `STINIT`'s 74 sparse stage records.** The mixed mode identifies the dominant `scjump_progress_a` dispatch, recovers 29 preallocated buffer layouts (including six consumer-confirmed row strides), and keeps four condition strings, six scalars, fixed-buffer writes, and all 1,396 footer-array copies separated by stage id. Profiles supply population/value and direct-consumer evidence. Header/map semantics now cover conditions, BGM, turn/replay/clear-reward settings, and map texture overrides; four parallel object fields plus 7 required/5 forbidden story prerequisites assemble into 2,312 object placements. Raw address views remain alongside generated `semantic_fields`. +- [x] **2.3 — Extract `STINIT`'s 74 sparse stage records.** The mixed mode identifies the dominant `scjump_progress_a` dispatch, recovers 29 preallocated buffer layouts (including six consumer-confirmed row strides), and keeps four condition strings, six scalars, fixed-buffer writes, and all 1,396 footer-array copies separated by stage id. Profiles supply population/value and direct-consumer evidence. Header/map semantics cover conditions, BGM, turn/replay/clear-reward settings, and map texture overrides; four parallel object fields plus 7 required/5 forbidden story prerequisites assemble into 2,312 object placements. The 30-cell enemy family now contributes 1,378 joined spawns with unit/faction, placement, difficulty/story gates, level scaling, weighted selection, and difficulty-specific movement/battle routine sets. Raw address views remain alongside generated `semantic_fields`. - [x] **2.4 — Partial global-var map BUILT + wired into the disassembler.** `tools/global_map.py` → `build/global-var-map.{json,md}` (16,354/49,435 globals labelled: string tables, `*INIT` field arrays, 122 record tables w/ strides, current-entity index pointers). `sys4load` renders the labels inline (`=rec[s30]`, `=current-entity-index?`). See `docs/name-resolution.md`. - [ ] **2.5 — Grow the global-var map (future, incremental).** Static first: fold in `*MES` writers; label 2D record tables by their reader scripts. Then Frida to name *which stat* each field is. Full detail: `docs/name-resolution.md` → "Future step — growing the map". Also deferred: `call-script` id→name resolution (engine-level — SCJUMP.BIN decode or Frida; see `docs/name-resolution.md` #1). diff --git a/tools/extract_init.py b/tools/extract_init.py index cccb20b..413c310 100644 --- a/tools/extract_init.py +++ b/tools/extract_init.py @@ -15,7 +15,7 @@ Records are {id, name?, desc?, fields:{"0x": value}} or, for footer tables, {id, global_addr, footer_off, values:[...]}. Column addresses are raw engine globals; -naming them (attack, cost, …) needs the engine global-var map — later work. +confirmed names come from the generated engine global registry while raw keys remain provenance. Usage: py -3.11 -X utf8 tools/extract_init.py
[OUTNAME] [--mode name|numeric|footer|mixed] """ @@ -484,6 +484,11 @@ def field_semantics( records: list[dict], array_layouts: dict[str, dict] | None = None ) -> dict[str, str]: """Map raw extracted field keys to canonical semantic names when available.""" + footer_keys = { + key + for record in records + for key in record.get("footer_arrays", {}) + } keys = { key for record in records @@ -510,10 +515,14 @@ def field_semantics( layout = (array_layouts or {}).get(f"0x{int(parts[0], 16):x}", {}) if stride := layout.get("stride"): row, column = divmod(index, stride) - column_name = entry.get("columns", {}).get( - str(column), f"column_{column}" - ) - name = f"{name}.row_{row}.{column_name}" + if key in footer_keys: + # A footer copy owns the complete row beginning at this offset. + name = f"{name}.row_{row}" + else: + column_name = entry.get("columns", {}).get( + str(column), f"column_{column}" + ) + name = f"{name}.row_{row}.{column_name}" else: index_name = entry.get("columns", {}).get(str(index), f"index_{index}") name = f"{name}.{index_name}" @@ -539,7 +548,9 @@ def attach_semantic_fields(records: list[dict], semantics: dict[str, str]) -> No raise ValueError( f"record {record['id']}: duplicate semantic field {semantic_name}" ) - semantic_fields[semantic_name] = value + semantic_fields[semantic_name] = ( + value["values"] if container == "footer_arrays" else value + ) if semantic_fields: record["semantic_fields"] = semantic_fields else: @@ -593,6 +604,78 @@ def attach_stage_object_placements(records: list[dict]) -> None: record["object_placements"] = objects +def attach_stage_enemy_spawns(records: list[dict]) -> None: + """Assemble STINIT's parallel enemy buffers into modder-facing slot records.""" + direct_fields = { + "unit_id": "0xe7811", + "faction_id": "0xe7799", + "difficulty_mask": "0xe77b7", + "min_level": "0xe782f", + "max_level": "0xe784d", + "auto_level_scale_divisor": "0xe786b", + } + optional_fields = { + "tile_x": "0xe773f", + "tile_y": "0xe775d", + "object_slot": "0xe777b", + "random_selection_weight": "0xe77f3", + } + routine_fields = { + "movement_routine_set_ids": ("0xe7889", 3), + "battle_routine_set_ids": ("0xe78e3", 3), + } + unknown_bases = ("0xe77d5",) + for record in records: + fields = record.get("array_fields", {}) + footer_arrays = record.get("footer_arrays", {}) + spawns = [] + # Slot zero is reserved by ADDEN for its synthesized special-unit path. + for slot in range(1, 30): + unit_key = f"{direct_fields['unit_id']}/{slot}" + if unit_key not in fields: + continue + spawn = {"slot": slot} + for semantic_name, base in direct_fields.items(): + key = f"{base}/{slot}" + # Faction zero is the buffer default and is meaningful to SETEN. + if key in fields: + spawn[semantic_name] = fields[key] + elif semantic_name == "faction_id": + spawn[semantic_name] = 0 + for semantic_name, base in optional_fields.items(): + if (key := f"{base}/{slot}") in fields: + spawn[semantic_name] = fields[key] + for semantic_name, (base, stride) in routine_fields.items(): + key = f"{base}/{slot * stride}" + if key in footer_arrays: + spawn[semantic_name] = footer_arrays[key]["values"] + required = [ + fields[key] + for column in range(7) + if (key := f"0xe793d/{slot * 7 + column}") in fields + and fields[key] > 0 + ] + forbidden = [ + fields[key] + for column in range(5) + if (key := f"0xe7a0f/{slot * 5 + column}") in fields + and fields[key] > 0 + ] + if required: + spawn["required_story_flags"] = required + if forbidden: + spawn["forbidden_story_flags"] = forbidden + unknown = { + base: fields[f"{base}/{slot}"] + for base in unknown_bases + if f"{base}/{slot}" in fields + } + if unknown: + spawn["unknown_fields"] = unknown + spawns.append(spawn) + record["enemy_spawns"] = spawns + + def write_data_index(data_dir: Path) -> None: """Regenerate the disposable build/data index from current table JSONs.""" tables = [] @@ -640,12 +723,13 @@ def write_data_index(data_dir: Path) -> None: "short description stored by the INIT script.", "Top-level `field_semantics` maps raw array/row-column keys to canonical machine-readable", "names from `vm-map/globals.toml`; each record's generated `semantic_fields` is the joined", - "name-keyed convenience view. Raw keys remain intact as bytecode provenance.", + "name-keyed convenience view. Complete footer copies expose their row values there while", + "raw keys and footer metadata remain intact as bytecode provenance.", "", "Mixed-mode tables preserve the sparse selector id, branch offset, condition strings,", "scalar fields, cells within preallocated buffers, and length-prefixed footer arrays.", - "STINIT additionally joins the confirmed parallel object buffers into per-slot", - "`object_placements`; unresolved type-specific parameters remain in `unknown_fields`.", + "STINIT additionally joins confirmed parallel buffers into per-slot `object_placements`", + "and `enemy_spawns`; unresolved type-specific/mode parameters remain in `unknown_fields`.", "Use `tools/init_table_profile.py
--build` to generate value/population and", "direct-consumer evidence.", "", @@ -697,6 +781,7 @@ def main() -> int: attach_semantic_fields(recs, semantics) if mode == "mixed" and name == "STINIT": attach_stage_object_placements(recs) + attach_stage_enemy_spawns(recs) out = {"table": name, "source": scr.path.name, "magic": scr.magic, "mode": mode, "record_count": len(recs), **meta, "field_columns": cols if mode != "footer" else None, diff --git a/tools/test_extract_init.py b/tools/test_extract_init.py index a61ab7e..aa41b31 100644 --- a/tools/test_extract_init.py +++ b/tools/test_extract_init.py @@ -120,6 +120,27 @@ def test_real_mixed_table() -> None: ) check(stage2_slot3["required_story_flags"] == [902], "STINIT object placements join positive story prerequisites") + extract_init.attach_stage_enemy_spawns(records) + first_spawn = records[0]["enemy_spawns"][0] + check(first_spawn == { + "slot": 1, + "unit_id": 205, + "faction_id": 2, + "difficulty_mask": 7, + "min_level": 1, + "max_level": 10, + "auto_level_scale_divisor": 1, + "object_slot": 2, + "movement_routine_set_ids": [1, 1, 1], + "forbidden_story_flags": [11], + "unknown_fields": {"0xe77d5": 2}, + }, "STINIT joins confirmed enemy buffers into one spawn record") + stage1_slot2 = records[0]["enemy_spawns"][1] + check(stage1_slot2["random_selection_weight"] == 1 + and stage1_slot2["object_slot"] == 5, + "STINIT preserves weighted object-linked enemy alternatives") + check(sum(len(record["enemy_spawns"]) for record in records) == 1378, + "STINIT assembles every populated enemy spawn slot") def test_real_message_tables() -> None: @@ -211,6 +232,18 @@ def test_field_semantics() -> None: == "stage_object_required_story_flags.row_3.required_flag_1", "mixed row buffers expose row and column semantics", ) + check( + stage_semantics["0xe7889/3"] + == "stage_enemy_movement_routine_set_ids.row_1", + "enemy footer copies expose whole-row semantics", + ) + extract_init.attach_semantic_fields(stages, stage_semantics) + check( + stages[0]["semantic_fields"][ + "stage_enemy_movement_routine_set_ids.row_1" + ] == [1, 1, 1], + "semantic footer fields expose row values without provenance wrappers", + ) if __name__ == "__main__": diff --git a/vm-map/globals.toml b/vm-map/globals.toml index 7f9269f..f4779be 100644 --- a/vm-map/globals.toml +++ b/vm-map/globals.toml @@ -1682,6 +1682,16 @@ usage = "GAMESTART's three-way difficulty selection. SETEN uses the zero-based i source = "investigation" confidence = "high" +[[global]] +address = "0x152617" +name = "current_stage_enemy_spawn_slot" +category = "index-pointer" +type = "int" +value_domain = "0..29; slot 0 is ADDEN's synthesized special-unit path" +usage = "Current STINIT enemy-template slot. FIELD selects slots 1..29 while materializing stage units, SETEN records the slot on the runtime entity and reads every parallel enemy buffer through it, and ADDEN uses slot 0 for its special generated unit." +source = "investigation" +confidence = "high" + [[global]] address = "0x6d3" name = "story_event_flags" @@ -1796,3 +1806,157 @@ usage = "Five negative prerequisites per stage object. SETOBJ subtracts one from source = "investigation" confidence = "high" depends_on = ["0x6d3"] + +[[global]] +address = "0xe773f" +name = "stage_enemy_spawn_tile_x" +category = "data-table" +type = "int[30]" +value_domain = "map tile X coordinate, 1..25 when populated" +usage = "Direct X coordinate for a current-stage enemy slot. FIELD copies it into the runtime unit position when stage_enemy_spawn_object_slot is zero; object-linked alternatives instead inherit the referenced object's coordinates." +source = "investigation" +confidence = "high" + +[[global]] +address = "0xe775d" +name = "stage_enemy_spawn_tile_y" +category = "data-table" +type = "int[30]" +value_domain = "map tile Y coordinate, 3..799 when populated" +usage = "Direct Y coordinate paired with stage_enemy_spawn_tile_x. FIELD and ADDEN copy the pair into the runtime unit position; object-linked alternatives instead inherit the referenced object's coordinates." +source = "investigation" +confidence = "high" +depends_on = ["0xe773f"] + +[[global]] +address = "0xe777b" +name = "stage_enemy_spawn_object_slot" +category = "data-table" +type = "int[30]" +value_domain = "stage object slot 2..46; zero means use the direct tile coordinates" +usage = "Optional object-placement anchor for an enemy spawn. FIELD resolves nonzero entries through stage_object_type_id and uses the matching stage_object_tile_x/Y location; zero selects stage_enemy_spawn_tile_x/Y directly." +source = "investigation" +confidence = "high" +depends_on = ["0xe7325", "0xe7357", "0xe7389"] + +[[global]] +address = "0xe7799" +name = "stage_enemy_faction_id" +category = "data-table" +type = "int[30]" +value_domain = "{0,2,3}" +usage = "Per-spawn faction/team selector. FIELD and ADDEN copy it into the runtime unit's faction field, use it to choose the entity-index band, and test the same value against map occupancy masks." +source = "investigation" +confidence = "high" + +[[global]] +address = "0xe77b7" +name = "stage_enemy_difficulty_mask" +category = "data-table" +type = "int[30]" +value_domain = "7 in the shipped table; bit 0/1/2 enables the spawn on the corresponding difficulty" +usage = "Per-spawn difficulty inclusion mask. FIELD uses check-bit with difficulty_index and rejects a slot when the selected difficulty bit is absent." +source = "investigation" +confidence = "high" +depends_on = ["0x32f1"] + +[[global]] +address = "0xe77f3" +name = "stage_enemy_random_selection_weight" +category = "data-table" +type = "int[30]" +value_domain = "{0,1,2,3,10,20,30}; zero means spawn directly" +usage = "Weight for stage-enemy alternatives. FIELD groups eligible nonzero-weight slots by their stage_enemy_spawn_object_slot, sums the weights, makes a weighted random choice within each eligible group, and directly materializes zero-weight slots." +source = "investigation" +confidence = "high" +depends_on = ["0xe777b"] + +[[global]] +address = "0xe7811" +name = "stage_enemy_unit_id" +category = "data-table" +type = "int[30]" +value_domain = "EBINIT unit ids 1..869; zero means unused slot" +usage = "Unit-definition id for each current-stage enemy slot. FIELD rejects zero slots, while FIELD and ADDEN pass populated ids to SETEN to copy the corresponding EBINIT definition into a runtime entity." +source = "investigation" +confidence = "high" + +[[global]] +address = "0xe782f" +name = "stage_enemy_min_level" +category = "data-table" +type = "int[30]" +value_domain = "1..99" +usage = "Scenario-specific minimum level for each stage enemy. After applying party-level auto-scaling, SETEN raises the runtime level to this floor before applying the maximum-level clamp." +source = "investigation" +confidence = "high" + +[[global]] +address = "0xe784d" +name = "stage_enemy_max_level" +category = "data-table" +type = "int[30]" +value_domain = "1..99" +usage = "Scenario-specific maximum level for each stage enemy. SETEN uses a positive value as the upper clamp unless the debug/config override is active; otherwise it falls back to the unit definition's unit_level_cap." +source = "investigation" +confidence = "high" +depends_on = ["0x7a766"] + +[[global]] +address = "0xe786b" +name = "stage_enemy_auto_level_scale_divisor" +category = "data-table" +type = "int[30]" +value_domain = "{1,2}" +usage = "Scenario override for enemy auto-level scaling. SETEN divides the positive gap between the scenario base level and unit_starting_level by this value before adding it to the runtime level; zero would fall back to unit_auto_level_scale_divisor." +source = "investigation" +confidence = "high" +depends_on = ["0x7a37e", "0x7ab4e"] + +[[global]] +address = "0xe7889" +name = "stage_enemy_movement_routine_set_ids" +category = "data-table" +type = "int[30][3]" +columns = { "0" = "difficulty_0", "1" = "difficulty_1", "2" = "difficulty_2" } +value_domain = "movement-routine set ids 1..176" +usage = "Three difficulty-specific movement-AI routine-set ids per stage enemy. SETEN copies the row into the runtime unit, and MVRTN selects the difficulty_index column as its movement routine table row." +source = "investigation" +confidence = "high" +depends_on = ["0x32f1", "0x152617"] + +[[global]] +address = "0xe78e3" +name = "stage_enemy_battle_routine_set_ids" +category = "data-table" +type = "int[30][3]" +columns = { "0" = "difficulty_0", "1" = "difficulty_1", "2" = "difficulty_2" } +value_domain = "battle-routine set ids 2..7; zero row falls back to the unit definition" +usage = "Optional three-column battle-AI routine override per stage enemy. SETEN copies a populated row into the runtime unit or falls back to the unit definition, and BTRTN selects the difficulty_index column as its battle routine table row." +source = "investigation" +confidence = "high" +depends_on = ["0x32f1", "0x152617"] + +[[global]] +address = "0xe793d" +name = "stage_enemy_required_story_flags" +category = "data-table" +type = "int[30][7]" +columns = { "0" = "required_flag_1", "1" = "required_flag_2", "2" = "required_flag_3", "3" = "required_flag_4", "4" = "required_flag_5", "5" = "required_flag_6", "6" = "required_flag_7" } +value_domain = "one-based story_event_flags ids; zero means no requirement" +usage = "Seven positive prerequisites per stage enemy. FIELD subtracts one from every populated id and suppresses the spawn unless the corresponding story_event_flags cell equals 1." +source = "investigation" +confidence = "high" +depends_on = ["0x6d3"] + +[[global]] +address = "0xe7a0f" +name = "stage_enemy_forbidden_story_flags" +category = "data-table" +type = "int[30][5]" +columns = { "0" = "forbidden_flag_1", "1" = "forbidden_flag_2", "2" = "forbidden_flag_3", "3" = "forbidden_flag_4", "4" = "forbidden_flag_5" } +value_domain = "one-based story_event_flags ids; zero means no exclusion" +usage = "Five negative prerequisites per stage enemy. FIELD subtracts one from every populated id and suppresses the spawn when the corresponding story_event_flags cell equals 1." +source = "investigation" +confidence = "high" +depends_on = ["0x6d3"]