From 9646b23ae21a5f5d756994bc10691b2828973bdf Mon Sep 17 00:00:00 2001 From: gamer147 Date: Thu, 23 Jul 2026 21:31:43 -0400 Subject: [PATCH] Decode MPINIT stage terrain atlas --- docs/global-reference.md | 22 +- docs/name-resolution.md | 9 +- docs/phase-a-slice-plan.md | 35 +++- docs/script-inventory.md | 8 + docs/tools-reference.md | 12 +- docs/vm-mapping-plan.md | 5 +- tools/extract_init.py | 339 ++++++++++++++++++++++++++++++- tools/init_table_profile.py | 63 +++++- tools/test_extract_init.py | 91 +++++++++ tools/test_init_table_profile.py | 35 ++++ vm-map/globals.toml | 109 ++++++++++ 11 files changed, 703 insertions(+), 25 deletions(-) diff --git a/docs/global-reference.md b/docs/global-reference.md index b3d0029..f5c58b9 100644 --- a/docs/global-reference.md +++ b/docs/global-reference.md @@ -1,7 +1,7 @@ # Global Variable Reference (generated) -5049 globals (319 curated, 4730 auto shape-inferred). Source of truth: `vm-map/globals.toml`. +5049 globals (329 curated, 4720 auto shape-inferred). Source of truth: `vm-map/globals.toml`. ## choice-output @@ -52,6 +52,7 @@ | `0x7684` | stage_clear_state | high | investigation | Persistent per-stage completion state indexed by stage id. STAGECLEAR sets the current stage cell to 1, while FORT, SELSTAGE, and FIELD use zero versus one to distinguish an uncleared mission from a cleared replay. | | `0x141a4` | stage_object_runtime_state | high | investigation | Per-stage, per-object-slot runtime state. FIELD initializes mode-1 objects from STINIT's first tagged payload on a fresh stage and updates capturable-object ownership; DRAWOBJ uses it as the vertical sprite-row index. RTN_M015 treats the state of Magic Pillar types 2..4 as their controlling faction and targets pillars whose value differs from the acting entity's faction. | | `0x20543` | tile_faction_traversal_masks | high | investigation | Row-major tile traversal permissions. Movement checks the acting entity's faction bit before enqueuing a tile in movement-limited MVSEEK searches. RTN_M013 builds either one selected faction bit or all bits except the actor's, requires the current tile not to match that set, and approaches the nearest reachable tile whose mask does. | +| `0x341ab` | current_stage_terrain_grid | high | investigation | Current stage's mutable terrain grid. FIELD clears all 2,000 rows and copies the selected stage_terrain_atlas rectangle; rendering, minimap, occupancy, battle, and movement scripts read it, while SETLAND/DELLAND alter cells and RESETLAND restores atlas values. | | `0x4e021` | entity_runtime_flags | high | investigation | Per-runtime-entity state flags. RTN_M007 requires bit 0 when selecting an injured ally, and MVSEEK mode 2 requires it before masking a foreign entity's occupied terrain cell. | | `0x4e085` | entity_current_resources | high | investigation | Per-runtime-entity current resources. DRAWCHP/DRAWENP render columns 0..2 against max-stat columns 11..13, skill/item resource deltas modify the same three columns, and movement search compares path cost with current_fs. Columns: 0=current_hp, 1=current_sp, 2=current_fs. | | `0x4e11b` | entity_effective_stats | high | investigation | Per-runtime-entity effective fourteen-stat row after CALCREVISE applies base data, equipment, skills, and conditions. Combat/UI consumers use columns 0..10 directly; columns 11..13 are the maxima paired with entity_current_resources HP/SP/FS. Columns: 0=accuracy, 1=evasion, 2=physical_attack, 3=physical_defense, 4=magic_attack, 5=magic_defense, 6=speed, 7=luck, 8=critical_chance, 9=capture_power, 10=movement, 11=max_hp, 12=max_sp, 13=max_fs. | @@ -181,6 +182,10 @@ | `0xcc9f4` | offensive_action_scope_masks | high | investigation | CALCSCOPE clears ten encoded-range cells, sets bit 0 over the equipped/default attack's range, and sets bits 1..4 over each usable offensive skill's range. SETMVWORK uses the range-0 mask and paired attack elements when filtering target cells; RTN_M003/006/016/017/018 require its normal-attack bit. Columns: 0=range_0, 1=range_1, 2=range_2, 3=range_3, 4=range_4, 5=range_5, 6=range_6, 7=range_7, 8=range_8, 9=range_9. | | `0xcc9fe` | healing_action_scope_masks | high | investigation | CALCSCOPE clears ten encoded-range cells and sets the equipped-slot bit for each usable category-7 healing skill over its applicable range. RTN_M061 uses the masks to restrict ally targets and to select the range-enabled healing skill. Columns: 0=range_0, 1=range_1, 2=range_2, 3=range_3, 4=range_4, 5=range_5, 6=range_6, 7=range_7, 8=range_8, 9=range_9. | | `0xcca08` | offensive_action_attack_elements | high | investigation | CALCSCOPE stores the normal attack element in action column 0 and each usable equipped skill's attack element in columns 1..4 for every applicable range band. SETMVWORK and RTN_M051/052 test these elements against attack_element_effectiveness_percent. | +| `0xccc93` | stage_terrain_atlas | high | investigation | Immutable sparse half-tile terrain atlas loaded by MPINIT. Each footer copy writes fifty cells at row stride 53. FIELD doubles STINIT2's tile bounds and copies the selected rectangle into current_stage_terrain_grid; DRAWMINIMAP reads the atlas outside the active rectangle for border context, and RESETLAND restores changed cells from it. | +| `0xe6aa4` | terrain_texture_slot_indices | high | investigation | LAINIT mapping from terrain id to the index in stage_map_texture_asset_overrides. DRAWMAP uses the selected slot to choose the current stage's tiled terrain surface. | +| `0xe6ac2` | terrain_area_fill_flags | high | investigation | LAINIT terrain topology flag. DRAWMAP and CALCOCC use neighboring values to expand room-like regions across the alternating half-tile grid; DRAWMINIMAP uses the same distinction when joining adjacent revealed cells. | +| `0xe6ae0` | terrain_layout_classes | high | investigation | LAINIT terrain class. Zero cells are rejected by map rendering, occupancy, and pathfinding; ordinary areas, passages, and hidden spaces take distinct DRAWMAP/CALCOCC/MVSEEK paths. Combined with terrain_area_fill_flags, it distinguishes hidden rooms from hidden passages. | | `0xe6dee` | object_sprite_state_row_mode | high | investigation | OBINIT metadata indexed by object type. FIELD copies the type-tagged initial payload into stage_object_runtime_state only for mode 1; DRAWOBJ likewise multiplies that runtime state by the object sprite height to select source Y only for mode 1. | | `0xe7302` | stage_bgm_id | high | investigation | STINIT's per-stage scalar loaded for all 74 records. FIELD passes the value directly to play-bgm when starting the stage. | | `0xe7303` | stage_target_clear_turns | high | investigation | STINIT's target/par turn count. STAGECLEAR divides elapsed turns by this value to derive a performance multiplier; when replaying an already-cleared ordinary stage, FIELD also uses it as the forced-retreat turn limit. | @@ -212,6 +217,10 @@ | `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. | +| `0xec4dd` | stage_map_min_tile_x | high | investigation | STINIT2's inclusive left map bound indexed by stage id. FIELD, DRAWMAP, DRAWMINIMAP, CALCOCC, and movement providers use it with stage_map_max_tile_x; terrain-grid accesses multiply the coordinate by two. | +| `0xec8c5` | stage_map_max_tile_x | high | investigation | STINIT2's inclusive right map bound indexed by stage id. Consumers pair it with stage_map_min_tile_x for iteration, camera/minimap limits, random placement, and the doubled-coordinate terrain-atlas copy. | +| `0xeccad` | stage_map_min_tile_y | high | investigation | STINIT2's inclusive top map bound indexed by stage id. FIELD and all map readers pair it with stage_map_max_tile_y and multiply it by two when addressing the half-tile terrain grid. | +| `0xed095` | stage_map_max_tile_y | high | investigation | STINIT2's inclusive bottom map bound indexed by stage id. The maximum shipped value 800 explains MPINIT's final authored doubled grid row at Y 1600. | | `0xeff78` | movement_routine_provider_selectors | high | investigation | RTINIT movement bank 0. MVRTN indexes it by current_routine_set_id and routine_step_index, resolves the selector through its RTN_M001..018/051..053/061 provider table, and call-scripts the selected movement routine. | | `0xf4d98` | movement_routine_activation_percents | high | investigation | RTINIT movement bank 1. After all other step gates pass, MVRTN executes the provider only when random-modulo-100 is below this value. | | `0xf9bb8` | movement_routine_parameter_1 | high | investigation | RTINIT movement bank 2. Its meaning is tagged by movement_routine_provider_selectors: RTN_M004 uses a stage_object_slot_index; RTN_M005/011/012 use destination_tile_x; RTN_M006/007/015 use maximum_target_route_steps; RTN_M010 uses resource_index (0=HP, 1=SP, 2=FS; shipped cells are unwritten/default zero); RTN_M013 uses target_faction_filter; and RTN_M014 uses maximum_threat_route_steps. The one RTN_M001 and one RTN_M008 authored cells are never read by those providers. | @@ -254,7 +263,6 @@ | `0x69e` | — | low | auto-shape | array | | `0x6fe` | — | low | auto-shape | array | | `0x712` | — | low | auto-shape | array | -| `0x26b5` | — | low | auto-shape | array | | `0x26d3` | — | low | auto-shape | array | | `0x27bd` | — | low | auto-shape | array | | `0x3276` | — | low | auto-shape | array | @@ -324,9 +332,6 @@ | `0xccbf8` | — | low | auto-shape | array | | `0xccc11` | — | low | auto-shape | array | | `0xccc2f` | — | low | auto-shape | array | -| `0xe6aa4` | — | low | auto-shape | array | -| `0xe6ac2` | — | low | auto-shape | array | -| `0xe6ae0` | — | low | auto-shape | array | | `0xe6c2a` | — | low | auto-shape | array | | `0xe6c48` | — | low | auto-shape | array | | `0xe7305` | — | low | auto-shape | array | @@ -338,10 +343,6 @@ | `0xe8275` | — | low | auto-shape | array | | `0xebd0d` | — | low | auto-shape | array | | `0xec0f5` | — | low | auto-shape | array | -| `0xec4dd` | — | low | auto-shape | array | -| `0xec8c5` | — | low | auto-shape | array | -| `0xeccad` | — | low | auto-shape | array | -| `0xed095` | — | low | auto-shape | array | | `0xed47d` | — | low | auto-shape | array | | `0xed865` | — | low | auto-shape | array | | `0xeebed` | — | low | auto-shape | array | @@ -480,6 +481,7 @@ | `0x2690` | attack_element_names | high | investigation | AFINIT attack-side affinity vocabulary. DRAWTIP indexes ids 1..8 from ITINIT, while INFOAF displays those same eight ordinary attack elements above attack_element_effectiveness_percent. Columns: 1=physical, 2=universal, 3=fire, 4=ice, 5=lightning, 6=earth, 7=holy, 8=dark, 11=resistance_1, 12=resistance_2, 13=resistance_3, 14=resistance_4, 15=resistance_5, 16=resistance_6, 17=resistance_7. | | `0x26a4` | defense_element_names | high | investigation | AFINIT defense-side affinity vocabulary. DRAWENP and DRAWTIP index it with unit/item defense element ids; the same id selects a row of attack_element_effectiveness_percent. Columns: 1=physical, 2=universal, 3=fire, 4=ice, 5=lightning, 6=earth, 7=holy, 8=dark, 9=divinity, 10=demon, 11=spirit, 12=undead. | | `0x26b4` | class_change_title_output | high | investigation | CCINIT writes the title selected by each eligible class-change rule. CALCCC copies it to unit_class_titles for a successful promotion, and ADDEXP includes the same string in the level-up notification. | +| `0x26b5` | terrain_type_names | high | investigation | LAINIT's terrain vocabulary. MPINIT's atlas cells contain terrain ids 0..19; the named ids distinguish passages, rooms, hidden spaces, bases, water, openings, altars, lava, and themed room variants. | | `0x26f1` | object_type_names | high | investigation | OBINIT writes the authoritative object names. FIELD, SETOBJ, and DRAWOBJ use STINIT's object type id to select these definitions; extract_init joins the names to stage object placements. | | `0x2755` | object_type_descriptions | high | investigation | OBINIT writes the short object descriptions displayed by the field object-information path. extract_init joins populated descriptions to STINIT object placements by type id. | | `0x27b9` | stage_victory_condition_1 | high | investigation | STINIT writes one value for each of its 74 stage records. AIM renders this line first in the victory-condition section, and FIELD copies it into the current mission-condition display. | @@ -3728,7 +3730,6 @@ | `0x3ebe` | — | med | auto-shape | record-table[stride 10] | | `0x6f70` | — | med | auto-shape | record-table[stride 14] | | `0x7e54` | — | med | auto-shape | record-table[stride 50] | -| `0x341ab` | — | med | auto-shape | record-table[stride 53] | | `0x4e3d7` | — | med | auto-shape | record-table[stride 14] | | `0x53a2b` | — | med | auto-shape | record-table[stride 20] | | `0x53edd` | — | low | auto-shape | index/counter? | @@ -5050,7 +5051,6 @@ | `0xccbdc` | — | med | auto-shape | record-table[stride 14] | | `0xccc0b` | — | low | auto-shape | index/counter? | | `0xccc33` | — | med | auto-shape | record-table[stride 3] | -| `0xccc93` | — | med | auto-shape | record-table[stride 53] | | `0xe6afe` | — | med | auto-shape | record-table[stride 10] | | `0xe6c5e` | — | med | auto-shape | obinit-field | | `0xe6cc2` | — | med | auto-shape | obinit-field | diff --git a/docs/name-resolution.md b/docs/name-resolution.md index d4b9895..4c123da 100644 --- a/docs/name-resolution.md +++ b/docs/name-resolution.md @@ -669,7 +669,7 @@ 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, EBINIT, STINIT, CCINIT, SCINIT, RTINIT, MAINIT, ILINIT, CGINIT, ALINIT, AFINIT, CTINIT, and CVINIT now have +1. **Continue INIT semantics by evidence density.** ITINIT/SKINIT, EBINIT, STINIT, CCINIT, SCINIT, RTINIT, MAINIT, ILINIT, CGINIT, ALINIT, AFINIT, CTINIT, CVINIT, and MPINIT now have machine-readable investigation surfaces and semantic joins; EBINIT's populated schema is fully named, STINIT's joined object/enemy payloads are decoded, and CCINIT's 71 class-change rules expose predicates and effects. SCINIT closes the progression decision-to-scene join, and RTINIT's twenty movement/battle @@ -678,8 +678,11 @@ The v1 map labels *shapes and tables*; the next increments add *meaning*, cheape closes the gallery registry, ALINIT closes the 107-recipe alchemy registry with complete ITINIT joins, AFINIT closes the signed affinity/tuning/facility tables, CTINIT closes INPUTNAME's five-page character palette, and CVINIT closes CONFIG's thirteen preview clips plus the - bidirectional character-to-suppression-setting join. With RTINIT's used-provider surface closed and - ITMES/SKMES/VIMES/EIMES/CIMES/MAMES joined and INFOMES/MES classified, audit MPINIT next; + bidirectional character-to-suppression-setting join. MPINIT closes the sparse 53-column + doubled-coordinate terrain atlas and joins 66 STINIT2 stage definitions to 53 unique rectangles, + with LAINIT terrain names/classes attached. With RTINIT's used-provider surface closed and + ITMES/SKMES/VIMES/EIMES/CIMES/MAMES joined and INFOMES/MES classified, finish LAINIT's remaining + small terrain-definition columns next; never assign one universal meaning to a parameter bank whose meaning varies by provider selector. 2. **Label remaining 2D record tables by their readers** — cross-reference which scripts read each `rec[sN]` table and infer purpose from context, preserving reserved rows and sparse cells. RECOVER's diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 760bcb9..2538dd0 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -2950,5 +2950,36 @@ appears, explaining CONFIG's unlock gate. Regressions protect complete instruction accounting, all asset and EBINIT joins, the system slot, all twelve round trips, speaker-seen addresses, and raw-to-semantic projection. -**Next:** audit MPINIT's 1,472 structurally regular footer-array records by their direct map readers, -grouping rows into consumer-proven families before naming any shallow address-only fields. +## Data-semantics sidebar: MPINIT stage-terrain atlas (2026-07-23) + +MPINIT's 1,472 footer copies are not independent map records. Every destination is column 1 of one +row in a single sparse table rooted at `0xccc93`: the row pitch is 53 cells, while each footer owns +the fifty authored columns 1..50. Destination arithmetic recovers grid Y directly, from the first +authored row at Y=2 through the last at Y=1600; 127 all-zero rows inside that range are omitted. +All 1,473 instructions are now classified as terrain-row copies plus `exit`. + +FIELD proves the coordinate contract. STINIT2 supplies inclusive tile bounds in four 1,000-cell +arrays; FIELD multiplies each bound by two, clears a reserved `2000 × 53` current-stage grid at +`0x341ab`, and copies the selected rectangle from the immutable MPINIT atlas. DRAWMAP, CALCOCC, +MVSEEK, battle, occupancy, and movement providers consume the mutable grid. DRAWMINIMAP reads the +mutable grid inside the active rectangle but falls back to the source atlas outside it for border +context, while RESETLAND restores changed cells from the atlas. + +The dedicated schema joins 66 named STINIT2 stage definitions to 53 unique atlas rectangles. Eight +rectangles are intentionally shared by 21 stage variants, including the chapter-3 three-route map +and repeated late-game arenas. The joined rectangles contain 17,079 of the atlas's 17,126 nonzero +cells; the remaining 47 are preserved as border-context provenance rather than mislabeled as dead +data. Every stage exposes tile/grid bounds, dimensions, terrain rows, and value populations. + +LAINIT supplies the terrain vocabulary and the three fields needed to interpret the atlas: +terrain-to-texture-slot indices, room/area fill flags, and layout classes +(`0=blocked/boundary`, `1=open area`, `2=passage`, `3=hidden`). MPINIT uses ids 0..19; all named +ids join to passages, rooms, hidden spaces, bases, water, openings, altars, lava, and themed room +variants. The generated registry retains every raw row address/footer offset, the omitted-zero-row +list, all stage slices, LAINIT joins, and the FIELD/DRAWMINIMAP/RESETLAND consumer contract. + +Regressions protect complete instruction accounting, row geometry, doubled coordinates, all 66 +stage joins, shared rectangles, terrain definitions, and the exact nonzero/border-cell totals. + +**Next:** finish LAINIT's remaining small terrain-definition surface by its FIELD/MVSEEK/DRAWMAP +consumers; MPINIT already proves its name, texture-slot, area-fill, and layout-class columns. diff --git a/docs/script-inventory.md b/docs/script-inventory.md index 0a93ff3..1d26ac5 100644 --- a/docs/script-inventory.md +++ b/docs/script-inventory.md @@ -121,6 +121,14 @@ after CNINIT's voice-family normalization. Slot 0 is the non-unit system voice; Lily and eleven other named characters. CONFIG uses persisted per-unit speaker-seen flags to reveal those twelve rows, plays the corresponding preview clip, and writes the selected suppression setting. +MPINIT is one sparse stage-terrain atlas rather than 1,472 independent map records. Each footer copy +writes fifty authored cells starting at column 1 of a 53-cell row; destination arithmetic yields +doubled-coordinate grid rows Y=2..1600 with 127 omitted all-zero rows. FIELD doubles STINIT2's inclusive +tile bounds and copies the selected atlas rectangle into a mutable `2000 × 53` current-stage grid. +The dedicated view joins 66 named stage definitions to 53 unique rectangles, preserves eight shared-map +groups and 47 nonzero border-context cells, and resolves terrain ids through LAINIT's name, texture-slot, +area-fill, and layout-class columns. + RTINIT is a seventh banked shape. Its 3,336 writes populate twenty parallel `1000 × 20` banks: ten movement banks consumed by MVRTN/RTN_M providers and ten battle banks consumed by BTRTN/RTN_B providers. The generated 172 routine-set records assemble 1,043 movement steps and fourteen battle steps, join diff --git a/docs/tools-reference.md b/docs/tools-reference.md index 2846d16..6637377 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 repeated global-id dispatch chains in either fallthrough-body (ITMES/SKMES/MAMES) or branch-target-body (VIMES/EIMES/CIMES) form, reconstruct player-facing text and furigana, preserve guard/body offsets, and emit an ID-keyed message table. Title-bearing tables expose `title`/`description`; EIMES exposes its untitled lines as `summary`/`strategy`; CIMES exposes one untitled multiline `biography`; MAMES exposes untitled `description` text. | `extract_message_table.py [OUTNAME]` | `.BIN` → `build/data/.json` | -| `extract_init.py` | Parse a `*INIT` data source (auto-detects name / numeric / footer / mixed / rules / dispatch / banked 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`; when flat global ranges overlap, a parallel base established by other records wins over a coincidental row-table cell calculation. Dedicated name-mode schemas recover VIINIT's pre-title glossary prerequisites and CIINIT's profile name, backing-unit, optional portrait, and reserved placement columns. 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`, joins object type names and available descriptions from OBINIT, and exposes consumer-proven tagged payloads (teleport, treasure, card, initial faction, non-triggering hazard/barrier faction, and OBINIT-gated initial object state), preserves engine-dead type-27 writes under `ignored_payload_fields`, and exposes reinforcement schedules and first-clear-only enemy gates. Rules mode decodes CCINIT's source-ordered class-change predicates and output accumulators, joining EBINIT unit names, SKINIT skill names, titles, named stat bonuses, cost deltas, and applied-state slots. Dispatch mode decodes SCINIT's paired decision-to-packed-scene and authored-chapter arrays, preserving source-order overwrites, resolving SYS4INI script names, and cross-checking chapter tags against decoded SCJUMP paths. Banked mode decodes RTINIT's twenty parallel 1000-by-20 movement/battle routine banks, retains all assignments and overwrites, assembles final per-slot steps, resolves provider selectors to RTN_M/RTN_B scripts, and applies selector-scoped schemas where proven. Current schemas cover all nineteen used providers, RTN_M001/002/003/004/005/006/007/008/009/010/011/012/013/014/015/017/051/052/061, including progress-only, randomized roaming, object-slot, coordinate, enemy/ally, treasure and Magic Pillar search, Healing Feather, waypoint, faction-terrain, retreat, reachable normal-attack routing, immediate offensive target/action selection, and immediate allied healing. Parameterless behavior schemas publish an empty `parameter_fields` mapping while still joining `provider_behavior`, target selection, action selection, and completion rules. Provider defaults such as M004's unwritten slot 0, M010's zero/HP resource index, and M013's any-foreign-faction filter are projected explicitly and counted separately from populated source cells. Authored cells proven unread by M001/M008 remain under per-step `ignored_movement_parameters` and contribute to top-level `ignored_movement_parameter_count`; they are not silently discarded or assigned invented meanings. Raw provider banks remain intact, and top-level `movement_provider_parameter_schemas` documents the projection. ITINIT, SKINIT, VIINIT, EBINIT, and CIINIT join their matching MES text. 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 and unresolved tagged payload cells 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 EBINIT/SKINIT for CCINIT, OBINIT for STINIT, `build/callscript-names.json` for RTINIT/SCINIT, `build/scjump-decisions.json` for SCINIT, matching `.BIN` when supported, and `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/rules/dispatch/banked table: per-scalar/string/array-cell/footer-array population and value distributions, representative records, and direct opcode/script consumers. Name tables report message coverage for title/description, summary/strategy, biography, and description-only layouts; rule tables summarize covered units, titled and level-independent rules, threshold/slot distributions, and skill awards; dispatch tables summarize assignments, overwrites, script-name resolution, and SCJUMP chapter agreement; banked tables summarize populated/reserved banks, movement/battle steps and providers, selector-specific semantic coverage, and overwrite conflicts. `--message-query REGEX` searches every supported message field 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, mixed selector-dispatched, CCINIT rule, SCINIT dispatch, RTINIT banked, ILINIT condition-matrix, CNINIT unit-name/voice-family, CGINIT gallery, ALINIT alchemy-recipe, AFINIT affinity/progression, CTINIT name-entry-palette, and CVINIT character-voice extraction; RECOVER ABI validation; MES reconstruction/joins; footer-array accounting and semantic projection; EBINIT/SKINIT/OBINIT definition joins; STINIT object/enemy joins and tagged-payload behavior; overwrite/provider joins; and field/message/rule/dispatch/banked profiling. | run each directly | — | +| `extract_init.py` | Parse a `*INIT` data source (auto-detects name / numeric / footer / mixed / rules / dispatch / banked 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`; when flat global ranges overlap, a parallel base established by other records wins over a coincidental row-table cell calculation. Dedicated name-mode schemas recover VIINIT's pre-title glossary prerequisites and CIINIT's profile name, backing-unit, optional portrait, and reserved placement columns. Footer-mode MPINIT recovers its sparse 53-column doubled-coordinate terrain atlas, joins 66 STINIT2 stage rectangles, and resolves terrain ids through LAINIT. 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`, joins object type names and available descriptions from OBINIT, and exposes consumer-proven tagged payloads (teleport, treasure, card, initial faction, non-triggering hazard/barrier faction, and OBINIT-gated initial object state), preserves engine-dead type-27 writes under `ignored_payload_fields`, and exposes reinforcement schedules and first-clear-only enemy gates. Rules mode decodes CCINIT's source-ordered class-change predicates and output accumulators, joining EBINIT unit names, SKINIT skill names, titles, named stat bonuses, cost deltas, and applied-state slots. Dispatch mode decodes SCINIT's paired decision-to-packed-scene and authored-chapter arrays, preserving source-order overwrites, resolving SYS4INI script names, and cross-checking chapter tags against decoded SCJUMP paths. Banked mode decodes RTINIT's twenty parallel 1000-by-20 movement/battle routine banks, retains all assignments and overwrites, assembles final per-slot steps, resolves provider selectors to RTN_M/RTN_B scripts, and applies selector-scoped schemas where proven. Current schemas cover all nineteen used providers, RTN_M001/002/003/004/005/006/007/008/009/010/011/012/013/014/015/017/051/052/061, including progress-only, randomized roaming, object-slot, coordinate, enemy/ally, treasure and Magic Pillar search, Healing Feather, waypoint, faction-terrain, retreat, reachable normal-attack routing, immediate offensive target/action selection, and immediate allied healing. Parameterless behavior schemas publish an empty `parameter_fields` mapping while still joining `provider_behavior`, target selection, action selection, and completion rules. Provider defaults such as M004's unwritten slot 0, M010's zero/HP resource index, and M013's any-foreign-faction filter are projected explicitly and counted separately from populated source cells. Authored cells proven unread by M001/M008 remain under per-step `ignored_movement_parameters` and contribute to top-level `ignored_movement_parameter_count`; they are not silently discarded or assigned invented meanings. Raw provider banks remain intact, and top-level `movement_provider_parameter_schemas` documents the projection. ITINIT, SKINIT, VIINIT, EBINIT, and CIINIT join their matching MES text. 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 and unresolved tagged payload cells 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 EBINIT/SKINIT for CCINIT, OBINIT for STINIT, STINIT2/LAINIT for MPINIT, `build/callscript-names.json` for RTINIT/SCINIT, `build/scjump-decisions.json` for SCINIT, matching `.BIN` when supported, and `build/globals.json` → `build/data/.json`, `build/data/README.md` | +| `init_table_profile.py` | Build the static investigation surface for an extracted name/numeric/footer/mixed/rules/dispatch/banked table: per-scalar/string/array-cell/footer-array population and value distributions, representative records, and direct opcode/script consumers. MPINIT's footer specialization summarizes atlas geometry, implicit-zero rows, terrain ids, stage-rectangle joins, sharing, and border cells. Name tables report message coverage for title/description, summary/strategy, biography, and description-only layouts; rule tables summarize covered units, titled and level-independent rules, threshold/slot distributions, and skill awards; dispatch tables summarize assignments, overwrites, script-name resolution, and SCJUMP chapter agreement; banked tables summarize populated/reserved banks, movement/battle steps and providers, selector-specific semantic coverage, and overwrite conflicts. `--message-query REGEX` searches every supported message field 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, mixed selector-dispatched, CCINIT rule, SCINIT dispatch, RTINIT banked, ILINIT condition-matrix, CNINIT unit-name/voice-family, CGINIT gallery, ALINIT alchemy-recipe, AFINIT affinity/progression, CTINIT name-entry-palette, CVINIT character-voice, and MPINIT terrain-atlas extraction; RECOVER ABI validation; MES reconstruction/joins; footer-array accounting and semantic projection; EBINIT/SKINIT/OBINIT definition joins; STINIT object/enemy joins and tagged-payload behavior; overwrite/provider joins; and field/message/rule/dispatch/banked/map 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}` | `extract_init.py` also has name-mode specializations for VIINIT's sparse 200-row glossary table, @@ -98,6 +98,12 @@ join to shipped OGG filenames, character slots join to EBINIT names, and the sch per-unit persisted speaker-seen cells that gate CONFIG row availability. Every raw coordinate remains beside the joined setting view. +Footer-mode MPINIT classifies all 1,472 copies as fifty-cell rows within one stride-53 terrain atlas. +It derives grid Y from each destination, retains 127 implicit-zero gaps, and joins STINIT2's inclusive +tile bounds after the consumer-proven two-times coordinate conversion. The output contains 66 named +stage maps over 53 unique rectangles, shared-rectangle groups, terrain populations, LAINIT definitions, +and the exact FIELD/DRAWMINIMAP/RESETLAND copy/fallback/restore contract. + ## VM | Tool | Purpose | Run | Reads → Writes | diff --git a/docs/vm-mapping-plan.md b/docs/vm-mapping-plan.md index 13d6c9f..12448b2 100644 --- a/docs/vm-mapping-plan.md +++ b/docs/vm-mapping-plan.md @@ -53,7 +53,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 and rule sources → JSON.** `tools/extract_init.py` auto-detects shape (`name`/`numeric`/`footer`/`mixed`/`rules`/`dispatch`/`banked`) → **SKINIT (131 skills), ITINIT (287 items), EBINIT (277 units), OBINIT (46 object definitions), CIINIT (24 character-information profiles)** [name: sparse one-based name/description/fields, with CIINIT's dedicated profile registry], **CGINIT (851 sparse gallery images)** [numeric: full image, optional save/stage preview, thumbnail sheet/slot, and variant ordinal in a reserved 2,000-row layout], **ALINIT (107 sparse alchemy recipes)** [numeric: output, level/story gates, point cost, and four paired ingredient slots in a reserved 1,000-row layout], **AFINIT (13 authored affinity rows plus tuning/facility curves)** [name/footer specialization], **CTINIT (five 70-cell name-entry pages)** [string-matrix specialization], **CVINIT (13 character-voice settings)** [numeric: preview assets, slot-to-unit joins, and inverse suppression-setting map], **MPINIT (1472 map records)** [footer: length-prefixed arrays], **STINIT (74 stages)** [mixed: selector-dispatched strings/scalars/buffer cells/footer arrays], **CCINIT (71 class-change rules over 33 units)** [rules: unit/level/state predicates plus title/cost/stat/skill effects], **SCINIT (1,209 final decision rows)** [dispatch: scene resource plus authored chapter metadata and overwrite history], and **RTINIT (172 routine sets)** [banked: twenty movement/battle step banks, provider joins, and overwrite history]. Validated; see `build/data/README.md`. Raw addresses remain bytecode provenance; confirmed semantics come from `vm-map/globals.toml`. +- [x] **2.2 — `*INIT` data and rule sources → JSON.** `tools/extract_init.py` auto-detects shape (`name`/`numeric`/`footer`/`mixed`/`rules`/`dispatch`/`banked`) → **SKINIT (131 skills), ITINIT (287 items), EBINIT (277 units), OBINIT (46 object definitions), CIINIT (24 character-information profiles)** [name: sparse one-based name/description/fields, with CIINIT's dedicated profile registry], **CGINIT (851 sparse gallery images)** [numeric: full image, optional save/stage preview, thumbnail sheet/slot, and variant ordinal in a reserved 2,000-row layout], **ALINIT (107 sparse alchemy recipes)** [numeric: output, level/story gates, point cost, and four paired ingredient slots in a reserved 1,000-row layout], **AFINIT (13 authored affinity rows plus tuning/facility curves)** [name/footer specialization], **CTINIT (five 70-cell name-entry pages)** [string-matrix specialization], **CVINIT (13 character-voice settings)** [numeric: preview assets, slot-to-unit joins, and inverse suppression-setting map], **MPINIT (1,472 sparse terrain rows joined to 66 stage maps)** [footer: 53-column doubled-coordinate atlas], **STINIT (74 stages)** [mixed: selector-dispatched strings/scalars/buffer cells/footer arrays], **CCINIT (71 class-change rules over 33 units)** [rules: unit/level/state predicates plus title/cost/stat/skill effects], **SCINIT (1,209 final decision rows)** [dispatch: scene resource plus authored chapter metadata and overwrite history], and **RTINIT (172 routine sets)** [banked: twenty movement/battle step banks, provider joins, and overwrite history]. 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 cover conditions, BGM, turn/replay/clear-reward settings, and map texture overrides. The object join assembles 2,312 placements with type/position/gates, OBINIT names and available descriptions, 604 reinforcement schedules, and typed initial-faction, teleport, treasure, card-list, non-triggering-faction, and initial-object-state payloads; FIELD's dedicated special-spawn path proves the final three populated type-27 tagged writes are engine-dead, so they remain visible as ignored provenance rather than unresolved semantics. The 30-cell enemy family contributes 1,378 joined spawns with unit/faction, placement, difficulty/story gates, level scaling, weighted selection, difficulty-specific movement/battle routine sets, and 485 first-clear-only gates. Raw address views remain alongside generated `semantic_fields`. - [x] **2.3a — Extract `CCINIT`'s 71 class-change rules.** Rules mode preserves source-order guards and detects the unit id, minimum level, clear applied-state slot, selected title/level, deployment-cost delta, named fourteen-stat bonuses, awarded SKINIT skills, and state slot set by each rule. EBINIT/SKINIT definition joins and the common global semantic projection coexist with raw addresses. CALCCC/ADDEXP establish the apply/report ABI; EVOLVE explains the two level-independent empty-title Lily rules. The generated profile covers 33 units, 69 titled rules, 30 skill awards, three used promotion slots, and 19 populated output fields. - [x] **2.3b — Extract `SCINIT`'s scene-dispatch registry.** Dispatch mode recognizes 2,179 alternating writes to two 10,000-cell arrays, preserves all 710 overwritten decision ids, and emits 1,209 final rows. The primary column maps decisions to 135 packed SYS4INI ids, all resolved to numbered SC scripts; the parallel column is authored chapter metadata. All 847 live SCJUMP decisions join to rows and 844 final chapter tags agree with the independently decoded paths; three legacy/stale mismatches remain explicit. @@ -61,8 +61,9 @@ - [x] **2.3d — Extract `ALINIT`'s alchemy recipes.** Numeric specialization recognizes seven recipe-indexed structures and emits 107 sparse recipes from all 914 static writes. ALCHEMY proves the output, minimum level, required/forbidden story gates, point cost, and four paired ingredient-id/quantity slots. Every output and all 286 ingredient references join to ITINIT; raw coordinates remain beside the semantic and nested ingredient views. - [x] **2.3e — Extract `AFINIT` affinity/progression data and `CTINIT` name-entry characters.** AFINIT classifies 27 element strings and 54 footer arrays into thirteen signed effectiveness rows, paired tuning bonus/cost curves, and three facility-progress rows. CTINIT preserves 273 authored characters and 77 empty cells across INPUTNAME's five reserved 70-cell pages. Both schemas account for every instruction and retain raw coordinates. - [x] **2.3f — Extract `CVINIT` character-voice settings.** Numeric specialization accounts for all 37 static writes as thirteen preview OGG assets, twelve CONFIG-setting-slot-to-EBINIT-unit joins, and the exact inverse unit-to-suppression-setting map. CONFIG proves preview, row-label, persisted speaker-seen unlock, and setting-edit behavior; the shared CNINIT/CVINIT voice chain proves the inverse map. +- [x] **2.3g — Extract `MPINIT`'s stage-terrain atlas.** Footer specialization classifies all 1,472 row copies as columns 1..50 of one stride-53 sparse atlas, with destination-derived grid Y=2..1600 and 127 implicit-zero rows. FIELD proves that STINIT2's tile bounds are doubled before copying into the mutable current-stage grid. The schema joins 66 stage definitions to 53 unique rectangles, preserves eight shared-map groups and 47 border-context cells, and resolves terrain ids through LAINIT's names, texture slots, area-fill flags, and layout classes. - [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: RTINIT is closed at 1,043/1,043 movement steps; the RECOVER/ILINIT condition ABI, ALINIT recipes, AFINIT affinity/progression tables, CTINIT name palette, and CVINIT character-voice registry are closed; ITMES/SKMES/VIMES/EIMES/CIMES/MAMES are joined to their definitions; and the non-table INFOMES/MES ABIs are classified. Next, audit MPINIT's structurally regular footer rows by their direct readers, then use Frida only for semantics static consumers cannot settle. Full detail: `docs/name-resolution.md` → "Future step — growing the map". Packed `call-script` ids, SCJUMP decision-to-scene dispatch, the shipped RTINIT movement-provider join, and the six completed message joins are resolved. +- [ ] **2.5 — Grow the global-var map (future, incremental).** Static first: RTINIT is closed at 1,043/1,043 movement steps; the RECOVER/ILINIT condition ABI, ALINIT recipes, AFINIT affinity/progression tables, CTINIT name palette, CVINIT character-voice registry, and MPINIT stage-terrain atlas are closed; ITMES/SKMES/VIMES/EIMES/CIMES/MAMES are joined to their definitions; and the non-table INFOMES/MES ABIs are classified. Next, finish LAINIT's remaining small terrain-definition columns by their FIELD/MVSEEK/DRAWMAP consumers, then use Frida only for semantics static consumers cannot settle. Full detail: `docs/name-resolution.md` → "Future step — growing the map". Packed `call-script` ids, SCJUMP decision-to-scene dispatch, the shipped RTINIT movement-provider join, and the six completed message joins are resolved. ## Phase 3 — Name the unnamed opcodes *(top ~20 BEFORE Phase 4; the rest on demand)* diff --git a/tools/extract_init.py b/tools/extract_init.py index d225def..8afd8b3 100644 --- a/tools/extract_init.py +++ b/tools/extract_init.py @@ -25,6 +25,10 @@ levels, joined to the runtime condition-state ABI and RECOVER policy. CVINIT is a special numeric-mode registry: thirteen voice-configuration preview slots, twelve slot-to-unit joins, and the matching unit-to-setting inverse map. +MPINIT is a special footer-mode terrain atlas: each footer copy owns the fifty +authored cells of one 53-cell half-tile grid row. STINIT2's per-stage tile +bounds select rectangles after multiplying both coordinates by two. + Records are {id, name?, desc?, fields:{"0x": value}} or, for footer tables, {id, global_addr, footer_off, values:[...]}. Column addresses are raw engine globals; confirmed names come from the generated engine global registry while raw keys remain provenance. @@ -644,6 +648,23 @@ RECOVER_REMAINING_TURNS = 0x5295F RECOVER_BASELINE_LEVELS = 0x52F3B RECOVER_POLICY = 0xAACB4 +MAP_TERRAIN_ATLAS_BASE = 0xCCC93 +MAP_TERRAIN_CURRENT_BASE = 0x341AB +MAP_GRID_ROW_STRIDE = 53 +MAP_GRID_FIRST_COLUMN = 1 +MAP_GRID_AUTHORED_COLUMNS = 50 +MAP_TILE_TO_GRID_SCALE = 2 +MAP_STAGE_MIN_X = 0xEC4DD +MAP_STAGE_MAX_X = 0xEC8C5 +MAP_STAGE_MIN_Y = 0xECCAD +MAP_STAGE_MAX_Y = 0xED095 + +TERRAIN_NAME_BASE = 0x26B5 +TERRAIN_TEXTURE_SLOT_BASE = 0xE6AA4 +TERRAIN_AREA_FILL_BASE = 0xE6AC2 +TERRAIN_LAYOUT_CLASS_BASE = 0xE6AE0 +TERRAIN_DEFINITION_SPAN = 30 + def resolve(name: str) -> Path: for cand in (paths.GAME_DIR / f"{name}.BIN", paths.DATA1 / f"{name}.BIN"): @@ -3291,6 +3312,312 @@ def extract_footer(scr): return records, {} +def _terrain_definitions(max_terrain_id: int) -> list[dict]: + """Decode the LAINIT fields consumed by MPINIT's terrain ids.""" + terrain_scr = sys4load.load(resolve("LAINIT")) + names: dict[int, str] = {} + arrays = { + "texture_slot_index": (TERRAIN_TEXTURE_SLOT_BASE, {}), + "area_fill_flag": (TERRAIN_AREA_FILL_BASE, {}), + "layout_class": (TERRAIN_LAYOUT_CLASS_BASE, {}), + } + for ins in terrain_scr.instructions: + if ( + ins.opcode == SET_STRING + and len(ins.args) >= 2 + and ins.args[0][0] == T_GLOBAL_STRING + ): + terrain_id = ins.args[0][1] - TERRAIN_NAME_BASE + if 0 <= terrain_id < TERRAIN_DEFINITION_SPAN: + names[terrain_id] = terrain_scr.strings[ins.args[1][1]][0] + write = _static_global_write(ins) + if write is None: + continue + destination, value = write + for _, (base, cells) in arrays.items(): + terrain_id = destination - base + if 0 <= terrain_id < TERRAIN_DEFINITION_SPAN: + cells[terrain_id] = value + + layout_class_names = { + 0: "blocked_or_boundary", + 1: "open_area", + 2: "passage", + 3: "hidden", + } + definitions = [] + for terrain_id in range(max_terrain_id + 1): + texture_slot = arrays["texture_slot_index"][1].get(terrain_id, 0) + area_fill = arrays["area_fill_flag"][1].get(terrain_id, 0) + layout_class = arrays["layout_class"][1].get(terrain_id, 0) + definitions.append({ + "id": terrain_id, + "name": names.get(terrain_id), + "texture_slot_index": texture_slot, + "area_fill_flag": area_fill, + "layout_class": layout_class, + "layout_class_name": layout_class_names.get( + layout_class, "unknown" + ), + }) + return definitions + + +def _map_stage_definitions() -> list[dict]: + """Read the STINIT2 records that own all four terrain-atlas bounds.""" + stage_scr = sys4load.load(resolve("STINIT2")) + stage_records, _ = extract_name(stage_scr) + bounds = ( + MAP_STAGE_MIN_X, + MAP_STAGE_MAX_X, + MAP_STAGE_MIN_Y, + MAP_STAGE_MAX_Y, + ) + definitions = [] + for record in stage_records: + fields = record.get("fields", {}) + keys = [f"0x{address:x}" for address in bounds] + if not all(key in fields for key in keys): + continue + min_x, max_x, min_y, max_y = (fields[key] for key in keys) + definitions.append({ + "id": record["id"], + "name": record.get("name", ""), + "tile_bounds": { + "min_x": min_x, + "max_x": max_x, + "min_y": min_y, + "max_y": max_y, + }, + "grid_bounds": { + "min_x": min_x * MAP_TILE_TO_GRID_SCALE, + "max_x": max_x * MAP_TILE_TO_GRID_SCALE, + "min_y": min_y * MAP_TILE_TO_GRID_SCALE, + "max_y": max_y * MAP_TILE_TO_GRID_SCALE, + }, + }) + return definitions + + +def extract_map_terrain_atlas(scr): + """Extract MPINIT's sparse 53-column, doubled-coordinate terrain atlas.""" + rows = [] + rows_by_y: dict[int, list[int]] = {} + classified_offsets = set() + for ins in scr.instructions: + if ( + ins.opcode != COPY_LOCAL_ARRAY + or len(ins.args) < 2 + or ins.args[0][0] != T_GLOBAL_INT + ): + continue + destination = ins.args[0][1] + footer_off = ins.args[1][1] + values = read_footer_array(scr, footer_off) + if values is None: + raise ValueError( + f"{scr.path.name}: invalid terrain row footer 0x{footer_off:x}" + ) + delta = destination - MAP_TERRAIN_ATLAS_BASE + grid_y, grid_x = divmod(delta, MAP_GRID_ROW_STRIDE) + if grid_x != MAP_GRID_FIRST_COLUMN: + raise ValueError( + f"{scr.path.name}: terrain row at 0x{destination:x} starts " + f"in grid column {grid_x}, expected {MAP_GRID_FIRST_COLUMN}" + ) + if len(values) != MAP_GRID_AUTHORED_COLUMNS: + raise ValueError( + f"{scr.path.name}: terrain row {grid_y} has {len(values)} " + f"cells, expected {MAP_GRID_AUTHORED_COLUMNS}" + ) + if grid_y in rows_by_y: + raise ValueError( + f"{scr.path.name}: duplicate terrain row {grid_y}" + ) + rows_by_y[grid_y] = values + classified_offsets.add(ins.offset) + rows.append({ + "id": grid_y, + "grid_y": grid_y, + "grid_x": grid_x, + "global_addr": f"0x{destination:x}", + "footer_off": f"0x{footer_off:x}", + "length": len(values), + "values": values, + "nonzero_cell_count": sum(value != 0 for value in values), + "terrain_ids_used": sorted(set(values) - {0}), + }) + + exit_offsets = { + ins.offset + for ins in scr.instructions + if sys4load.display_label(ins.opcode) == "exit" + } + classified_offsets.update(exit_offsets) + unclassified = [ + f"0x{ins.offset:x}" + for ins in scr.instructions + if ins.offset not in classified_offsets + ] + if unclassified: + raise ValueError( + f"{scr.path.name}: unclassified instructions " + + ", ".join(unclassified) + ) + if not rows: + raise ValueError(f"{scr.path.name}: no terrain rows") + + max_terrain_id = max( + value for values in rows_by_y.values() for value in values + ) + terrain_definitions = _terrain_definitions(max_terrain_id) + terrain_names = { + definition["id"]: definition["name"] + for definition in terrain_definitions + } + stage_maps = [] + rectangle_stage_ids: dict[ + tuple[int, int, int, int], list[int] + ] = collections.defaultdict(list) + covered_nonzero_cells = set() + for stage in _map_stage_definitions(): + bounds = stage["grid_bounds"] + min_x = bounds["min_x"] + max_x = bounds["max_x"] + min_y = bounds["min_y"] + max_y = bounds["max_y"] + rectangle = (min_x, max_x, min_y, max_y) + rectangle_stage_ids[rectangle].append(stage["id"]) + terrain_rows = [] + value_counts = collections.Counter() + for grid_y in range(min_y, max_y + 1): + atlas_row = rows_by_y.get( + grid_y, [0] * MAP_GRID_AUTHORED_COLUMNS + ) + terrain_ids = atlas_row[min_x - 1:max_x] + terrain_rows.append({ + "grid_y": grid_y, + "terrain_ids": terrain_ids, + }) + value_counts.update(terrain_ids) + covered_nonzero_cells.update( + (grid_x, grid_y) + for grid_x, value in enumerate(terrain_ids, min_x) + if value != 0 + ) + used_ids = sorted(value for value in value_counts if value != 0) + stage_maps.append({ + **stage, + "tile_width": ( + stage["tile_bounds"]["max_x"] + - stage["tile_bounds"]["min_x"] + + 1 + ), + "tile_height": ( + stage["tile_bounds"]["max_y"] + - stage["tile_bounds"]["min_y"] + + 1 + ), + "grid_width": max_x - min_x + 1, + "grid_height": max_y - min_y + 1, + "terrain_ids_used": used_ids, + "terrain_names_used": [ + terrain_names.get(terrain_id) for terrain_id in used_ids + ], + "terrain_id_counts": { + str(terrain_id): count + for terrain_id, count in sorted(value_counts.items()) + }, + "terrain_rows": terrain_rows, + }) + + all_nonzero_cells = { + (grid_x, grid_y) + for grid_y, values in rows_by_y.items() + for grid_x, value in enumerate(values, MAP_GRID_FIRST_COLUMN) + if value != 0 + } + missing_rows = sorted( + set(range(min(rows_by_y), max(rows_by_y) + 1)) - set(rows_by_y) + ) + shared_rectangles = [ + { + "grid_bounds": { + "min_x": rectangle[0], + "max_x": rectangle[1], + "min_y": rectangle[2], + "max_y": rectangle[3], + }, + "stage_ids": stage_ids, + } + for rectangle, stage_ids in sorted(rectangle_stage_ids.items()) + if len(stage_ids) > 1 + ] + return rows, { + "schema": "stage-terrain-atlas", + "atlas_base": f"0x{MAP_TERRAIN_ATLAS_BASE:x}", + "current_stage_grid_base": f"0x{MAP_TERRAIN_CURRENT_BASE:x}", + "row_stride": MAP_GRID_ROW_STRIDE, + "first_authored_column": MAP_GRID_FIRST_COLUMN, + "authored_column_count": MAP_GRID_AUTHORED_COLUMNS, + "tile_to_grid_scale": MAP_TILE_TO_GRID_SCALE, + "authored_grid_y_min": min(rows_by_y), + "authored_grid_y_max": max(rows_by_y), + "authored_row_count": len(rows), + "implicit_zero_rows": missing_rows, + "implicit_zero_row_count": len(missing_rows), + "authored_cell_count": len(rows) * MAP_GRID_AUTHORED_COLUMNS, + "nonzero_cell_count": len(all_nonzero_cells), + "stage_rectangle_nonzero_cell_count": len(covered_nonzero_cells), + "outside_stage_rectangle_nonzero_cell_count": len( + all_nonzero_cells - covered_nonzero_cells + ), + "terrain_ids_used": sorted({ + value + for values in rows_by_y.values() + for value in values + }), + "terrain_definitions": terrain_definitions, + "stage_metadata_source": "STINIT2.BIN", + "stage_bounds_arrays": { + "min_tile_x": f"0x{MAP_STAGE_MIN_X:x}", + "max_tile_x": f"0x{MAP_STAGE_MAX_X:x}", + "min_tile_y": f"0x{MAP_STAGE_MIN_Y:x}", + "max_tile_y": f"0x{MAP_STAGE_MAX_Y:x}", + }, + "stage_map_count": len(stage_maps), + "unique_atlas_rectangle_count": len(rectangle_stage_ids), + "shared_atlas_rectangles": shared_rectangles, + "stage_maps": stage_maps, + "footer_array_count": len(rows), + "footer_array_columns": [f"0x{MAP_TERRAIN_ATLAS_BASE:x}"], + "array_layouts": { + f"0x{MAP_TERRAIN_ATLAS_BASE:x}": { + "stride": MAP_GRID_ROW_STRIDE, + "first_authored_column": MAP_GRID_FIRST_COLUMN, + "authored_columns": MAP_GRID_AUTHORED_COLUMNS, + } + }, + "schema_field_semantics": { + f"0x{MAP_TERRAIN_ATLAS_BASE:x}": "stage_terrain_atlas", + }, + "consumer_contract": { + "FIELD.BIN": ( + "clear the 2000-by-53 current-stage grid, double the selected " + "STINIT2 tile bounds, and copy that atlas rectangle into it" + ), + "DRAWMINIMAP.BIN": ( + "read the current-stage grid inside the selected bounds and " + "fall back to the immutable atlas outside them for border context" + ), + "RESETLAND.BIN": ( + "restore a changed current-stage terrain cell from the atlas" + ), + }, + "classified_instruction_count": len(classified_offsets), + } + + def join_messages(records: list[dict], message_scr) -> dict: """Join a message-dispatch script to INIT records by runtime id.""" messages, message_meta = extract_message_table.extract_messages(message_scr) @@ -3642,6 +3969,11 @@ def write_data_index(data_dir: Path) -> None: "clips, twelve slot-to-unit joins, and the matching unit-to-suppression-setting", "inverse map used by story, history, field, and battle voice filters.", "", + "MPINIT's dedicated terrain-atlas schema exposes 1,472 authored rows of a sparse", + "53-column half-tile grid. It joins STINIT2's doubled tile-bound rectangles to 66", + "stage definitions, preserves implicit-zero rows and raw footer provenance, and", + "links the used terrain ids to LAINIT's names and rendering/layout classes.", + "", "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 confirmed parallel buffers into per-slot `object_placements`", @@ -3727,6 +4059,8 @@ def main() -> int: extractor = extract_alchemy_recipes elif mode == "numeric" and name == "CVINIT": extractor = extract_voice_configuration + elif mode == "footer" and name == "MPINIT": + extractor = extract_map_terrain_atlas recs, meta = extractor(scr) if mode == "name" and name in MESSAGE_TABLES: message_name = MESSAGE_TABLES[name] @@ -3735,7 +4069,10 @@ def main() -> int: ) cols = sorted({c for r in recs for c in r.get("fields", {})}, key=lambda h: int(h, 16)) - semantics = field_semantics(recs, meta.get("array_layouts")) + semantics = { + **meta.pop("schema_field_semantics", {}), + **field_semantics(recs, meta.get("array_layouts")), + } attach_semantic_fields(recs, semantics) if mode == "mixed" and name == "STINIT": meta["object_definition_table"] = "OBINIT" diff --git a/tools/init_table_profile.py b/tools/init_table_profile.py index 37ce7fa..0e8f8af 100644 --- a/tools/init_table_profile.py +++ b/tools/init_table_profile.py @@ -48,7 +48,7 @@ def load_table(name: str) -> dict: raise SystemExit(f"missing extracted table: {path}") data = json.loads(path.read_text(encoding="utf8")) if data.get("mode") not in { - "name", "numeric", "mixed", "rules", "dispatch", "banked" + "name", "numeric", "footer", "mixed", "rules", "dispatch", "banked" }: raise SystemExit(f"{name}: unsupported field-profiling mode {data.get('mode')!r}") return data @@ -279,6 +279,38 @@ def profile_banked(data: dict) -> dict: } +def profile_map_atlas(data: dict) -> dict: + """Summarize MPINIT's sparse terrain rows and STINIT2 rectangle join.""" + if data.get("schema") != "stage-terrain-atlas": + return {} + shared = data.get("shared_atlas_rectangles", []) + return { + "row_stride": data.get("row_stride", 0), + "authored_column_count": data.get("authored_column_count", 0), + "tile_to_grid_scale": data.get("tile_to_grid_scale", 0), + "authored_row_count": data.get("authored_row_count", 0), + "implicit_zero_row_count": data.get("implicit_zero_row_count", 0), + "authored_grid_y_min": data.get("authored_grid_y_min", 0), + "authored_grid_y_max": data.get("authored_grid_y_max", 0), + "nonzero_cell_count": data.get("nonzero_cell_count", 0), + "stage_rectangle_nonzero_cell_count": data.get( + "stage_rectangle_nonzero_cell_count", 0 + ), + "outside_stage_rectangle_nonzero_cell_count": data.get( + "outside_stage_rectangle_nonzero_cell_count", 0 + ), + "terrain_ids_used": data.get("terrain_ids_used", []), + "stage_map_count": data.get("stage_map_count", 0), + "unique_atlas_rectangle_count": data.get( + "unique_atlas_rectangle_count", 0 + ), + "shared_rectangle_count": len(shared), + "shared_stage_definition_count": sum( + len(row.get("stage_ids", [])) for row in shared + ), + } + + def profile_messages(data: dict) -> dict: """Summarize the joined player-facing message evidence.""" records = data["records"] @@ -406,7 +438,27 @@ def render_markdown(data: dict, rows: list[dict], limit: int) -> str: f"- records: {data['record_count']}", f"- populated fields: {len(rows)}", ] - if rule_profile := profile_rules(data): + if map_profile := profile_map_atlas(data): + lines.extend([ + f"- geometry: {map_profile['authored_column_count']} authored cells " + f"inside a {map_profile['row_stride']}-cell row pitch", + f"- coordinate scale: one tile = " + f"{map_profile['tile_to_grid_scale']} grid cells", + f"- authored rows: {map_profile['authored_row_count']} across grid Y " + f"{map_profile['authored_grid_y_min']}.." + f"{map_profile['authored_grid_y_max']} " + f"({map_profile['implicit_zero_row_count']} omitted zero rows)", + f"- nonzero cells: {map_profile['nonzero_cell_count']} " + f"({map_profile['stage_rectangle_nonzero_cell_count']} inside stage " + f"rectangles, " + f"{map_profile['outside_stage_rectangle_nonzero_cell_count']} border cells)", + f"- terrain ids: {map_profile['terrain_ids_used']}", + f"- stage joins: {map_profile['stage_map_count']} definitions over " + f"{map_profile['unique_atlas_rectangle_count']} unique rectangles", + f"- shared rectangles: {map_profile['shared_rectangle_count']} used by " + f"{map_profile['shared_stage_definition_count']} stage definitions", + ]) + elif rule_profile := profile_rules(data): lines.extend([ f"- covered units: {rule_profile['unit_count']}", f"- titled rules: {rule_profile['titled_rule_count']}/{data['record_count']}", @@ -512,13 +564,18 @@ def main() -> int: "scalar_field_count": sum(row["kind"] == "scalar-field" for row in rows), "string_field_count": sum(row["kind"] == "string-field" for row in rows), "array_cell_count": sum(row["kind"] == "array-cell" for row in rows), - "footer_array_count": sum(row["kind"] == "footer-array" for row in rows), + "footer_array_count": ( + data.get("footer_array_count", 0) + if data.get("schema") == "stage-terrain-atlas" + else sum(row["kind"] == "footer-array" for row in rows) + ), "rule_output_count": sum(row["kind"] == "rule-output" for row in rows), "dispatch_field_count": sum(row["kind"] == "dispatch-field" for row in rows), "message_profile": messages, "rule_profile": profile_rules(data), "dispatch_profile": profile_dispatch(data), "banked_profile": profile_banked(data), + "map_atlas_profile": profile_map_atlas(data), "columns": sorted(rows, key=lambda row: ( int(row["base"], 16), row["stride"] or 0, row["column"] or 0 )), diff --git a/tools/test_extract_init.py b/tools/test_extract_init.py index d92b675..748922a 100644 --- a/tools/test_extract_init.py +++ b/tools/test_extract_init.py @@ -1359,6 +1359,96 @@ def test_voice_configuration() -> None: ) +def test_map_terrain_atlas() -> None: + scripts = paths.scripts() + script = sys4load.load(scripts["MPINIT.BIN"]) + check( + extract_init.detect_mode(script) == "footer", + "MPINIT remains compatible with footer-mode auto-detection", + ) + records, meta = extract_init.extract_map_terrain_atlas(script) + by_y = {record["grid_y"]: record for record in records} + check( + len(records) == 1472 + and meta["footer_array_count"] == 1472 + and meta["classified_instruction_count"] == 1473, + "MPINIT classifies every terrain-row footer copy and exit instruction", + ) + check( + meta["atlas_base"] == "0xccc93" + and meta["row_stride"] == 53 + and meta["first_authored_column"] == 1 + and meta["authored_column_count"] == 50 + and meta["tile_to_grid_scale"] == 2, + "MPINIT exposes its 53-cell row pitch and doubled tile coordinate system", + ) + check( + meta["authored_grid_y_min"] == 2 + and meta["authored_grid_y_max"] == 1600 + and meta["implicit_zero_row_count"] == 127 + and all(record["length"] == 50 for record in records), + "MPINIT preserves all authored rows and the omitted all-zero row gaps", + ) + check( + by_y[2]["global_addr"] == "0xcccfe" + and by_y[1600]["global_addr"] == "0xe17d4" + and by_y[1199]["terrain_ids_used"] == [6] + and by_y[1199]["nonzero_cell_count"] == 25, + "MPINIT row coordinates recover directly from destination addresses", + ) + + definitions = {row["id"]: row for row in meta["terrain_definitions"]} + check( + definitions[1]["name"] == "通路" + and definitions[1]["layout_class_name"] == "passage" + and definitions[2]["name"] == "部屋" + and definitions[2]["area_fill_flag"] == 1 + and definitions[3]["name"] == "隠し通路" + and definitions[3]["layout_class_name"] == "hidden" + and definitions[15]["name"] == "溶岩流" + and definitions[15]["texture_slot_index"] == 9, + "MPINIT terrain ids join to LAINIT names and layout/render classes", + ) + + stage_maps = {stage["id"]: stage for stage in meta["stage_maps"]} + stage1 = stage_maps[1] + check( + meta["stage_map_count"] == 66 + and meta["unique_atlas_rectangle_count"] == 53 + and stage1["name"] == "『庭園の地下空洞』" + and stage1["tile_bounds"] + == {"min_x": 9, "max_x": 17, "min_y": 1, "max_y": 8} + and stage1["grid_bounds"] + == {"min_x": 18, "max_x": 34, "min_y": 2, "max_y": 16}, + "STINIT2 bounds join 66 stage definitions to their doubled atlas rectangles", + ) + check( + stage1["grid_width"] == 17 + and stage1["grid_height"] == 15 + and stage1["terrain_ids_used"] == [1, 2, 4, 12] + and stage1["terrain_id_counts"] + == {"0": 212, "1": 12, "2": 13, "4": 2, "12": 16}, + "stage joins expose complete terrain grids and value populations", + ) + check( + any( + shared["stage_ids"] == [32, 33, 34] + for shared in meta["shared_atlas_rectangles"] + ) + and any( + shared["stage_ids"] == [101, 104, 106, 107, 108] + for shared in meta["shared_atlas_rectangles"] + ), + "MPINIT preserves intentional atlas sharing across stage variants", + ) + check( + meta["nonzero_cell_count"] == 17126 + and meta["stage_rectangle_nonzero_cell_count"] == 17079 + and meta["outside_stage_rectangle_nonzero_cell_count"] == 47, + "MPINIT accounts for stage terrain and the raw border-context cells", + ) + + def test_condition_definitions() -> None: scripts = paths.scripts() script = sys4load.load(scripts["ILINIT.BIN"]) @@ -1573,6 +1663,7 @@ if __name__ == "__main__": test_affinity_definitions() test_name_entry_palette() test_voice_configuration() + test_map_terrain_atlas() test_condition_definitions() test_field_semantics() if FAILS: diff --git a/tools/test_init_table_profile.py b/tools/test_init_table_profile.py index a4e6600..dd20284 100644 --- a/tools/test_init_table_profile.py +++ b/tools/test_init_table_profile.py @@ -174,6 +174,41 @@ def main() -> int: assert banked_summary["decoded_movement_defaulted_parameter_count"] == 1 assert banked_summary["ignored_movement_parameter_count"] == 1 + map_fixture = { + "table": "MAP", + "mode": "footer", + "schema": "stage-terrain-atlas", + "record_count": 1472, + "row_stride": 53, + "authored_column_count": 50, + "tile_to_grid_scale": 2, + "authored_row_count": 1472, + "implicit_zero_row_count": 127, + "authored_grid_y_min": 2, + "authored_grid_y_max": 1600, + "nonzero_cell_count": 17126, + "stage_rectangle_nonzero_cell_count": 17079, + "outside_stage_rectangle_nonzero_cell_count": 47, + "terrain_ids_used": [0, 1, 2, 3], + "stage_map_count": 66, + "unique_atlas_rectangle_count": 53, + "shared_atlas_rectangles": [ + {"stage_ids": [32, 33, 34]}, + {"stage_ids": [35, 36]}, + ], + "records": [], + } + map_summary = profile.profile_map_atlas(map_fixture) + assert map_summary["row_stride"] == 53 + assert map_summary["authored_row_count"] == 1472 + assert map_summary["outside_stage_rectangle_nonzero_cell_count"] == 47 + assert map_summary["stage_map_count"] == 66 + assert map_summary["shared_rectangle_count"] == 2 + assert map_summary["shared_stage_definition_count"] == 5 + rendered_map = profile.render_markdown(map_fixture, [], 40) + assert "- geometry: 50 authored cells inside a 53-cell row pitch" in rendered_map + assert "- stage joins: 66 definitions over 53 unique rectangles" in rendered_map + messages = profile.profile_messages(fixture) assert messages["population"] == 1 assert messages["coverage"] == 1 / 3 diff --git a/vm-map/globals.toml b/vm-map/globals.toml index 6437858..e343232 100644 --- a/vm-map/globals.toml +++ b/vm-map/globals.toml @@ -3077,6 +3077,115 @@ usage = "FIELD's already-cleared-stage override gate. For a cleared stage with v source = "investigation" confidence = "high" +[[global]] +address = "0x26b5" +name = "terrain_type_names" +category = "string-table" +type = "string[30]" +value_domain = "LAINIT terrain labels for ids 1..4 and 7..19; ids 0, 5, and 6 are unnamed" +usage = "LAINIT's terrain vocabulary. MPINIT's atlas cells contain terrain ids 0..19; the named ids distinguish passages, rooms, hidden spaces, bases, water, openings, altars, lava, and themed room variants." +source = "investigation" +confidence = "high" + +[[global]] +address = "0xe6aa4" +name = "terrain_texture_slot_indices" +category = "data-table" +type = "int[30]" +value_domain = "stage map texture slot index 0..13" +usage = "LAINIT mapping from terrain id to the index in stage_map_texture_asset_overrides. DRAWMAP uses the selected slot to choose the current stage's tiled terrain surface." +source = "investigation" +confidence = "high" +depends_on = ["0x26b5", "0xe7311"] + +[[global]] +address = "0xe6ac2" +name = "terrain_area_fill_flags" +category = "data-table" +type = "int[30]" +value_domain = "{0,1}; 1 marks room/area-style fill, 0 marks passage/boundary-style geometry" +usage = "LAINIT terrain topology flag. DRAWMAP and CALCOCC use neighboring values to expand room-like regions across the alternating half-tile grid; DRAWMINIMAP uses the same distinction when joining adjacent revealed cells." +source = "investigation" +confidence = "high" +depends_on = ["0x26b5"] + +[[global]] +address = "0xe6ae0" +name = "terrain_layout_classes" +category = "data-table" +type = "int[30]" +value_domain = "{0=blocked_or_boundary, 1=open_area, 2=passage, 3=hidden}" +usage = "LAINIT terrain class. Zero cells are rejected by map rendering, occupancy, and pathfinding; ordinary areas, passages, and hidden spaces take distinct DRAWMAP/CALCOCC/MVSEEK paths. Combined with terrain_area_fill_flags, it distinguishes hidden rooms from hidden passages." +source = "investigation" +confidence = "high" +depends_on = ["0x26b5", "0xe6ac2"] + +[[global]] +address = "0xccc93" +name = "stage_terrain_atlas" +category = "data-table" +type = "int[][53]" +value_domain = "terrain type id 0..19; MPINIT authors columns 1..50 of 1,472 nonzero rows through grid Y 1600" +usage = "Immutable sparse half-tile terrain atlas loaded by MPINIT. Each footer copy writes fifty cells at row stride 53. FIELD doubles STINIT2's tile bounds and copies the selected rectangle into current_stage_terrain_grid; DRAWMINIMAP reads the atlas outside the active rectangle for border context, and RESETLAND restores changed cells from it." +source = "investigation" +confidence = "high" +depends_on = ["0x26b5", "0xec4dd", "0xec8c5", "0xeccad", "0xed095"] + +[[global]] +address = "0x341ab" +name = "current_stage_terrain_grid" +category = "data-table" +type = "int[2000][53]" +value_domain = "mutable terrain type id 0..19 on the doubled-coordinate half-tile grid" +usage = "Current stage's mutable terrain grid. FIELD clears all 2,000 rows and copies the selected stage_terrain_atlas rectangle; rendering, minimap, occupancy, battle, and movement scripts read it, while SETLAND/DELLAND alter cells and RESETLAND restores atlas values." +source = "investigation" +confidence = "high" +depends_on = ["0xccc93"] + +[[global]] +address = "0xec4dd" +name = "stage_map_min_tile_x" +category = "data-table" +type = "int[1000]" +value_domain = "inclusive tile X minimum, 1..21 in shipped mapped stages" +usage = "STINIT2's inclusive left map bound indexed by stage id. FIELD, DRAWMAP, DRAWMINIMAP, CALCOCC, and movement providers use it with stage_map_max_tile_x; terrain-grid accesses multiply the coordinate by two." +source = "investigation" +confidence = "high" +depends_on = ["0xccc93"] + +[[global]] +address = "0xec8c5" +name = "stage_map_max_tile_x" +category = "data-table" +type = "int[1000]" +value_domain = "inclusive tile X maximum, 6..25 in shipped mapped stages" +usage = "STINIT2's inclusive right map bound indexed by stage id. Consumers pair it with stage_map_min_tile_x for iteration, camera/minimap limits, random placement, and the doubled-coordinate terrain-atlas copy." +source = "investigation" +confidence = "high" +depends_on = ["0xec4dd"] + +[[global]] +address = "0xeccad" +name = "stage_map_min_tile_y" +category = "data-table" +type = "int[1000]" +value_domain = "inclusive tile Y minimum, 1..776 in shipped mapped stages" +usage = "STINIT2's inclusive top map bound indexed by stage id. FIELD and all map readers pair it with stage_map_max_tile_y and multiply it by two when addressing the half-tile terrain grid." +source = "investigation" +confidence = "high" +depends_on = ["0xccc93"] + +[[global]] +address = "0xed095" +name = "stage_map_max_tile_y" +category = "data-table" +type = "int[1000]" +value_domain = "inclusive tile Y maximum, 7..800 in shipped mapped stages" +usage = "STINIT2's inclusive bottom map bound indexed by stage id. The maximum shipped value 800 explains MPINIT's final authored doubled grid row at Y 1600." +source = "investigation" +confidence = "high" +depends_on = ["0xeccad"] + [[global]] address = "0xe7311" name = "stage_map_texture_asset_overrides"