diff --git a/docs/global-reference.md b/docs/global-reference.md index f5c58b9..0c3f3ea 100644 --- a/docs/global-reference.md +++ b/docs/global-reference.md @@ -1,7 +1,7 @@ # Global Variable Reference (generated) -5049 globals (329 curated, 4720 auto shape-inferred). Source of truth: `vm-map/globals.toml`. +5049 globals (333 curated, 4716 auto shape-inferred). Source of truth: `vm-map/globals.toml`. ## choice-output @@ -186,6 +186,9 @@ | `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. | +| `0xe6afe` | terrain_combat_stat_deltas | high | investigation | LAINIT terrain-effect matrix. CALCBTPARAM reads the acting unit's doubled-coordinate battle tile and adds columns 0..9 through the same combat-parameter channels used by SKINIT skill deltas. INFOAF displays the first eight columns for its terrain rows, and the five populated terrain_effect_descriptions exactly summarize the shipped nonzero cells. 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. | +| `0xe6c2a` | terrain_required_skill_ids | high | investigation | LAINIT traversal/reveal requirement indexed by terrain id. MVSEEK and FIELD reject ordinary terrain unless the moving unit owns the referenced SKINIT skill; hidden passages/rooms use the exploration skill through the dedicated hidden-terrain path. FIELD and INFOAF resolve populated ids to skill names for display. | +| `0xe6c48` | stage_map_texture_default_asset_ids | high | investigation | Shared fallback map texture for each of twenty stage texture slots. FIELD loads a positive stage_map_texture_asset_overrides value directly, skips zero, and when the stage value is -1 loads this slot's MP000A/B/C/E/H/I/J/K/L/N.AGF default into surface 0x52+slot. | | `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. | @@ -263,7 +266,6 @@ | `0x69e` | — | low | auto-shape | array | | `0x6fe` | — | low | auto-shape | array | | `0x712` | — | low | auto-shape | array | -| `0x26d3` | — | low | auto-shape | array | | `0x27bd` | — | low | auto-shape | array | | `0x3276` | — | low | auto-shape | array | | `0x328a` | — | low | auto-shape | array | @@ -332,8 +334,6 @@ | `0xccbf8` | — | low | auto-shape | array | | `0xccc11` | — | low | auto-shape | array | | `0xccc2f` | — | low | auto-shape | array | -| `0xe6c2a` | — | low | auto-shape | array | -| `0xe6c48` | — | low | auto-shape | array | | `0xe7305` | — | low | auto-shape | array | | `0xe730e` | — | low | auto-shape | array | | `0xe73bb` | — | low | auto-shape | array | @@ -482,6 +482,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. | +| `0x26d3` | terrain_effect_descriptions | high | investigation | LAINIT's player-facing terrain effect summary indexed by terrain id. FIELD shows it beneath the selected tile's terrain name; each populated string exactly describes that row of terrain_combat_stat_deltas. | | `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. | @@ -5051,7 +5052,6 @@ | `0xccbdc` | — | med | auto-shape | record-table[stride 14] | | `0xccc0b` | — | low | auto-shape | index/counter? | | `0xccc33` | — | med | auto-shape | record-table[stride 3] | -| `0xe6afe` | — | med | auto-shape | record-table[stride 10] | | `0xe6c5e` | — | med | auto-shape | obinit-field | | `0xe6cc2` | — | med | auto-shape | obinit-field | | `0xe6d26` | — | med | auto-shape | obinit-field | diff --git a/docs/name-resolution.md b/docs/name-resolution.md index 4c123da..a1dad18 100644 --- a/docs/name-resolution.md +++ b/docs/name-resolution.md @@ -680,9 +680,10 @@ The v1 map labels *shapes and tables*; the next increments add *meaning*, cheape five-page character palette, and CVINIT closes CONFIG's thirteen preview clips plus the 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; + while LAINIT closes all twenty shipped terrain definitions: names/effects, rendering/topology + columns, ten combat-stat columns, traversal-skill requirements, and shared map-texture assets. + With RTINIT's used-provider surface closed and ITMES/SKMES/VIMES/EIMES/CIMES/MAMES joined and + INFOMES/MES classified, audit SPINIT's stride-15 HMODE resource matrix 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 2538dd0..7167c98 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -2983,3 +2983,37 @@ stage joins, shared rectangles, terrain definitions, and the exact nonzero/borde **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. + +## Data-semantics sidebar: LAINIT terrain definitions (2026-07-23) + +LAINIT is now a dedicated twenty-terrain definition registry inside a reserved thirty-row layout. +Its 98 instructions divide exactly into seventeen terrain names, five player-facing effect +descriptions, 75 numeric writes, and `exit`. Terrain ids 0, 5, and 6 are meaningful implicit-default +rows used by MPINIT; the other shipped ids 1..19 are authored sparsely. The schema retains raw +addresses while projecting all defaults explicitly. + +The remaining row-major table at `0xe6afe` is a `30 × 10` signed combat-stat matrix with the same +column ABI as SKINIT's combat deltas: accuracy, evasion, physical attack/defense, magic +attack/defense, speed, luck, critical chance, and capture power. CALCBTPARAM reads the acting unit's +terrain from the doubled-coordinate current map and adds the selected row to its battle parameters. +INFOAF displays the rows. All thirteen populated cells agree exactly with LAINIT's five summaries: +the base raises accuracy/evasion/defenses, water and shallows lower accuracy/evasion/speed, the altar +raises accuracy/evasion, and the hall raises evasion. + +The sparse array at `0xe6c2a` is a required traversal/reveal skill id. MVSEEK and FIELD reject +ordinary terrain unless the moving unit owns the referenced SKINIT skill; the dedicated hidden +terrain path enforces the same exploration requirement for hidden passages and rooms. All five +requirements resolve: flight for the open shaft, diving for the underground lake, exploration for +both hidden terrain types, and heat resistance for lava. + +The final array at `0xe6c48` is indexed by the twenty stage texture slots rather than terrain id. +When STINIT's per-stage texture override is positive, FIELD loads it directly; zero disables the +slot; `-1` selects LAINIT's shared fallback. All ten authored fallbacks resolve through SYS4INI to +`MP000A/B/C/E/H/I/J/K/L/N.AGF`, and the terrain-to-slot table joins each terrain definition to the +applicable default. + +Regressions protect all 98 instructions, the 20-of-30 row contract, every sparse string and numeric +population, all thirteen combat-stat cells, all five SKINIT skill joins, and all ten texture assets. + +**Next:** audit SPINIT's 118 populated cells as the stride-15 HMODE resource matrix; the generic +numeric view currently fragments its eight logical rows into individual address-derived records. diff --git a/docs/script-inventory.md b/docs/script-inventory.md index 1d26ac5..58c0245 100644 --- a/docs/script-inventory.md +++ b/docs/script-inventory.md @@ -129,6 +129,13 @@ The dedicated view joins 66 named stage definitions to 53 unique rectangles, pre groups and 47 nonzero border-context cells, and resolves terrain ids through LAINIT's name, texture-slot, area-fill, and layout-class columns. +LAINIT defines the twenty shipped terrain ids inside a reserved thirty-row registry. Its sparse +strings provide seventeen names and five effect summaries; its numeric surface provides +texture-slot, area-fill, layout-class, required-skill, and ten-column combat-stat data. CALCBTPARAM +proves the stat ABI, MVSEEK/FIELD prove five SKINIT traversal requirements, and FIELD joins ten +shared `MP000*.AGF` fallback assets to the stage texture slots. The dedicated view accounts for all +98 instructions and preserves ids 0, 5, and 6 as meaningful implicit-default rows. + 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 6637377..83e8608 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -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. +Name-mode LAINIT classifies all 98 instructions into twenty shipped terrain definitions inside a +reserved thirty-row registry. It preserves seventeen names, five effect descriptions, four parallel +terrain arrays, the signed ten-column combat-stat matrix, and three implicit-default terrain ids. +Required skill ids join to SKINIT names; shared texture-slot fallbacks join to SYS4INI AGF filenames. +The profile summarizes those populations and the CALCBTPARAM/MVSEEK/FIELD/INFOAF/DRAWMAP contract. + 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 diff --git a/docs/vm-mapping-plan.md b/docs/vm-mapping-plan.md index 12448b2..29db023 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 (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.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], **LAINIT (20 shipped terrain definitions)** [name: sparse strings, topology/rendering, combat-stat, traversal-skill, and texture-fallback joins], **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. @@ -62,8 +62,9 @@ - [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.3h — Extract `LAINIT`'s terrain definitions.** Name specialization classifies all 98 instructions into twenty shipped terrain ids inside a reserved thirty-row registry: seventeen names, five effect descriptions, four parallel terrain arrays, a ten-column signed combat-stat matrix, five SKINIT traversal/reveal requirements, and ten SYS4INI-resolved shared texture fallbacks. CALCBTPARAM, MVSEEK, FIELD, INFOAF, and DRAWMAP prove the complete consumer contract. - [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, 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. +- [ ] **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, LAINIT terrain definitions, 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, decode SPINIT's 118 populated cells as HMODE's stride-15 resource matrix, 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 8afd8b3..b6042ec 100644 --- a/tools/extract_init.py +++ b/tools/extract_init.py @@ -660,10 +660,17 @@ MAP_STAGE_MIN_Y = 0xECCAD MAP_STAGE_MAX_Y = 0xED095 TERRAIN_NAME_BASE = 0x26B5 +TERRAIN_EFFECT_DESCRIPTION_BASE = 0x26D3 TERRAIN_TEXTURE_SLOT_BASE = 0xE6AA4 TERRAIN_AREA_FILL_BASE = 0xE6AC2 TERRAIN_LAYOUT_CLASS_BASE = 0xE6AE0 +TERRAIN_COMBAT_STAT_BASE = 0xE6AFE +TERRAIN_COMBAT_STAT_STRIDE = 10 +TERRAIN_REQUIRED_SKILL_BASE = 0xE6C2A +MAP_TEXTURE_DEFAULT_ASSET_BASE = 0xE6C48 +MAP_TEXTURE_SLOT_COUNT = 20 TERRAIN_DEFINITION_SPAN = 30 +TERRAIN_SHIPPED_ID_MAX = 19 def resolve(name: str) -> Path: @@ -3312,32 +3319,95 @@ 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")) +def extract_terrain_definitions(scr): + """Extract LAINIT's terrain definitions and shared texture-slot assets.""" names: dict[int, str] = {} - arrays = { + effect_descriptions: dict[int, str] = {} + parallel_arrays = { "texture_slot_index": (TERRAIN_TEXTURE_SLOT_BASE, {}), "area_fill_flag": (TERRAIN_AREA_FILL_BASE, {}), "layout_class": (TERRAIN_LAYOUT_CLASS_BASE, {}), + "required_skill_id": (TERRAIN_REQUIRED_SKILL_BASE, {}), } - for ins in terrain_scr.instructions: + combat_stat_cells: dict[tuple[int, int], int] = {} + texture_default_assets: dict[int, int] = {} + classified_offsets = set() + string_write_count = 0 + static_write_count = 0 + for ins in 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 + destination = ins.args[0][1] + value = scr.strings[ins.args[1][1]][0] + terrain_id = destination - TERRAIN_NAME_BASE if 0 <= terrain_id < TERRAIN_DEFINITION_SPAN: - names[terrain_id] = terrain_scr.strings[ins.args[1][1]][0] + _store_unique(names, terrain_id, value, terrain_id) + else: + terrain_id = destination - TERRAIN_EFFECT_DESCRIPTION_BASE + if not 0 <= terrain_id < TERRAIN_DEFINITION_SPAN: + raise ValueError( + f"{scr.path.name}: unclassified terrain string write " + f"0x{destination:x} at 0x{ins.offset:x}" + ) + _store_unique( + effect_descriptions, terrain_id, value, terrain_id + ) + classified_offsets.add(ins.offset) + string_write_count += 1 + continue + write = _static_global_write(ins) if write is None: continue + static_write_count += 1 destination, value = write - for _, (base, cells) in arrays.items(): + if not isinstance(value, int): + raise ValueError( + f"{scr.path.name}: non-static terrain value " + f"at 0x{ins.offset:x}" + ) + + classified = False + for _, (base, cells) in parallel_arrays.items(): terrain_id = destination - base if 0 <= terrain_id < TERRAIN_DEFINITION_SPAN: - cells[terrain_id] = value + _store_unique(cells, terrain_id, value, terrain_id) + classified = True + break + if not classified: + index = destination - TERRAIN_COMBAT_STAT_BASE + if 0 <= index < ( + TERRAIN_DEFINITION_SPAN * TERRAIN_COMBAT_STAT_STRIDE + ): + terrain_id, column = divmod( + index, TERRAIN_COMBAT_STAT_STRIDE + ) + _store_unique( + combat_stat_cells, + (terrain_id, column), + value, + terrain_id, + ) + classified = True + if not classified: + texture_slot = destination - MAP_TEXTURE_DEFAULT_ASSET_BASE + if 0 <= texture_slot < MAP_TEXTURE_SLOT_COUNT: + _store_unique( + texture_default_assets, + texture_slot, + value, + texture_slot, + ) + classified = True + if not classified: + raise ValueError( + f"{scr.path.name}: unclassified terrain write " + f"0x{destination:x} at 0x{ins.offset:x}" + ) + classified_offsets.add(ins.offset) layout_class_names = { 0: "blocked_or_boundary", @@ -3345,22 +3415,219 @@ def _terrain_definitions(max_terrain_id: int) -> list[dict]: 2: "passage", 3: "hidden", } + stat_columns = ( + "accuracy", + "evasion", + "physical_attack", + "physical_defense", + "magic_attack", + "magic_defense", + "speed", + "luck", + "critical_chance", + "capture_power", + ) + skill_records, _ = extract_name(sys4load.load(resolve("SKINIT"))) + skill_names = {record["id"]: record["name"] for record in skill_records} + asset_names = callscript_names() + + name_key = f"0x{TERRAIN_NAME_BASE:x}" + effect_key = f"0x{TERRAIN_EFFECT_DESCRIPTION_BASE:x}" + texture_key = f"0x{TERRAIN_TEXTURE_SLOT_BASE:x}" + fill_key = f"0x{TERRAIN_AREA_FILL_BASE:x}" + layout_key = f"0x{TERRAIN_LAYOUT_CLASS_BASE:x}" + stat_key = f"0x{TERRAIN_COMBAT_STAT_BASE:x}" + skill_key = f"0x{TERRAIN_REQUIRED_SKILL_BASE:x}" 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({ + for terrain_id in range(TERRAIN_SHIPPED_ID_MAX + 1): + texture_slot = parallel_arrays[ + "texture_slot_index" + ][1].get(terrain_id, 0) + area_fill = parallel_arrays["area_fill_flag"][1].get(terrain_id, 0) + layout_class = parallel_arrays["layout_class"][1].get(terrain_id, 0) + required_skill_id = parallel_arrays[ + "required_skill_id" + ][1].get(terrain_id, 0) + record = { "id": terrain_id, "name": names.get(terrain_id), + "effect_description": effect_descriptions.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" ), + "required_skill_id": required_skill_id, + "required_skill_name": skill_names.get(required_skill_id), + "fields": {}, + "string_fields": {}, + "record_fields": {}, + } + if terrain_id in names: + record["string_fields"][name_key] = names[terrain_id] + if terrain_id in effect_descriptions: + record["string_fields"][effect_key] = ( + effect_descriptions[terrain_id] + ) + for field_name, key in ( + ("texture_slot_index", texture_key), + ("area_fill_flag", fill_key), + ("layout_class", layout_key), + ("required_skill_id", skill_key), + ): + cells = parallel_arrays[field_name][1] + if terrain_id in cells: + record["fields"][key] = cells[terrain_id] + combat_stat_deltas = {} + for column, column_name in enumerate(stat_columns): + cell = (terrain_id, column) + if cell not in combat_stat_cells: + continue + value = combat_stat_cells[cell] + combat_stat_deltas[column_name] = value + record["record_fields"][ + f"{stat_key}/{TERRAIN_COMBAT_STAT_STRIDE}/{column}" + ] = value + record["combat_stat_deltas"] = combat_stat_deltas + if texture_slot in texture_default_assets: + default_asset_id = texture_default_assets[texture_slot] + record["default_texture_asset_id"] = default_asset_id + record["default_texture_asset_name"] = asset_names.get( + default_asset_id, "" + ) + definitions.append(record) + + texture_slots = [] + default_asset_key = f"0x{MAP_TEXTURE_DEFAULT_ASSET_BASE:x}" + for texture_slot in range(MAP_TEXTURE_SLOT_COUNT): + asset_id = texture_default_assets.get(texture_slot, 0) + texture_slots.append({ + "id": texture_slot, + "default_asset_id": asset_id, + "default_asset_name": asset_names.get(asset_id, ""), + "authored": texture_slot in texture_default_assets, + "raw_field": ( + {default_asset_key: asset_id} + if texture_slot in texture_default_assets else {} + ), }) - return definitions + + 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 len(exit_offsets) != 1: + raise ValueError( + f"{scr.path.name}: expected one exit, found {len(exit_offsets)}" + ) + + authored_terrain_ids = sorted( + set(names) + | set(effect_descriptions) + | { + terrain_id + for _, cells in parallel_arrays.values() + for terrain_id in cells + } + | {terrain_id for terrain_id, _ in combat_stat_cells} + ) + return definitions, { + "schema": "terrain-definitions", + "reserved_record_span": TERRAIN_DEFINITION_SPAN, + "shipped_terrain_id_range": [ + 0, + TERRAIN_SHIPPED_ID_MAX, + ], + "authored_terrain_ids": authored_terrain_ids, + "implicit_default_terrain_ids": sorted( + set(range(TERRAIN_SHIPPED_ID_MAX + 1)) + - set(authored_terrain_ids) + ), + "name_array_base": name_key, + "effect_description_array_base": effect_key, + "texture_slot_array_base": texture_key, + "area_fill_array_base": fill_key, + "layout_class_array_base": layout_key, + "combat_stat_table_base": stat_key, + "required_skill_array_base": skill_key, + "map_texture_default_asset_array_base": default_asset_key, + "map_texture_slot_count": MAP_TEXTURE_SLOT_COUNT, + "texture_slots": texture_slots, + "array_layouts": { + stat_key: {"stride": TERRAIN_COMBAT_STAT_STRIDE}, + }, + "schema_field_semantics": { + name_key: "terrain_type_names", + effect_key: "terrain_effect_descriptions", + texture_key: "terrain_texture_slot_indices", + fill_key: "terrain_area_fill_flags", + layout_key: "terrain_layout_classes", + stat_key: "terrain_combat_stat_deltas", + skill_key: "terrain_required_skill_ids", + }, + "string_write_count": string_write_count, + "static_write_count": static_write_count, + "classified_static_write_count": len(classified_offsets - exit_offsets) + - string_write_count, + "combat_stat_cell_count": len(combat_stat_cells), + "required_skill_count": len( + parallel_arrays["required_skill_id"][1] + ), + "default_texture_asset_count": len(texture_default_assets), + "default_texture_asset_join_count": sum( + bool(asset_names.get(asset_id)) + for asset_id in texture_default_assets.values() + ), + "classified_instruction_count": len(classified_offsets), + "consumer_contract": { + "DRAWMAP.BIN": ( + "map each terrain id to a texture slot and render it with the " + "current stage override or the shared per-slot fallback asset" + ), + "CALCBTPARAM.BIN": ( + "add the selected battle tile's ten-column terrain delta row " + "to accuracy, evasion, attack, defense, speed, luck, critical, " + "and capture parameters" + ), + "MVSEEK.BIN": ( + "reject non-hidden terrain with a required skill unless the " + "moving unit owns that skill; hidden terrain uses the same " + "exploration requirement through its dedicated reveal path" + ), + "FIELD.BIN": ( + "show the terrain name, effect description, and required-skill " + "name in tile information and enforce the same traversal gates" + ), + "INFOAF.BIN": ( + "display all terrain combat-stat rows and resolve each " + "required skill id through SKINIT's skill-name table" + ), + }, + } + + +def _terrain_definitions(max_terrain_id: int) -> list[dict]: + """Decode LAINIT terrain rows consumed by MPINIT's terrain ids.""" + terrain_scr = sys4load.load(resolve("LAINIT")) + definitions, _ = extract_terrain_definitions(terrain_scr) + return [ + definition + for definition in definitions + if definition["id"] <= max_terrain_id + ] def _map_stage_definitions() -> list[dict]: @@ -3969,6 +4236,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.", "", + "LAINIT's dedicated terrain-definition schema exposes all twenty shipped terrain", + "ids inside the reserved thirty-row table. It preserves the sparse names and", + "effect descriptions, four parallel topology/rendering arrays, the ten-column", + "combat-stat matrix, SKINIT traversal-skill joins, and shared texture fallbacks.", + "", "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", @@ -4059,6 +4331,8 @@ def main() -> int: extractor = extract_alchemy_recipes elif mode == "numeric" and name == "CVINIT": extractor = extract_voice_configuration + elif mode == "name" and name == "LAINIT": + extractor = extract_terrain_definitions elif mode == "footer" and name == "MPINIT": extractor = extract_map_terrain_atlas recs, meta = extractor(scr) diff --git a/tools/init_table_profile.py b/tools/init_table_profile.py index 0e8f8af..6d07fb0 100644 --- a/tools/init_table_profile.py +++ b/tools/init_table_profile.py @@ -311,6 +311,41 @@ def profile_map_atlas(data: dict) -> dict: } +def profile_terrain_definitions(data: dict) -> dict: + """Summarize LAINIT's terrain rows and shared texture-slot assets.""" + if data.get("schema") != "terrain-definitions": + return {} + records = data.get("records", []) + return { + "reserved_record_span": data.get("reserved_record_span", 0), + "shipped_record_count": len(records), + "authored_record_count": len(data.get("authored_terrain_ids", [])), + "implicit_default_record_count": len( + data.get("implicit_default_terrain_ids", []) + ), + "named_record_count": sum( + bool(record.get("name")) for record in records + ), + "effect_description_count": sum( + bool(record.get("effect_description")) for record in records + ), + "combat_stat_cell_count": data.get("combat_stat_cell_count", 0), + "required_skill_count": data.get("required_skill_count", 0), + "required_skill_names": sorted({ + record["required_skill_name"] + for record in records + if record.get("required_skill_name") + }), + "map_texture_slot_count": data.get("map_texture_slot_count", 0), + "default_texture_asset_count": data.get( + "default_texture_asset_count", 0 + ), + "default_texture_asset_join_count": data.get( + "default_texture_asset_join_count", 0 + ), + } + + def profile_messages(data: dict) -> dict: """Summarize the joined player-facing message evidence.""" records = data["records"] @@ -438,7 +473,27 @@ def render_markdown(data: dict, rows: list[dict], limit: int) -> str: f"- records: {data['record_count']}", f"- populated fields: {len(rows)}", ] - if map_profile := profile_map_atlas(data): + if terrain_profile := profile_terrain_definitions(data): + lines.extend([ + f"- shipped terrain ids: " + f"{terrain_profile['shipped_record_count']} inside a " + f"{terrain_profile['reserved_record_span']}-row table", + f"- authored/default rows: " + f"{terrain_profile['authored_record_count']}/" + f"{terrain_profile['implicit_default_record_count']}", + f"- named/effect rows: {terrain_profile['named_record_count']}/" + f"{terrain_profile['effect_description_count']}", + f"- combat-stat cells: " + f"{terrain_profile['combat_stat_cell_count']}", + f"- required traversal skills: " + f"{terrain_profile['required_skill_count']} " + f"{terrain_profile['required_skill_names']}", + f"- texture fallbacks: " + f"{terrain_profile['default_texture_asset_join_count']}/" + f"{terrain_profile['default_texture_asset_count']} assets resolved " + f"across {terrain_profile['map_texture_slot_count']} slots", + ]) + elif 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", @@ -576,6 +631,7 @@ def main() -> int: "dispatch_profile": profile_dispatch(data), "banked_profile": profile_banked(data), "map_atlas_profile": profile_map_atlas(data), + "terrain_definition_profile": profile_terrain_definitions(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 748922a..dac53bf 100644 --- a/tools/test_extract_init.py +++ b/tools/test_extract_init.py @@ -1449,6 +1449,80 @@ def test_map_terrain_atlas() -> None: ) +def test_terrain_definitions() -> None: + scripts = paths.scripts() + script = sys4load.load(scripts["LAINIT.BIN"]) + check( + extract_init.detect_mode(script) == "name", + "LAINIT remains compatible with name-mode auto-detection", + ) + records, meta = extract_init.extract_terrain_definitions(script) + by_id = {record["id"]: record for record in records} + check( + len(records) == 20 + and meta["reserved_record_span"] == 30 + and meta["authored_terrain_ids"] + == [1, 2, 3, 4, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19] + and meta["implicit_default_terrain_ids"] == [0, 5, 6], + "LAINIT exposes the shipped terrain ids and reserved definition span", + ) + check( + meta["string_write_count"] == 22 + and meta["static_write_count"] == 75 + and meta["classified_static_write_count"] == 75 + and meta["classified_instruction_count"] == 98, + "LAINIT classifies every string, numeric, and exit instruction", + ) + check( + by_id[1]["name"] == "通路" + and by_id[1]["texture_slot_index"] == 1 + and by_id[1]["layout_class_name"] == "passage" + and by_id[2]["name"] == "部屋" + and by_id[2]["area_fill_flag"] == 1 + and by_id[3]["name"] == "隠し通路" + and by_id[3]["layout_class_name"] == "hidden", + "LAINIT preserves terrain names, texture slots, fill flags, and classes", + ) + check( + by_id[4]["effect_description"] == "▲命中・回避・防御" + and by_id[4]["combat_stat_deltas"] + == { + "accuracy": 5, + "evasion": 5, + "physical_defense": 1, + "magic_defense": 1, + } + and by_id[7]["combat_stat_deltas"] + == {"accuracy": -5, "evasion": -5, "speed": -3} + and by_id[10]["combat_stat_deltas"] + == {"accuracy": 10, "evasion": 10} + and meta["combat_stat_cell_count"] == 13, + "LAINIT effect text agrees with every populated combat-stat delta", + ) + check( + by_id[3]["required_skill_id"] == 3 + and by_id[3]["required_skill_name"] == "探索" + and by_id[7]["required_skill_name"] == "潜水" + and by_id[9]["required_skill_name"] == "飛行" + and by_id[15]["required_skill_name"] == "耐熱" + and meta["required_skill_count"] == 5, + "LAINIT traversal gates resolve through SKINIT skill definitions", + ) + authored_slots = [ + slot for slot in meta["texture_slots"] if slot["authored"] + ] + check( + len(authored_slots) == 10 + and authored_slots[0]["id"] == 1 + and authored_slots[0]["default_asset_name"] == "MP000A.AGF" + and authored_slots[-1]["id"] == 13 + and authored_slots[-1]["default_asset_name"] == "MP000N.AGF" + and by_id[15]["default_texture_asset_name"] == "MP000I.AGF" + and meta["default_texture_asset_join_count"] == 10, + "LAINIT texture slots join every fallback asset to SYS4INI", + ) + + def test_condition_definitions() -> None: scripts = paths.scripts() script = sys4load.load(scripts["ILINIT.BIN"]) @@ -1663,6 +1737,7 @@ if __name__ == "__main__": test_affinity_definitions() test_name_entry_palette() test_voice_configuration() + test_terrain_definitions() test_map_terrain_atlas() test_condition_definitions() test_field_semantics() diff --git a/tools/test_init_table_profile.py b/tools/test_init_table_profile.py index dd20284..8b4c3d9 100644 --- a/tools/test_init_table_profile.py +++ b/tools/test_init_table_profile.py @@ -209,6 +209,42 @@ def main() -> int: 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 + terrain_fixture = { + "table": "LAND", + "mode": "name", + "schema": "terrain-definitions", + "record_count": 20, + "reserved_record_span": 30, + "authored_terrain_ids": list(range(17)), + "implicit_default_terrain_ids": [0, 5, 6], + "combat_stat_cell_count": 13, + "required_skill_count": 5, + "map_texture_slot_count": 20, + "default_texture_asset_count": 10, + "default_texture_asset_join_count": 10, + "records": [ + { + "name": "通路" if index else None, + "effect_description": "▲命中" if index < 5 else None, + "required_skill_name": ( + ("飛行", "潜水", "探索", "耐熱")[index] + if index < 4 else None + ), + } + for index in range(20) + ], + } + terrain_summary = profile.profile_terrain_definitions(terrain_fixture) + assert terrain_summary["reserved_record_span"] == 30 + assert terrain_summary["shipped_record_count"] == 20 + assert terrain_summary["authored_record_count"] == 17 + assert terrain_summary["effect_description_count"] == 5 + assert terrain_summary["combat_stat_cell_count"] == 13 + assert terrain_summary["required_skill_names"] == ["探索", "潜水", "耐熱", "飛行"] + rendered_terrain = profile.render_markdown(terrain_fixture, [], 40) + assert "- shipped terrain ids: 20 inside a 30-row table" in rendered_terrain + assert "- combat-stat cells: 13" in rendered_terrain + 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 e343232..45e55a4 100644 --- a/vm-map/globals.toml +++ b/vm-map/globals.toml @@ -3087,6 +3087,17 @@ usage = "LAINIT's terrain vocabulary. MPINIT's atlas cells contain terrain ids 0 source = "investigation" confidence = "high" +[[global]] +address = "0x26d3" +name = "terrain_effect_descriptions" +category = "string-table" +type = "string[30]" +value_domain = "five sparse signed summaries: accuracy/evasion/defense/speed increases or decreases" +usage = "LAINIT's player-facing terrain effect summary indexed by terrain id. FIELD shows it beneath the selected tile's terrain name; each populated string exactly describes that row of terrain_combat_stat_deltas." +source = "investigation" +confidence = "high" +depends_on = ["0x26b5", "0xe6afe"] + [[global]] address = "0xe6aa4" name = "terrain_texture_slot_indices" @@ -3120,6 +3131,40 @@ source = "investigation" confidence = "high" depends_on = ["0x26b5", "0xe6ac2"] +[[global]] +address = "0xe6afe" +name = "terrain_combat_stat_deltas" +category = "data-table" +type = "int[30][10]" +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" } +value_domain = "signed combat parameter deltas; thirteen shipped cells range from -5 to 10" +usage = "LAINIT terrain-effect matrix. CALCBTPARAM reads the acting unit's doubled-coordinate battle tile and adds columns 0..9 through the same combat-parameter channels used by SKINIT skill deltas. INFOAF displays the first eight columns for its terrain rows, and the five populated terrain_effect_descriptions exactly summarize the shipped nonzero cells." +source = "investigation" +confidence = "high" +depends_on = ["0x26b5", "0x26d3", "0x341ab"] + +[[global]] +address = "0xe6c2a" +name = "terrain_required_skill_ids" +category = "data-table" +type = "int[30]" +value_domain = "{0=none, 1=flight, 2=diving, 3=exploration, 4=heat_resistance}" +usage = "LAINIT traversal/reveal requirement indexed by terrain id. MVSEEK and FIELD reject ordinary terrain unless the moving unit owns the referenced SKINIT skill; hidden passages/rooms use the exploration skill through the dedicated hidden-terrain path. FIELD and INFOAF resolve populated ids to skill names for display." +source = "investigation" +confidence = "high" +depends_on = ["0x26b5", "0xe6ae0"] + +[[global]] +address = "0xe6c48" +name = "stage_map_texture_default_asset_ids" +category = "data-table" +type = "int[20]" +value_domain = "ten sparse SYS4INI AGF resource ids 0x319c..0x31a5" +usage = "Shared fallback map texture for each of twenty stage texture slots. FIELD loads a positive stage_map_texture_asset_overrides value directly, skips zero, and when the stage value is -1 loads this slot's MP000A/B/C/E/H/I/J/K/L/N.AGF default into surface 0x52+slot." +source = "investigation" +confidence = "high" +depends_on = ["0xe6aa4", "0xe7311"] + [[global]] address = "0xccc93" name = "stage_terrain_atlas"