Files
OpenMaidEngine/tools/extract_init.py
2026-07-23 21:47:48 -04:00

4383 lines
171 KiB
Python

#!/usr/bin/env python3
"""Extract a *INIT data table to JSON. Auto-detects the table's shape.
*INIT scripts populate global arrays and work buffers with static game data. Eight shapes seen:
name — records keyed by a name string. Each record: set-string(name), static field writes,
set-string(desc). Arrays indexed by record id in lockstep (+1/record).
(SKINIT skills, ITINIT items, EBINIT units, OBINIT object definitions)
numeric— column table with NO names: mov/copy-to-global into parallel int arrays, keyed
by an incrementing index column. (CGINIT gallery)
footer — copy-local-array (op 0x64) bulk-loads length-prefixed arrays from the file
footer into per-record global arrays. The data lives in the footer. (MPINIT maps)
mixed — a sparse selector dispatch writes strings, scalars, fixed-buffer cells, and
footer arrays for one runtime record. (STINIT stages)
rules — conditional blocks select a unit promotion and add effects to shared output
buffers. (CCINIT class changes)
dispatch—paired parallel arrays map a sparse decision id to a packed script resource id
and authored chapter metadata. (SCINIT scene dispatch)
banked —twenty parallel 1000-by-20 banks define sparse movement and battle routine
step records, including provider joins and source overwrites. (RTINIT routines)
ILINIT is a special name-mode matrix: 30 reserved condition ids by five authored
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<col_base>": 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.
Usage: py -3.11 -X utf8 tools/extract_init.py <TABLE> [OUTNAME] [--mode name|numeric|footer|mixed|rules|dispatch|banked]
"""
from __future__ import annotations
import collections
import json
import sys
from functools import cache
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
import paths
import extract_message_table
import sys4load
SET_STRING = 0x192
MOV = 0x55
SUB = 0x51
COPY_TO_GLOBAL = 0x6C
COPY_LOCAL_ARRAY = 0x64
T_GLOBAL_INT = 3
T_GLOBAL_STRING = 5
T_IMM = 0
T_LOCAL_INT = 9
CURRENT_UNIT_ID = 0x66715
CURRENT_UNIT_LEVELS = 0x6930
UNIT_CLASS_CHANGE_STATE = 0x573BB
CLASS_CHANGE_TITLE_OUT = 0x26B4
CLASS_CHANGE_LEVEL_OUT = 0xAB8E7
CLASS_CHANGE_COST_OUT = 0xAB8E8
CLASS_CHANGE_STATS_OUT = 0xAB8E9
CLASS_CHANGE_SKILLS_OUT = 0xAB8F7
CLASS_CHANGE_FLAGS_OUT = 0xAB8FB
ROUTINE_BANK_ROOT = 0xEFF78
ROUTINE_BANK_SPAN = 20000
ROUTINE_BANK_COUNT = 20
ROUTINE_RECORD_STRIDE = 20
ROUTINE_RECORD_SPAN = 1000
ROUTINE_SET_ID = 0xEFF75
ROUTINE_STEP_INDEX = 0xEFF76
ROUTINE_EXECUTION_STATE = 0xEFF77
ROUTINE_BANK_ROLES = (
"movement_provider_selector",
"movement_activation_percent",
"movement_parameter_1",
"movement_parameter_2",
"movement_parameter_3",
"movement_parameter_4",
"movement_reserved",
"movement_minimum_progress_count",
"movement_required_story_flag_id",
"movement_forbidden_story_flag_id",
"battle_provider_selector",
"battle_activation_percent",
"battle_parameter_1",
"battle_reserved_1",
"battle_reserved_2",
"battle_reserved_3",
"battle_reserved_4",
"battle_reserved_5",
"battle_required_story_flag_id",
"battle_forbidden_story_flag_id",
)
MOVEMENT_PROVIDER_PARAMETER_SCHEMAS = {
1: {
"behavior": "advance_step_progress",
"parameter_fields": {},
"ignored_parameter_fields": {
"movement_parameter_1": (
"authored once (routine set 173, slot 2, value 10), but "
"RTN_M001 never reads movement parameter bank 2"
),
"movement_parameter_2": (
"authored once in the same step (value 711), but RTN_M001 "
"never reads movement parameter bank 3"
),
},
"completion": (
"unconditionally advance the current step's progress counter and "
"produce movement result state 1 without selecting a destination"
),
},
2: {
"behavior": "roam_to_random_reachable_tile",
"parameter_fields": {},
"target_selection": (
"build the acting entity's movement-limited reach grid, apply "
"SETMVWORK filtering, and retain map tiles with a positive "
"filtered route score whose movement cost is no greater than "
"current FS; randomize the candidate order and use the first tile "
"for which SETROUTE produces a route"
),
"completion": (
"advance the current step's progress counter and produce movement "
"result state 1 after routing to a randomized reachable tile"
),
},
3: {
"behavior": "route_toward_reachable_normal_attack_target",
"parameter_fields": {},
"target_selection": (
"require a usable normal attack, build the acting entity's "
"movement-limited reach grid, and retain active foreign-faction "
"entities whose occupied tile remains element-effective after "
"SETMVWORK filtering and costs no more than current FS; rank "
"candidates by descending remaining-route score with randomized "
"ties, then use the first candidate for which SETROUTE produces "
"a route"
),
"completion": (
"advance the current step's progress counter and produce movement "
"result state 1 after routing toward a reachable normal-attack "
"target"
),
},
4: {
"behavior": "approach_stage_object_slot",
"parameter_fields": {
"movement_parameter_1": "stage_object_slot_index",
},
"parameter_defaults": {
"movement_parameter_1": 0,
},
"parameter_notes": {
"stage_object_slot_index": (
"zero-based index into the current stage's object arrays; "
"shipped explicit values are 1 or 2, with three unwritten "
"cells using the zero/slot-0 default"
),
},
"completion": (
"advance the current step's progress counter after reaching the "
"selected object's tile (or, for a type-6 stage object, its linked "
"exit tile)"
),
},
5: {
"behavior": "approach_destination_tile",
"parameter_fields": {
"movement_parameter_1": "destination_tile_x",
"movement_parameter_2": "destination_tile_y",
},
"completion": (
"advance the current step's progress counter after reaching the "
"destination tile (or its linked type-6 stage-object exit tile)"
),
},
6: {
"behavior": "approach_nearest_enemy",
"parameter_fields": {
"movement_parameter_1": "maximum_target_route_steps",
},
"parameter_notes": {
"maximum_target_route_steps": (
"inclusive route-step radius from the acting entity after "
"SETMVWORK applies offensive-action eligibility; shipped "
"values are 1..7, 10, or 20"
),
},
"target_selection": (
"nearest active entity of another faction within the route-step "
"radius; choose randomly among ties, then approach a reachable "
"tile nearest that enemy; execution also requires the normal-"
"attack bit in offensive_action_scope_masks[0]"
),
"completion": (
"advance the current step's progress counter after producing a "
"valid movement destination toward the selected enemy"
),
},
7: {
"behavior": "approach_injured_ally",
"parameter_fields": {
"movement_parameter_1": "maximum_target_route_steps",
"movement_parameter_2": "maximum_target_hp_percent",
},
"parameter_notes": {
"maximum_target_route_steps": (
"maximum flood-fill step distance from the acting entity; "
"shipped values are 5 or 10"
),
"maximum_target_hp_percent": (
"inclusive current-HP percentage cutoff; shipped values are "
"50, 70, or 80"
),
},
"target_selection": (
"nearest active non-self entity of the same faction whose current "
"HP percentage is at or below the cutoff; choose randomly among "
"ties, then approach a reachable tile nearest that ally"
),
"completion": (
"advance the current step's progress counter after producing a "
"valid movement destination toward the selected ally"
),
},
8: {
"behavior": "approach_nearest_foreign_magic_pillar",
"parameter_fields": {},
"ignored_parameter_fields": {
"movement_parameter_1": (
"authored once (routine set 112, slot 6, value 1), but "
"RTN_M008 never reads movement parameter bank 2"
),
},
"target_selection": (
"nearest reachable active stage object of OBINIT type 2, 3, or 4 "
"(small, medium, or large Magic Pillar) whose runtime state/faction "
"differs from the acting entity; unlike RTN_M015, no configured "
"route-radius gate is applied"
),
"completion": (
"produce a movement result when a reachable foreign-controlled "
"Magic Pillar exists"
),
},
9: {
"behavior": "approach_collectible_treasure",
"parameter_fields": {},
"target_selection": (
"select the nearest active unopened OBINIT type-7 chest when the "
"acting entity has skill 22 (Unlock), or type-8 treasure without "
"that skill gate; require at least one of the entity's two carried-"
"item slots to be empty or already contain the object's item id, "
"then approach a reachable tile nearest the selected object"
),
"completion": (
"advance the current step's progress counter and produce movement "
"result state 1 after routing toward collectible treasure"
),
},
10: {
"behavior": "approach_healing_feather",
"parameter_fields": {
"movement_parameter_1": "resource_index",
"movement_parameter_2": "maximum_resource_percent",
},
"parameter_defaults": {
"movement_parameter_1": 0,
},
"parameter_notes": {
"resource_index": (
"0=HP, 1=SP, 2=FS; all shipped RTINIT cells are unwritten and "
"therefore use the zero/HP default"
),
"maximum_resource_percent": (
"inclusive current/max percentage cutoff; shipped values are "
"30 or 50"
),
},
"target_selection": (
"nearest active stage object of OBINIT type 15 (Healing Feather) "
"or 16 (single-use red Healing Feather), then approach a reachable "
"tile nearest that object"
),
"completion": (
"produce a movement result only when the selected resource's "
"maximum is nonzero, its current percentage is at or below the "
"cutoff, and a reachable Healing Feather exists"
),
},
11: {
"behavior": "cycle_destination_waypoints",
"parameter_fields": {
"movement_parameter_1": "destination_tile_x",
"movement_parameter_2": "destination_tile_y",
"movement_parameter_3": "waypoint_ordinal",
"movement_parameter_4": "path_cost_limit_override",
},
"parameter_notes": {
"waypoint_ordinal": (
"one-based; only the ordinal matching the entity's current "
"zero-based waypoint index executes"
),
"path_cost_limit_override": (
"optional; zero/absent falls back to the entity's current FS"
),
},
"completion": (
"advance the entity's waypoint index modulo the largest authored "
"waypoint ordinal after reaching the destination tile (or its "
"linked type-6 stage-object exit tile)"
),
},
12: {
"behavior": "approach_destination_tile_avoiding_foreign_entities",
"parameter_fields": {
"movement_parameter_1": "destination_tile_x",
"movement_parameter_2": "destination_tile_y",
},
"routing": (
"same destination and completion logic as RTN_M005, but MVSEEK "
"mode 2 masks the doubled-coordinate terrain cells occupied by "
"active entities of another faction before its flood fill"
),
"completion": (
"advance the current step's progress counter after reaching the "
"destination tile (or its linked type-6 stage-object exit tile)"
),
},
13: {
"behavior": "approach_faction_traversable_tile",
"parameter_fields": {
"movement_parameter_1": "target_faction_filter",
},
"parameter_defaults": {
"movement_parameter_1": 0,
},
"parameter_notes": {
"target_faction_filter": (
"zero means any faction other than the acting entity's faction; "
"a nonzero value selects exactly that faction id. Only one "
"shipped step explicitly writes value 1; three use default zero"
),
},
"target_selection": (
"when the current tile is not traversable by the selected faction "
"set, choose the nearest reachable tile whose "
"tile_faction_traversal_masks value includes that set, then "
"approach it"
),
"completion": (
"advance the current step's progress counter after producing a "
"valid movement destination into the selected faction's traversable "
"territory"
),
},
14: {
"behavior": "retreat_from_nearby_enemies",
"parameter_fields": {
"movement_parameter_1": "maximum_threat_route_steps",
},
"parameter_notes": {
"maximum_threat_route_steps": (
"inclusive route-step radius used to collect active foreign-"
"faction threats; shipped values are 3 or 6"
),
},
"target_selection": (
"sum route-proximity scores from every active foreign-faction "
"entity within the threat radius, exclude occupied tiles, and "
"choose a reachable tile with the lowest positive aggregate score "
"(farthest from the collected threats), randomizing ties"
),
"completion": (
"advance the current step's progress counter after producing a "
"valid retreat destination"
),
},
15: {
"behavior": "approach_foreign_magic_pillar",
"parameter_fields": {
"movement_parameter_1": "maximum_target_route_steps",
},
"parameter_notes": {
"maximum_target_route_steps": (
"inclusive route-step radius; shipped values are 2..6"
),
},
"target_selection": (
"nearest active stage object of OBINIT type 2, 3, or 4 (small, "
"medium, or large Magic Pillar) whose runtime state/faction differs "
"from the acting entity; require it to be within the route-step "
"radius, then approach a reachable tile nearest that object"
),
"completion": (
"produce a movement result only when a foreign-controlled Magic "
"Pillar exists within the configured route-step radius"
),
},
17: {
"behavior": "route_toward_lowest_hp_reachable_normal_attack_target",
"parameter_fields": {},
"target_selection": (
"require a usable normal attack, build the acting entity's "
"movement-limited reach grid, and retain active foreign-faction "
"entities whose occupied tile remains element-effective after "
"SETMVWORK filtering and costs no more than current FS; sort by "
"current HP ascending, preserving source order among ties, then "
"use the first candidate for which SETROUTE produces "
"a route"
),
"completion": (
"advance the current step's progress counter and produce movement "
"result state 1 after routing toward the lowest-current-HP "
"reachable normal-attack target"
),
},
51: {
"behavior": "select_effective_attack_target_and_action",
"parameter_fields": {},
"target_selection": (
"require a usable nonzero attack-range band, scan active foreign-"
"faction entities inside the ATSEEK range grid, and retain targets "
"for which at least one allowed normal-attack/equipped-skill "
"element has positive effectiveness against the target's defense "
"element; encountering a lower range band clears earlier "
"candidates, and the final target is randomized from the retained "
"list"
),
"action_selection": (
"after choosing the target, collect the effective actions enabled "
"in the tracked closest range band (0 means normal attack; nonzero "
"values are equipped skill ids), choose randomly, and store both "
"the target entity and selected action"
),
"completion": (
"advance the current step's progress counter and produce immediate-"
"battle result state 2 when a target/action pair is selected; no "
"movement route is produced"
),
},
52: {
"behavior": "select_lowest_hp_effective_attack_target_and_action",
"parameter_fields": {},
"target_selection": (
"require a usable nonzero attack-range band, scan active foreign-"
"faction entities inside the ATSEEK range grid, and retain only "
"the equal-lowest-current-HP targets for which at least one "
"allowed normal-attack/equipped-skill element has positive "
"effectiveness against the target's defense element; choose "
"randomly among those HP ties"
),
"action_selection": (
"reload the chosen target's actual ATSEEK range band, collect the "
"effective actions enabled there (0 means normal attack; nonzero "
"values are equipped skill ids), choose randomly, and store both "
"the target entity and selected action"
),
"completion": (
"advance the current step's progress counter and produce immediate-"
"battle result state 2 when a target/action pair is selected; no "
"movement route is produced"
),
},
61: {
"behavior": "select_lowest_hp_ally_and_healing_skill",
"parameter_fields": {},
"target_selection": (
"require at least one range-enabled healing skill, scan active "
"same-faction entities inside the ATSEEK range grid, retain only "
"targets tied at the lowest current-HP percentage whose range band "
"enables a healing action, and choose randomly among those ties"
),
"action_selection": (
"among equipped healing skills enabled at the chosen target's "
"range, compare current HP plus each skill's HP recovery against "
"max HP, maximizing the projected result while it remains below "
"max and minimizing it after reaching or exceeding max; store the "
"chosen target entity and healing skill id"
),
"completion": (
"advance the current step's progress counter and produce immediate-"
"support result state 3 when a target/healing-skill pair is "
"selected; no movement route is produced"
),
},
}
UNIT_STAT_COLUMNS = (
"accuracy", "evasion", "physical_attack", "physical_defense",
"magic_attack", "magic_defense", "speed", "luck", "critical_chance",
"capture_power", "movement", "max_hp", "max_sp", "max_fs",
)
MESSAGE_TABLES = {
"CIINIT": "CIMES",
"EBINIT": "EIMES",
"ITINIT": "ITMES",
"MAINIT": "MAMES",
"SKINIT": "SKMES",
"VIINIT": "VIMES",
}
CHARACTER_PROFILE_NAME_ARRAY_BASE = 0x45D7
CHARACTER_PROFILE_UNIT_ARRAY_BASE = 0x15A118
CHARACTER_PROFILE_PORTRAIT_ARRAY_BASE = 0x15A17C
CHARACTER_PROFILE_PORTRAIT_X_ARRAY_BASE = 0x15A1E0
CHARACTER_PROFILE_PORTRAIT_Y_ARRAY_BASE = 0x15A244
CHARACTER_PROFILE_RECORD_SPAN = 100
MAGIC_ACTION_NAME_ARRAY_BASE = 0x45B9
MAGIC_ACTION_INTEGER_ARRAY_BASES = (
0x1560E8,
0x156106,
0x156124,
0x156142,
0x156160,
0x15617E,
0x15619C,
0x1561BA,
0x1561D8,
0x1561F6,
)
MAGIC_ACTION_HANDLER_ARRAY_BASE = 0x1561F6
MAGIC_ACTION_RECORD_SPAN = 30
VOCABULARY_NAME_ARRAY_BASE = 0x463B
VOCABULARY_RECORD_TABLE_BASE = 0x15A2A9
VOCABULARY_RECORD_STRIDE = 3
VOCABULARY_RECORD_SPAN = 200
CHARACTER_NAME_ARRAY_BASE = 0x315
CHARACTER_VOICE_FAMILY_ARRAY_BASE = 0x624BF
CHARACTER_NAME_RECORD_SPAN = 1000
CONDITION_RECORD_SPAN = 30
CONDITION_LEVEL_COUNT = 5
CONDITION_LEVEL_NAME_BASE = 0x25FA
CONDITION_COLUMNS = {
1: "instant_death",
2: "hp_drain",
3: "sp_drain",
4: "fs_drain",
5: "curse",
6: "charm",
7: "confusion",
8: "paralysis",
9: "poison",
10: "water_flow",
11: "fear",
12: "reserved",
13: "regeneration",
14: "exaltation",
}
CONDITION_SCALAR_ARRAYS = {
0xAAC78: "effectiveness_element_id",
0xAAC96: "can_affect_bosses",
0xAACB4: "cleared_by_recover",
0xAACD2: "icon_id",
}
CONDITION_DURATION_BASE = 0xAACF0
CONDITION_STAT_DELTA_BASE = 0xAAD86
CONDITION_RESOURCE_DELTA_BASE = 0xAB3F8
CONDITION_STAT_COLUMNS = (
"accuracy",
"evasion",
"physical_attack",
"physical_defense",
"magic_attack",
"magic_defense",
"speed",
"luck",
"critical_chance",
"capture_power",
"movement",
)
CONDITION_RESOURCE_COLUMNS = ("hp", "sp", "fs")
GALLERY_ASSET_TABLE_BASE = 0x62CD1
GALLERY_RECORD_SPAN = 2000
GALLERY_ASSET_STRIDE = 2
GALLERY_THUMBNAIL_SHEET_ARRAY_BASE = 0x63C71
GALLERY_THUMBNAIL_SLOT_ARRAY_BASE = 0x64441
GALLERY_VARIANT_ORDINAL_ARRAY_BASE = 0x64C11
GALLERY_THUMBNAIL_SHEET_CONFIG_BASE = 0x66381
GALLERY_THUMBNAIL_SHEET_CONFIG_SPAN = 10
ALCHEMY_RECIPE_OUTPUT_ITEM_ARRAY_BASE = 0x156214
ALCHEMY_RECIPE_MINIMUM_LEVEL_ARRAY_BASE = 0x1565FC
ALCHEMY_RECIPE_REQUIRED_FLAGS_BASE = 0x1569E4
ALCHEMY_RECIPE_FORBIDDEN_FLAGS_BASE = 0x1571B4
ALCHEMY_RECIPE_POINT_COST_ARRAY_BASE = 0x157D6C
ALCHEMY_RECIPE_INGREDIENT_ITEM_IDS_BASE = 0x158154
ALCHEMY_RECIPE_INGREDIENT_QUANTITIES_BASE = 0x1590F4
ALCHEMY_RECIPE_RECORD_SPAN = 1000
ALCHEMY_RECIPE_STORY_FLAG_STRIDE = 2
ALCHEMY_RECIPE_INGREDIENT_STRIDE = 4
AFFINITY_ATTACK_ELEMENT_NAME_BASE = 0x2690
AFFINITY_DEFENSE_ELEMENT_NAME_BASE = 0x26A4
AFFINITY_ELEMENT_NAME_SPAN = 20
AFFINITY_EFFECTIVENESS_BASE = 0xAB5BA
AFFINITY_EFFECTIVENESS_STRIDE = 20
AFFINITY_EFFECTIVENESS_ROW_COUNT = 13
AFFINITY_EFFECTIVENESS_AUTHORED_COLUMNS = 18
ITEM_TUNING_BONUS_CURVE_BASE = 0xAB6FA
ITEM_TUNING_COST_CURVE_BASE = 0xAB7D6
ITEM_TUNING_CURVE_STRIDE = 11
ITEM_TUNING_CURVE_COUNT = 19
ITEM_TUNING_AUTHORED_LEVELS = 10
FACILITY_LEVEL_THRESHOLD_BASE = 0xAB8B2
FACILITY_LEVEL_THRESHOLD_STRIDE = 7
FACILITY_LEVEL_THRESHOLD_ROW_COUNT = 3
FACILITY_LEVEL_THRESHOLD_AUTHORED_LEVELS = 6
NAME_ENTRY_CHARACTER_PALETTE_BASE = 0x43DD
NAME_ENTRY_CHARACTER_PALETTE_STRIDE = 70
NAME_ENTRY_CHARACTER_PALETTE_ROW_NAMES = (
"hiragana",
"katakana",
"latin",
"numerals",
"symbols",
)
VOICE_CONFIG_PREVIEW_ASSET_ARRAY_BASE = 0x62CAD
VOICE_CONFIG_SLOT_UNIT_ARRAY_BASE = 0x62C8F
VOICE_CONFIG_UNIT_SETTING_ARRAY_BASE = 0x628A7
VOICE_CONFIG_SLOT_COUNT = 13
VOICE_CONFIG_NAMED_SLOT_COUNT = 12
VOICE_CONFIG_SPEAKER_SEEN_ARRAY_BASE = 0x56223
RECOVER_CURRENT_ENTITY = 0x152616
RECOVER_EFFECTIVE_STATS = 0x4E11B
RECOVER_CURRENT_RESOURCES = 0x4E085
RECOVER_CURRENT_LEVELS = 0x52383
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_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:
for cand in (paths.GAME_DIR / f"{name}.BIN", paths.DATA1 / f"{name}.BIN"):
if cand.exists():
return cand
raise SystemExit(f"not found: {name}.BIN")
def normalize_outname(value: str) -> str:
"""Accept a generated-file stem, not a path; tolerate one `.json` suffix."""
if not value or Path(value).name != value or "/" in value or "\\" in value:
raise ValueError("OUTNAME must be a file stem, not a path")
outname = value.removesuffix(".json")
if not outname or outname in {".", ".."}:
raise ValueError("OUTNAME must be a nonempty file stem")
return outname
def _val(arg):
"""Render an operand as an int (immediate) or a {type,value} ref."""
t, v = arg
return v if t == T_IMM else {"type": f"0x{t:x}", "value": f"0x{v:x}"}
def _static_global_write(ins):
"""Return (destination, value) for statically evaluable global-int writes.
The shipped name-mode INIT scripts encode positive values with `mov` and
negative values with `sub destination, 0, magnitude`. Ignoring the latter
silently drops costs and penalties from the extracted schema.
"""
if not ins.args or ins.args[0][0] != T_GLOBAL_INT:
return None
if ins.opcode == MOV and len(ins.args) >= 2:
return ins.args[0][1], _val(ins.args[1])
if (ins.opcode == SUB and len(ins.args) >= 3
and ins.args[1][0] == T_IMM and ins.args[2][0] == T_IMM):
return ins.args[0][1], ins.args[1][1] - ins.args[2][1]
return None
def read_footer_array(scr, off):
"""Read a length-prefixed Data_Array at dword `off`: [length][v0..v_{length-1}]."""
dw = scr.dwords
if not (0 <= off < scr.nbody):
return None
length = dw[off]
if length > scr.nbody or off + 1 + length > scr.nbody:
return None
return list(dw[off + 1: off + 1 + length])
def _mixed_guards(scr):
"""Find the dominant `eq local, selector-global, record-id; jcc` dispatch."""
candidates = []
instructions = scr.instructions
for index, ins in enumerate(instructions[:-1]):
if (sys4load.display_label(ins.opcode) != "eq"
or len(ins.args) < 3
or ins.args[0][0] != T_LOCAL_INT
or ins.args[1][0] != T_GLOBAL_INT
or ins.args[2][0] != T_IMM):
continue
branch = instructions[index + 1]
if (sys4load.display_label(branch.opcode) != "jcc"
or not branch.args
or branch.args[0] != ins.args[0]):
continue
candidates.append({
"index": index,
"offset": ins.offset,
"selector": ins.args[1][1],
"id": ins.args[2][1],
})
if not candidates:
return []
selector_counts = {}
for guard in candidates:
selector = guard["selector"]
selector_counts[selector] = selector_counts.get(selector, 0) + 1
selector = max(selector_counts, key=lambda value: (selector_counts[value], -value))
return [guard for guard in candidates if guard["selector"] == selector]
def _class_change_guards(scr) -> list[dict]:
"""Find CCINIT's source-ordered `current_unit_id == immediate` rule guards."""
guards = []
for index, ins in enumerate(scr.instructions):
if (sys4load.display_label(ins.opcode) == "eq"
and len(ins.args) >= 3
and ins.args[0][0] == T_LOCAL_INT
and ins.args[1] == (T_GLOBAL_INT, CURRENT_UNIT_ID)
and ins.args[2][0] == T_IMM):
guards.append({
"index": index,
"offset": ins.offset,
"unit_id": ins.args[2][1],
})
return guards
def _paired_parallel_writes(scr) -> tuple[list[tuple], int] | None:
"""Recognize alternating writes to two equally indexed parallel arrays."""
writes = []
for ins in scr.instructions:
write = _static_global_write(ins)
if write is not None and isinstance(write[1], int):
writes.append((ins.offset, *write))
elif sys4load.display_label(ins.opcode) != "exit":
return None
if len(writes) < 200 or len(writes) % 2:
return None
span = writes[1][1] - writes[0][1]
if span <= 0:
return None
for index in range(0, len(writes), 2):
primary, secondary = writes[index:index + 2]
if secondary[1] - primary[1] != span:
return None
return writes, span
def _routine_bank_writes(scr) -> list[tuple] | None:
"""Recognize RTINIT's twenty reserved 1000-by-20 routine-step banks."""
writes = []
for ins in scr.instructions:
write = _static_global_write(ins)
if write is not None and isinstance(write[1], int):
destination, value = write
relative = destination - ROUTINE_BANK_ROOT
if not (0 <= relative < ROUTINE_BANK_COUNT * ROUTINE_BANK_SPAN):
return None
bank_index, cell = divmod(relative, ROUTINE_BANK_SPAN)
record_id, slot = divmod(cell, ROUTINE_RECORD_STRIDE)
if not (
0 <= bank_index < ROUTINE_BANK_COUNT
and 0 <= record_id < ROUTINE_RECORD_SPAN
and 0 <= slot < ROUTINE_RECORD_STRIDE
):
return None
writes.append((
ins.offset, destination, value, bank_index, record_id, slot
))
elif sys4load.display_label(ins.opcode) != "exit":
return None
return writes if len(writes) >= 1000 else None
def detect_mode(scr):
ops = [ins.opcode for ins in scr.instructions]
has_str = any(ins.opcode == SET_STRING and ins.args and ins.args[0][0] == T_GLOBAL_STRING
for ins in scr.instructions)
has_class_change_title = any(
ins.opcode == SET_STRING
and ins.args
and ins.args[0] == (T_GLOBAL_STRING, CLASS_CHANGE_TITLE_OUT)
for ins in scr.instructions
)
if has_class_change_title and len(_class_change_guards(scr)) >= 4:
return "rules"
if has_str and len(_mixed_guards(scr)) >= 4:
return "mixed"
if has_str:
return "name"
if _paired_parallel_writes(scr):
return "dispatch"
if _routine_bank_writes(scr):
return "banked"
n_footer = ops.count(COPY_LOCAL_ARRAY)
n_int = ops.count(MOV) + ops.count(COPY_TO_GLOBAL)
return "footer" if n_footer >= max(4, n_int) else "numeric"
@cache
def unit_definition_names() -> dict[int, str]:
"""Load EBINIT's authoritative unit names by definition id."""
records, _ = extract_name(sys4load.load(resolve("EBINIT")))
return {record["id"]: record["name"] for record in records}
@cache
def skill_definition_names() -> dict[int, str]:
"""Load SKINIT's authoritative skill names by skill id."""
records, _ = extract_name(sys4load.load(resolve("SKINIT")))
return {record["id"]: record["name"] for record in records}
def extract_class_change_rules(scr):
"""Extract CCINIT's promotion predicates and accumulator effects.
CALCCC initializes the output block, invokes CCINIT, and applies the selected
title, cost delta, fourteen stat deltas, and up to three skills to the unit.
Each CCINIT block is therefore a rule rather than a row in a static table.
"""
guards = _class_change_guards(scr)
if not guards:
return [], {}
unit_names = unit_definition_names()
skill_names = skill_definition_names()
records = []
instructions = scr.instructions
for rule_index, guard in enumerate(guards):
end = guards[rule_index + 1]["index"] if rule_index + 1 < len(guards) else len(instructions)
block = instructions[guard["index"]:end]
record = {
"id": rule_index + 1,
"guard_offset": f"0x{guard['offset']:x}",
"unit_id": guard["unit_id"],
"unit_name": unit_names.get(guard["unit_id"], ""),
"fields": {},
"string_fields": {},
"array_fields": {},
}
for ins in block:
label = sys4load.display_label(ins.opcode)
if (label == "lookup-array"
and len(ins.args) >= 3
and ins.args[1] == (T_GLOBAL_INT, CURRENT_UNIT_LEVELS)
and ins.args[2] == (T_GLOBAL_INT, CURRENT_UNIT_ID)):
record["level_table"] = f"0x{CURRENT_UNIT_LEVELS:x}"
elif (label == "gre"
and len(ins.args) >= 3
and ins.args[1][0] == 12
and ins.args[2][0] == T_IMM
and "level_table" in record):
record["minimum_level"] = ins.args[2][1]
elif (label == "lookup-array-2d"
and len(ins.args) >= 5
and ins.args[1] == (T_GLOBAL_INT, UNIT_CLASS_CHANGE_STATE)
and ins.args[2] == (T_GLOBAL_INT, CURRENT_UNIT_ID)
and ins.args[3] == (T_IMM, 10)
and ins.args[4][0] == T_IMM):
record["class_change_slot_index"] = ins.args[4][1]
elif (label == "ne"
and len(ins.args) >= 3
and ins.args[1] == (T_GLOBAL_INT, CURRENT_UNIT_ID)
and ins.args[2][0] == T_GLOBAL_INT):
record["excluded_when_unit_equals_global"] = f"0x{ins.args[2][1]:x}"
elif (ins.opcode == SET_STRING
and len(ins.args) >= 2
and ins.args[0] == (T_GLOBAL_STRING, CLASS_CHANGE_TITLE_OUT)):
title = scr.strings.get(ins.args[1][1], ("",))[0]
record["title"] = title
record["name"] = title
record["string_fields"][f"0x{CLASS_CHANGE_TITLE_OUT:x}"] = title
elif (write := _static_global_write(ins)) is not None:
destination, value = write
if destination == CLASS_CHANGE_LEVEL_OUT:
record["selected_level"] = value
record["fields"][f"0x{destination:x}"] = value
elif CLASS_CHANGE_SKILLS_OUT <= destination < CLASS_CHANGE_SKILLS_OUT + 4:
record["array_fields"][
f"0x{CLASS_CHANGE_SKILLS_OUT:x}/{destination - CLASS_CHANGE_SKILLS_OUT}"
] = value
elif CLASS_CHANGE_FLAGS_OUT <= destination < CLASS_CHANGE_FLAGS_OUT + 10:
record["array_fields"][
f"0x{CLASS_CHANGE_FLAGS_OUT:x}/{destination - CLASS_CHANGE_FLAGS_OUT}"
] = value
elif (label == "add"
and len(ins.args) >= 3
and ins.args[0][0] == T_GLOBAL_INT
and ins.args[0] == ins.args[1]
and ins.args[2][0] == T_IMM):
destination = ins.args[0][1]
value = ins.args[2][1]
if destination == CLASS_CHANGE_COST_OUT:
record["fields"][f"0x{destination:x}"] = value
elif CLASS_CHANGE_STATS_OUT <= destination < CLASS_CHANGE_STATS_OUT + 14:
record["array_fields"][
f"0x{CLASS_CHANGE_STATS_OUT:x}/{destination - CLASS_CHANGE_STATS_OUT}"
] = value
stat_bonuses = {
UNIT_STAT_COLUMNS[int(key.split("/")[1])]: value
for key, value in record["array_fields"].items()
if key.startswith(f"0x{CLASS_CHANGE_STATS_OUT:x}/")
}
if stat_bonuses:
record["stat_bonuses"] = stat_bonuses
record["deployment_cost_delta"] = record["fields"].get(
f"0x{CLASS_CHANGE_COST_OUT:x}", 0
)
skill_awards = []
for key, skill_id in record["array_fields"].items():
if not key.startswith(f"0x{CLASS_CHANGE_SKILLS_OUT:x}/") or skill_id <= 0:
continue
skill_awards.append({
"skill_slot": int(key.split("/")[1]) + 1,
"skill_id": skill_id,
"skill_name": skill_names.get(skill_id, ""),
})
if skill_awards:
record["skill_awards"] = skill_awards
record["state_flag_indices_set"] = [
int(key.split("/")[1])
for key, value in record["array_fields"].items()
if key.startswith(f"0x{CLASS_CHANGE_FLAGS_OUT:x}/") and value
]
records.append(record)
array_columns = sorted({
key for record in records for key in record["array_fields"]
}, key=lambda key: tuple(int(part, 0) for part in key.split("/")))
string_columns = sorted({
key for record in records for key in record["string_fields"]
}, key=lambda key: int(key, 0))
return records, {
"rule_kind": "unit-class-change",
"selector_global": f"0x{CURRENT_UNIT_ID:x}",
"unit_level_table": f"0x{CURRENT_UNIT_LEVELS:x}",
"persistent_state_table": f"0x{UNIT_CLASS_CHANGE_STATE:x}",
"selection_policy": "highest selected_level among eligible unapplied rules",
"array_layouts": {
f"0x{CLASS_CHANGE_STATS_OUT:x}": {"length": 14},
f"0x{CLASS_CHANGE_SKILLS_OUT:x}": {"length": 4},
f"0x{CLASS_CHANGE_FLAGS_OUT:x}": {"length": 10},
},
"string_field_columns": string_columns,
"array_field_columns": array_columns,
}
def _eval_static_arg(arg, locals_: dict[int, int]):
arg_type, value = arg
if arg_type == T_IMM:
return value
if arg_type == T_LOCAL_INT:
return locals_.get(value)
return None
def _mixed_array_layouts(scr, first_guard_index: int) -> dict[int, dict]:
"""Recover fixed global-buffer lengths initialized before the dispatch."""
locals_: dict[int, int] = {}
layouts: dict[int, dict] = {}
for ins in scr.instructions[:first_guard_index]:
label = sys4load.display_label(ins.opcode)
if ins.args and ins.args[0][0] == T_LOCAL_INT:
destination = ins.args[0][1]
operands = [_eval_static_arg(arg, locals_) for arg in ins.args[1:]]
value = None
if label == "mov" and operands:
value = operands[0]
elif len(operands) >= 2 and None not in operands[:2]:
left, right = operands[:2]
if label == "add":
value = left + right
elif label == "sub":
value = left - right
elif label == "mul":
value = left * right
elif label == "div" and right:
value = left // right
if value is None:
locals_.pop(destination, None)
else:
locals_[destination] = value
if (ins.opcode == COPY_TO_GLOBAL
and len(ins.args) >= 2
and ins.args[0][0] == T_GLOBAL_INT):
length = _eval_static_arg(ins.args[1], locals_)
if isinstance(length, int) and length > 0:
layouts[ins.args[0][1]] = {"length": length}
known = dict(_known_record_tables())
for base, layout in layouts.items():
if stride := known.get(base):
layout["stride"] = stride
if layout["length"] % stride == 0:
layout["rows"] = layout["length"] // stride
return layouts
def _mixed_buffer_key(destination: int, layouts: dict[int, dict]) -> str | None:
matches = [
(base, destination - base)
for base, layout in layouts.items()
if base <= destination < base + layout["length"]
]
if len(matches) > 1:
raise ValueError(f"ambiguous mixed-table destination 0x{destination:x}: {matches}")
if not matches:
return None
base, index = matches[0]
return f"0x{base:x}/{index}"
def _store_unique(target: dict, key: str, value, record_id: int) -> None:
if key in target and target[key] != value:
raise ValueError(f"mixed record {record_id}: conflicting writes to {key}")
target[key] = value
def extract_mixed(scr):
"""Extract selector-dispatched records that populate a shared runtime buffer."""
guards = _mixed_guards(scr)
if not guards:
return [], {}
layouts = _mixed_array_layouts(scr, guards[0]["index"])
records = []
instructions = scr.instructions
for guard_index, guard in enumerate(guards):
end = guards[guard_index + 1]["index"] if guard_index + 1 < len(guards) else len(instructions)
record = {
"id": guard["id"],
"guard_offset": f"0x{guard['offset']:x}",
"string_fields": {},
"fields": {},
"array_fields": {},
"footer_arrays": {},
}
for ins in instructions[guard["index"] + 2:end]:
if (ins.opcode == SET_STRING
and len(ins.args) >= 2
and ins.args[0][0] == T_GLOBAL_STRING):
text = scr.strings.get(ins.args[1][1], (None,))[0]
_store_unique(
record["string_fields"], f"0x{ins.args[0][1]:x}", text, record["id"]
)
continue
if (ins.opcode == COPY_LOCAL_ARRAY
and len(ins.args) >= 2
and ins.args[0][0] == T_GLOBAL_INT
and ins.args[1][0] == T_IMM):
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"mixed record {record['id']}: invalid footer array 0x{footer_off:x}"
)
key = _mixed_buffer_key(destination, layouts) or f"0x{destination:x}"
_store_unique(record["footer_arrays"], key, {
"footer_off": f"0x{footer_off:x}",
"values": values,
}, record["id"])
continue
if (write := _static_global_write(ins)) is not None:
destination, value = write
key = _mixed_buffer_key(destination, layouts)
target = record["array_fields"] if key else record["fields"]
_store_unique(target, key or f"0x{destination:x}", value, record["id"])
for key in ("string_fields", "fields", "array_fields", "footer_arrays"):
if not record[key]:
del record[key]
records.append(record)
layouts_json = {
f"0x{base:x}": layout for base, layout in sorted(layouts.items())
}
key_sort = lambda key: tuple(int(part, 0) for part in key.split("/"))
return records, {
"selector_global": f"0x{guards[0]['selector']:x}",
"array_layouts": layouts_json,
"string_field_columns": sorted({
key for record in records for key in record.get("string_fields", {})
}, key=lambda key: int(key, 16)),
"array_field_columns": sorted({
key for record in records for key in record.get("array_fields", {})
}, key=key_sort),
"footer_array_columns": sorted({
key for record in records for key in record.get("footer_arrays", {})
}, key=key_sort),
}
def _infer_record_span(string_addrs):
"""Infer the reserved width of one parallel string-array column.
The shipped INIT tables reserve a fixed number of ids per column (300 for
SKINIT and 1000 for ITINIT/EBINIT). A populated record commonly writes its
name and then its description, so that column stride is the dominant large
positive delta between consecutive string destinations.
"""
counts = {}
for left, right in zip(string_addrs, string_addrs[1:]):
delta = right - left
if delta >= 32:
counts[delta] = counts.get(delta, 0) + 1
if not counts:
raise ValueError("cannot infer name-table record span")
return max(counts, key=lambda delta: (counts[delta], delta))
@cache
def _known_record_tables():
"""Return corpus-observed (base, stride) pairs used by lookup-array-2d.
INIT scripts often populate linked row-major tables while defining an
entity. Treating every such write as `destination - entity_id` invents a
different one-off parallel column for every row. Consumer bytecode gives
us the unambiguous table base and stride instead.
"""
tables = set()
for path in paths.scripts().values():
try:
script = sys4load.load(path)
except sys4load.Sys4Error:
continue
for ins in script.instructions:
if (sys4load.display_label(ins.opcode) == "lookup-array-2d"
and len(ins.args) >= 5
and ins.args[1][0] in (T_GLOBAL_INT, 6)
and ins.args[3][0] == T_IMM
and ins.args[3][1] > 0):
tables.add((ins.args[1][1], ins.args[3][1]))
return tuple(sorted(tables))
def _record_table_cell(destination, record_id):
matches = []
for base, stride in _known_record_tables():
column = destination - (base + record_id * stride)
if 0 <= column < stride:
matches.append((base, stride, column))
if len(matches) > 1:
raise ValueError(
f"ambiguous record-table destination 0x{destination:x} for id {record_id}: {matches}"
)
return matches[0] if matches else None
def _resolve_parallel_record_overlaps(records):
"""Prefer an established parallel column over a row-table range collision.
The global bank is flat, so a sufficiently large row-major table can
contain an address that another INIT schema reaches as `base + entity_id`.
A parallel base repeated by other records is stronger ownership evidence
than one accidental in-range row/column calculation.
"""
parallel_records = {}
for record in records:
for key in record.get("fields", {}):
parallel_records.setdefault(int(key, 0), set()).add(record["id"])
for record in records:
retained = {}
for key, value in record.get("record_fields", {}).items():
base, stride, column = (int(part, 0) for part in key.split("/"))
destination = base + record["id"] * stride + column
parallel_base = destination - record["id"]
if any(
other_id != record["id"]
for other_id in parallel_records.get(parallel_base, ())
):
_store_unique(
record["fields"], f"0x{parallel_base:x}", value, record["id"]
)
else:
retained[key] = value
record["record_fields"] = retained
def extract_name(scr):
string_addrs = [
ins.args[0][1]
for ins in scr.instructions
if ins.opcode == SET_STRING and ins.args and ins.args[0][0] == T_GLOBAL_STRING
]
if not string_addrs:
return [], {}
name_write_base = string_addrs[0]
# AGE's shipped entity ids are one-based. Array lookups use the cell just
# before the first populated destination as their base, then add the id.
first_record_id = 1
name_base = name_write_base - first_record_id
record_span = _infer_record_span(string_addrs)
records, cur, desc_slot, desc_bases = [], None, 0, {}
for ins in scr.instructions:
if ins.opcode == SET_STRING and ins.args and ins.args[0][0] == T_GLOBAL_STRING:
addr = ins.args[0][1]
txt = scr.strings.get(ins.args[1][1], (None,))[0] if len(ins.args) > 1 else None
# Names occupy column zero. Do not use an address decrease as the
# boundary: ITINIT begins with 101 consecutive name-only records,
# which the old heuristic collapsed into item zero.
if name_write_base <= addr < name_write_base + record_span:
cur = {"id": addr - name_base, "name": txt, "fields": {}, "record_fields": {}}
records.append(cur); desc_slot = 0
elif cur is not None:
key = "desc" if desc_slot == 0 else f"desc{desc_slot}"
cur[key] = txt; desc_bases.setdefault(key, addr - cur["id"]); desc_slot += 1
elif cur is not None and (write := _static_global_write(ins)) is not None:
destination, value = write
cell = _record_table_cell(destination, cur["id"])
if cell is None:
cur["fields"][f"0x{destination - cur['id']:x}"] = value
else:
base, stride, column = cell
cur["record_fields"][f"0x{base:x}/{stride}/{column}"] = value
_resolve_parallel_record_overlaps(records)
for record in records:
if not record["record_fields"]:
del record["record_fields"]
record_columns = sorted(
{key for record in records for key in record.get("record_fields", {})},
key=lambda key: tuple(int(part, 0) for part in key.split("/")),
)
return records, {"name_array_base": f"0x{name_base:x}",
"name_write_base": f"0x{name_write_base:x}",
"first_record_id": first_record_id,
"record_span": record_span,
"record_field_columns": record_columns,
"desc_array_bases": {k: f"0x{v:x}" for k, v in sorted(desc_bases.items())}}
def extract_vocabulary(scr):
"""Extract VIINIT's sparse glossary names and pre-name row-table writes."""
records = []
by_id = {}
for ins in scr.instructions:
if (
ins.opcode != SET_STRING
or len(ins.args) < 2
or ins.args[0][0] != T_GLOBAL_STRING
):
continue
record_id = ins.args[0][1] - VOCABULARY_NAME_ARRAY_BASE
if not (1 <= record_id < VOCABULARY_RECORD_SPAN):
raise ValueError(
f"{scr.path.name}: glossary name outside reserved id span: "
f"0x{ins.args[0][1]:x}"
)
text = scr.strings.get(ins.args[1][1], (None,))[0]
record = {
"id": record_id,
"name": text,
"fields": {},
"record_fields": {},
}
records.append(record)
by_id[record_id] = record
for ins in scr.instructions:
write = _static_global_write(ins)
if write is None:
continue
destination, value = write
relative = destination - VOCABULARY_RECORD_TABLE_BASE
if not (0 <= relative < VOCABULARY_RECORD_SPAN * VOCABULARY_RECORD_STRIDE):
raise ValueError(
f"{scr.path.name}: unexpected integer write 0x{destination:x}"
)
record_id, column = divmod(relative, VOCABULARY_RECORD_STRIDE)
if record_id not in by_id:
raise ValueError(
f"{scr.path.name}: integer write for unnamed glossary id {record_id}"
)
_store_unique(
by_id[record_id]["record_fields"],
(
f"0x{VOCABULARY_RECORD_TABLE_BASE:x}/"
f"{VOCABULARY_RECORD_STRIDE}/{column}"
),
value,
record_id,
)
record_columns = sorted({
key for record in records for key in record["record_fields"]
}, key=lambda key: tuple(int(part, 0) for part in key.split("/")))
return records, {
"name_array_base": f"0x{VOCABULARY_NAME_ARRAY_BASE:x}",
"name_write_base": f"0x{VOCABULARY_NAME_ARRAY_BASE + 1:x}",
"first_record_id": 1,
"record_span": VOCABULARY_RECORD_SPAN,
"record_field_columns": record_columns,
}
def extract_character_names(scr):
"""Extract CNINIT's unit-id keyed display-name and voice-family arrays."""
by_id: dict[int, dict] = {}
integer_writes = 0
string_writes = 0
for ins in scr.instructions:
write = _static_global_write(ins)
if write is not None:
destination, canonical_unit_id = write
record_id = destination - CHARACTER_VOICE_FAMILY_ARRAY_BASE
if not (
1 <= record_id < CHARACTER_NAME_RECORD_SPAN
and isinstance(canonical_unit_id, int)
):
raise ValueError(
f"{scr.path.name}: unexpected integer write 0x{destination:x}"
)
if record_id in by_id:
raise ValueError(
f"{scr.path.name}: duplicate unit-name row {record_id}"
)
by_id[record_id] = {
"id": record_id,
"name": None,
"canonical_voice_unit_id": canonical_unit_id,
"string_fields": {},
"fields": {
f"0x{CHARACTER_VOICE_FAMILY_ARRAY_BASE:x}": canonical_unit_id,
},
}
integer_writes += 1
continue
if (
ins.opcode == SET_STRING
and len(ins.args) >= 2
and ins.args[0][0] == T_GLOBAL_STRING
):
record_id = ins.args[0][1] - CHARACTER_NAME_ARRAY_BASE
if not (1 <= record_id < CHARACTER_NAME_RECORD_SPAN):
raise ValueError(
f"{scr.path.name}: unexpected name write 0x{ins.args[0][1]:x}"
)
if record_id not in by_id:
raise ValueError(
f"{scr.path.name}: name without unit mapping for row {record_id}"
)
text = scr.strings.get(ins.args[1][1], (None,))[0]
by_id[record_id]["name"] = text
by_id[record_id]["string_fields"][
f"0x{CHARACTER_NAME_ARRAY_BASE:x}"
] = text
string_writes += 1
continue
if sys4load.display_label(ins.opcode) != "exit":
raise ValueError(
f"{scr.path.name}: unexpected opcode "
f"{sys4load.display_label(ins.opcode)} at 0x{ins.offset:x}"
)
unit_definitions = {
record["id"]: record
for record in extract_name(sys4load.load(resolve("EBINIT")))[0]
}
unit_ids = set(by_id)
definition_ids = set(unit_definitions)
for record in by_id.values():
unit_definition = unit_definitions.get(record["id"])
canonical_definition = unit_definitions.get(
record["canonical_voice_unit_id"]
)
if unit_definition:
record["unit_definition_name"] = unit_definition["name"]
if canonical_definition:
record["canonical_voice_unit_name"] = canonical_definition["name"]
record["voice_family_alias"] = (
record["canonical_voice_unit_id"] != record["id"]
)
records = [by_id[record_id] for record_id in sorted(by_id)]
return records, {
"schema": "unit-display-names",
"record_span": CHARACTER_NAME_RECORD_SPAN,
"first_record_id": 1,
"name_array_base": f"0x{CHARACTER_NAME_ARRAY_BASE:x}",
"canonical_voice_unit_array_base": (
f"0x{CHARACTER_VOICE_FAMILY_ARRAY_BASE:x}"
),
"integer_write_count": integer_writes,
"string_write_count": string_writes,
"named_record_count": sum(record["name"] is not None for record in records),
"unnamed_record_ids": [
record["id"] for record in records if record["name"] is None
],
"voice_family_alias_count": sum(
record["voice_family_alias"] for record in records
),
"unit_definition_table": "EBINIT",
"joined_unit_definition_count": len(unit_ids & definition_ids),
"cninit_ids_without_unit_definition": sorted(unit_ids - definition_ids),
"unit_definition_ids_without_cninit": sorted(definition_ids - unit_ids),
}
def extract_recovery_protocol(scr) -> dict:
"""Validate and describe RECOVER's resource/condition reset ABI."""
lookups_2d = {
(ins.args[1][1], ins.args[3][1])
for ins in scr.instructions
if (
sys4load.display_label(ins.opcode) == "lookup-array-2d"
and len(ins.args) >= 5
and ins.args[1][0] == T_GLOBAL_INT
and ins.args[3][0] == T_IMM
)
}
lookups_1d = {
ins.args[1][1]
for ins in scr.instructions
if (
sys4load.display_label(ins.opcode) == "lookup-array"
and len(ins.args) >= 3
and ins.args[1][0] == T_GLOBAL_INT
)
}
loop_bounds = {
ins.args[2][1]
for ins in scr.instructions
if (
sys4load.display_label(ins.opcode) == "lt"
and len(ins.args) >= 3
and ins.args[2][0] == T_IMM
)
}
calls = {
ins.args[0][1]
for ins in scr.instructions
if sys4load.display_label(ins.opcode) == "call-script" and ins.args
}
required_2d = {
(RECOVER_EFFECTIVE_STATS, 14),
(RECOVER_CURRENT_RESOURCES, 3),
(RECOVER_CURRENT_LEVELS, CONDITION_RECORD_SPAN),
(RECOVER_REMAINING_TURNS, CONDITION_RECORD_SPAN),
(RECOVER_BASELINE_LEVELS, CONDITION_RECORD_SPAN),
}
failures = []
if not required_2d <= lookups_2d:
failures.append(f"missing 2d lookups {sorted(required_2d - lookups_2d)}")
if RECOVER_POLICY not in lookups_1d:
failures.append("missing recovery-policy lookup")
if not {3, CONDITION_RECORD_SPAN} <= loop_bounds:
failures.append("missing resource or condition loop bound")
if not {0x329D, 0x2ADE} <= calls:
failures.append("missing CALCREVISE or DRAWCHP post-call")
if failures:
raise ValueError(f"{scr.path.name}: " + "; ".join(failures))
return {
"source": scr.path.name,
"current_entity_selector": f"0x{RECOVER_CURRENT_ENTITY:x}",
"resource_restore": {
"source_table": f"0x{RECOVER_EFFECTIVE_STATS:x}",
"source_columns": ["max_hp", "max_sp", "max_fs"],
"destination_table": f"0x{RECOVER_CURRENT_RESOURCES:x}",
"destination_columns": ["current_hp", "current_sp", "current_fs"],
},
"condition_reset": {
"column_count": CONDITION_RECORD_SPAN,
"current_level_table": f"0x{RECOVER_CURRENT_LEVELS:x}",
"baseline_level_table": f"0x{RECOVER_BASELINE_LEVELS:x}",
"remaining_turns_table": f"0x{RECOVER_REMAINING_TURNS:x}",
"recovery_policy_table": f"0x{RECOVER_POLICY:x}",
"policy": (
"For each active condition whose policy cell is nonzero, copy "
"the equipment/passive baseline into the current level and set "
"remaining turns to -1 when that baseline is nonzero, otherwise 0."
),
},
"post_recovery_scripts": ["CALCREVISE.BIN", "DRAWCHP.BIN"],
}
def _condition_family_name(level_names: dict[int, str]) -> str | None:
"""Collapse authored `name1`..`name5` strings to their shared family."""
if not level_names:
return None
ordered = [level_names[level] for level in sorted(level_names)]
prefixes = [
text[:-1]
for level, text in sorted(level_names.items())
if text.endswith(str(level))
]
if len(prefixes) == len(ordered) and len(set(prefixes)) == 1:
return prefixes[0]
return ordered[0]
def extract_condition_definitions(scr):
"""Extract ILINIT's sparse 30-condition, five-level definition matrix."""
level_names: dict[int, dict[int, str]] = collections.defaultdict(dict)
records_by_id: dict[int, dict] = {}
classified_writes = 0
static_write_count = 0
def record_for(condition_id: int) -> dict:
if not (1 <= condition_id < CONDITION_RECORD_SPAN):
raise ValueError(
f"{scr.path.name}: condition id outside reserved span: {condition_id}"
)
return records_by_id.setdefault(condition_id, {
"id": condition_id,
"condition": CONDITION_COLUMNS.get(condition_id, f"reserved_{condition_id}"),
"string_fields": {},
"fields": {},
"record_fields": {},
})
for ins in scr.instructions:
if (
ins.opcode == SET_STRING
and len(ins.args) >= 2
and ins.args[0][0] == T_GLOBAL_STRING
):
relative = ins.args[0][1] - CONDITION_LEVEL_NAME_BASE
condition_id, level_index = divmod(relative, CONDITION_LEVEL_COUNT)
if not (
1 <= condition_id < CONDITION_RECORD_SPAN
and 0 <= level_index < CONDITION_LEVEL_COUNT
):
raise ValueError(
f"{scr.path.name}: unexpected condition name destination "
f"0x{ins.args[0][1]:x}"
)
text = scr.strings.get(ins.args[1][1], (None,))[0]
level_names[condition_id][level_index + 1] = text
_store_unique(
record_for(condition_id)["string_fields"],
(
f"0x{CONDITION_LEVEL_NAME_BASE:x}/"
f"{CONDITION_LEVEL_COUNT}/{level_index}"
),
text,
condition_id,
)
continue
write = _static_global_write(ins)
if write is None:
continue
static_write_count += 1
destination, value = write
matched = False
for base in CONDITION_SCALAR_ARRAYS:
condition_id = destination - base
if 1 <= condition_id < CONDITION_RECORD_SPAN:
_store_unique(
record_for(condition_id)["fields"],
f"0x{base:x}",
value,
condition_id,
)
matched = True
break
if not matched:
layouts = (
(CONDITION_DURATION_BASE, CONDITION_LEVEL_COUNT),
(
CONDITION_STAT_DELTA_BASE,
CONDITION_LEVEL_COUNT * len(CONDITION_STAT_COLUMNS),
),
(
CONDITION_RESOURCE_DELTA_BASE,
CONDITION_LEVEL_COUNT * len(CONDITION_RESOURCE_COLUMNS),
),
)
for base, stride in layouts:
relative = destination - base
condition_id, column = divmod(relative, stride)
if 1 <= condition_id < CONDITION_RECORD_SPAN:
_store_unique(
record_for(condition_id)["record_fields"],
f"0x{base:x}/{stride}/{column}",
value,
condition_id,
)
matched = True
break
if not matched:
raise ValueError(
f"{scr.path.name}: unclassified static write 0x{destination:x}"
)
classified_writes += 1
records = []
for condition_id in sorted(records_by_id):
record = records_by_id[condition_id]
names = level_names.get(condition_id, {})
record["name"] = _condition_family_name(names)
record["level_names"] = [
names.get(level) for level in range(1, CONDITION_LEVEL_COUNT + 1)
]
levels = []
for level in range(1, CONDITION_LEVEL_COUNT + 1):
level_record = {"level": level}
if level in names:
level_record["name"] = names[level]
duration_key = (
f"0x{CONDITION_DURATION_BASE:x}/{CONDITION_LEVEL_COUNT}/{level - 1}"
)
if duration_key in record["record_fields"]:
level_record["duration_turns"] = record["record_fields"][duration_key]
stat_deltas = {}
for stat_index, stat_name in enumerate(CONDITION_STAT_COLUMNS):
column = (level - 1) * len(CONDITION_STAT_COLUMNS) + stat_index
key = (
f"0x{CONDITION_STAT_DELTA_BASE:x}/"
f"{CONDITION_LEVEL_COUNT * len(CONDITION_STAT_COLUMNS)}/{column}"
)
if key in record["record_fields"]:
stat_deltas[stat_name] = record["record_fields"][key]
if stat_deltas:
level_record["stat_deltas"] = stat_deltas
resource_deltas = {}
for resource_index, resource_name in enumerate(CONDITION_RESOURCE_COLUMNS):
column = (level - 1) * len(CONDITION_RESOURCE_COLUMNS) + resource_index
key = (
f"0x{CONDITION_RESOURCE_DELTA_BASE:x}/"
f"{CONDITION_LEVEL_COUNT * len(CONDITION_RESOURCE_COLUMNS)}/{column}"
)
if key in record["record_fields"]:
resource_deltas[resource_name] = record["record_fields"][key]
if resource_deltas:
level_record["resource_deltas"] = resource_deltas
if len(level_record) > 1:
levels.append(level_record)
record["levels"] = levels
records.append(record)
defined_ids = [record["id"] for record in records]
record_columns = sorted(
{
key
for record in records
for key in record.get("record_fields", {})
},
key=lambda key: tuple(int(part, 0) for part in key.split("/")),
)
return records, {
"schema": "condition-definitions",
"record_span": CONDITION_RECORD_SPAN,
"level_count": CONDITION_LEVEL_COUNT,
"condition_columns": {
str(index): name for index, name in CONDITION_COLUMNS.items()
},
"defined_condition_ids": defined_ids,
"reserved_condition_ids": [
condition_id
for condition_id in range(1, CONDITION_RECORD_SPAN)
if condition_id not in defined_ids
],
"level_name_table": {
"base": f"0x{CONDITION_LEVEL_NAME_BASE:x}",
"stride": CONDITION_LEVEL_COUNT,
},
"record_field_columns": record_columns,
"static_write_count": static_write_count,
"classified_static_write_count": classified_writes,
"recovery_protocol": extract_recovery_protocol(
sys4load.load(resolve("RECOVER"))
),
}
def extract_character_profiles(scr):
"""Extract CIINIT's profile-id keyed character-information registry."""
records = []
by_id = {}
for ins in scr.instructions:
if (
ins.opcode != SET_STRING
or len(ins.args) < 2
or ins.args[0][0] != T_GLOBAL_STRING
):
continue
record_id = ins.args[0][1] - CHARACTER_PROFILE_NAME_ARRAY_BASE
if not (1 <= record_id < CHARACTER_PROFILE_RECORD_SPAN):
raise ValueError(
f"{scr.path.name}: character name outside reserved id span: "
f"0x{ins.args[0][1]:x}"
)
text = scr.strings.get(ins.args[1][1], (None,))[0]
record = {"id": record_id, "name": text, "fields": {}}
records.append(record)
by_id[record_id] = record
integer_arrays = (
CHARACTER_PROFILE_UNIT_ARRAY_BASE,
CHARACTER_PROFILE_PORTRAIT_ARRAY_BASE,
CHARACTER_PROFILE_PORTRAIT_X_ARRAY_BASE,
CHARACTER_PROFILE_PORTRAIT_Y_ARRAY_BASE,
)
for ins in scr.instructions:
write = _static_global_write(ins)
if write is None:
continue
destination, value = write
matched = False
for base in integer_arrays:
relative = destination - base
if 0 <= relative < CHARACTER_PROFILE_RECORD_SPAN:
if relative not in by_id:
raise ValueError(
f"{scr.path.name}: integer write for unnamed character "
f"profile id {relative}"
)
_store_unique(
by_id[relative]["fields"],
f"0x{base:x}",
value,
relative,
)
matched = True
break
if not matched:
raise ValueError(
f"{scr.path.name}: unexpected integer write 0x{destination:x}"
)
return records, {
"schema": "character-information-profiles",
"name_array_base": f"0x{CHARACTER_PROFILE_NAME_ARRAY_BASE:x}",
"name_write_base": f"0x{CHARACTER_PROFILE_NAME_ARRAY_BASE + 1:x}",
"first_record_id": 1,
"record_span": CHARACTER_PROFILE_RECORD_SPAN,
"unit_id_array_base": f"0x{CHARACTER_PROFILE_UNIT_ARRAY_BASE:x}",
"portrait_asset_array_base": (
f"0x{CHARACTER_PROFILE_PORTRAIT_ARRAY_BASE:x}"
),
"portrait_x_offset_array_base": (
f"0x{CHARACTER_PROFILE_PORTRAIT_X_ARRAY_BASE:x}"
),
"portrait_y_offset_array_base": (
f"0x{CHARACTER_PROFILE_PORTRAIT_Y_ARRAY_BASE:x}"
),
"implicit_defaults": {
f"0x{CHARACTER_PROFILE_PORTRAIT_X_ARRAY_BASE:x}": 0,
f"0x{CHARACTER_PROFILE_PORTRAIT_Y_ARRAY_BASE:x}": 0,
},
}
def extract_magic_actions(scr):
"""Extract MAINIT's action-id keyed magic/research/growth registry.
MAINIT has only one consecutive string column, so the generic name-table
span heuristic cannot see its reserved 30-cell stride. Its ten integer
columns are equally spaced consumers of the same action id.
"""
records = []
by_id = {}
for ins in scr.instructions:
if (
ins.opcode != SET_STRING
or len(ins.args) < 2
or ins.args[0][0] != T_GLOBAL_STRING
):
continue
record_id = ins.args[0][1] - MAGIC_ACTION_NAME_ARRAY_BASE
if not (1 <= record_id < MAGIC_ACTION_RECORD_SPAN):
raise ValueError(
f"{scr.path.name}: magic-action name outside reserved id span: "
f"0x{ins.args[0][1]:x}"
)
text = scr.strings.get(ins.args[1][1], (None,))[0]
record = {"id": record_id, "name": text, "fields": {}}
records.append(record)
by_id[record_id] = record
for ins in scr.instructions:
write = _static_global_write(ins)
if write is None:
continue
destination, value = write
matched = False
for base in MAGIC_ACTION_INTEGER_ARRAY_BASES:
record_id = destination - base
if 0 <= record_id < MAGIC_ACTION_RECORD_SPAN:
if record_id not in by_id:
raise ValueError(
f"{scr.path.name}: integer write for unnamed magic "
f"action id {record_id}"
)
_store_unique(
by_id[record_id]["fields"],
f"0x{base:x}",
value,
record_id,
)
matched = True
break
if not matched:
raise ValueError(
f"{scr.path.name}: unexpected integer write 0x{destination:x}"
)
return records, {
"schema": "magic-actions",
"name_array_base": f"0x{MAGIC_ACTION_NAME_ARRAY_BASE:x}",
"name_write_base": f"0x{MAGIC_ACTION_NAME_ARRAY_BASE + 1:x}",
"first_record_id": 1,
"record_span": MAGIC_ACTION_RECORD_SPAN,
"integer_array_bases": [
f"0x{base:x}" for base in MAGIC_ACTION_INTEGER_ARRAY_BASES
],
"handler_script_array_base": (
f"0x{MAGIC_ACTION_HANDLER_ARRAY_BASE:x}"
),
"implicit_default": 0,
}
@cache
def object_type_definitions() -> dict[int, dict]:
"""Load OBINIT's authoritative display and state-row metadata by object type id."""
records, _ = extract_name(sys4load.load(resolve("OBINIT")))
return {
record["id"]: {
"name": record["name"],
**({"description": record["desc"]} if record.get("desc") else {}),
"uses_runtime_state_sprite_row": (
record.get("fields", {}).get("0xe6dee") == 1
),
}
for record in records
}
def _int_writes(scr):
"""Ordered (addr, value_arg) for global-int mov / copy-to-global."""
out = []
for ins in scr.instructions:
if ins.opcode in (MOV, COPY_TO_GLOBAL) and ins.args and ins.args[0][0] == T_GLOBAL_INT:
out.append((ins.args[0][1], ins.args[1]))
return out
def _longest_stride1_column(addrs):
"""Pick the primary index array: the stride-1 arithmetic run covering the most records."""
seen = set(addrs)
best_base, best_len = None, 0
for a in sorted(seen):
if a - 1 in seen:
continue # only start at a run's base
n = 0
while a + n in seen:
n += 1
if n > best_len:
best_base, best_len = a, n
return best_base, best_len
def extract_numeric(scr):
writes = _int_writes(scr)
base, n = _longest_stride1_column([a for a, _ in writes])
if base is None:
return [], {}
primary = set(range(base, base + n))
records, buf = [], []
for addr, varg in writes:
buf.append((addr, varg))
if addr in primary: # primary write closes the record
rid = addr - base
fields = {f"0x{a - rid:x}": _val(v) for a, v in buf}
records.append({"id": rid, "fields": fields})
buf = []
return records, {"primary_index_base": f"0x{base:x}", "record_span": n}
def extract_gallery_definitions(scr):
"""Extract CGINIT's sparse gallery-image registry.
CGINIT owns one 2,000-by-2 asset table and three parallel 2,000-cell
classification arrays. CGMODE uses the latter as a thumbnail-sheet,
30-cell atlas slot, and per-slot variant ordinal; SAVE and SELSTAGE use
the optional second asset as a 112-by-84 preview of the first.
"""
records_by_id: dict[int, dict] = {}
static_write_count = 0
classified_write_count = 0
def record_for(record_id: int) -> dict:
if not (1 <= record_id < GALLERY_RECORD_SPAN):
raise ValueError(
f"{scr.path.name}: gallery id {record_id} outside reserved span"
)
return records_by_id.setdefault(record_id, {
"id": record_id,
"fields": {},
"record_fields": {},
})
for ins in scr.instructions:
write = _static_global_write(ins)
if write is None:
if sys4load.display_label(ins.opcode) != "exit":
raise ValueError(
f"{scr.path.name}: unclassified instruction at 0x{ins.offset:x}"
)
continue
static_write_count += 1
destination, value = write
if not isinstance(value, int):
raise ValueError(
f"{scr.path.name}: non-static gallery value at 0x{ins.offset:x}"
)
relative = destination - GALLERY_ASSET_TABLE_BASE
if 0 <= relative < GALLERY_RECORD_SPAN * GALLERY_ASSET_STRIDE:
record_id, column = divmod(relative, GALLERY_ASSET_STRIDE)
record = record_for(record_id)
_store_unique(
record["record_fields"],
(
f"0x{GALLERY_ASSET_TABLE_BASE:x}/"
f"{GALLERY_ASSET_STRIDE}/{column}"
),
value,
record_id,
)
classified_write_count += 1
continue
scalar_arrays = (
GALLERY_THUMBNAIL_SHEET_ARRAY_BASE,
GALLERY_THUMBNAIL_SLOT_ARRAY_BASE,
GALLERY_VARIANT_ORDINAL_ARRAY_BASE,
)
for base in scalar_arrays:
record_id = destination - base
if 1 <= record_id < GALLERY_RECORD_SPAN:
_store_unique(
record_for(record_id)["fields"],
f"0x{base:x}",
value,
record_id,
)
classified_write_count += 1
break
else:
raise ValueError(
f"{scr.path.name}: unclassified gallery write "
f"0x{destination:x} at 0x{ins.offset:x}"
)
names = callscript_names()
sheet_asset_ids = gallery_thumbnail_sheet_assets()
records = [records_by_id[record_id] for record_id in sorted(records_by_id)]
required_scalar_keys = {
f"0x{GALLERY_THUMBNAIL_SHEET_ARRAY_BASE:x}",
f"0x{GALLERY_THUMBNAIL_SLOT_ARRAY_BASE:x}",
f"0x{GALLERY_VARIANT_ORDINAL_ARRAY_BASE:x}",
}
primary_key = (
f"0x{GALLERY_ASSET_TABLE_BASE:x}/{GALLERY_ASSET_STRIDE}/0"
)
preview_key = (
f"0x{GALLERY_ASSET_TABLE_BASE:x}/{GALLERY_ASSET_STRIDE}/1"
)
for record in records:
if set(record["fields"]) != required_scalar_keys:
raise ValueError(
f"{scr.path.name}: gallery id {record['id']} has incomplete scalars"
)
if primary_key not in record["record_fields"]:
raise ValueError(
f"{scr.path.name}: gallery id {record['id']} has no image asset"
)
sheet_id = record["fields"][
f"0x{GALLERY_THUMBNAIL_SHEET_ARRAY_BASE:x}"
]
slot_id = record["fields"][
f"0x{GALLERY_THUMBNAIL_SLOT_ARRAY_BASE:x}"
]
variant_ordinal = record["fields"][
f"0x{GALLERY_VARIANT_ORDINAL_ARRAY_BASE:x}"
]
if sheet_id not in sheet_asset_ids:
raise ValueError(
f"{scr.path.name}: gallery id {record['id']} has bad sheet {sheet_id}"
)
if not (1 <= slot_id <= 30 and variant_ordinal >= 1):
raise ValueError(
f"{scr.path.name}: gallery id {record['id']} has bad "
f"slot/variant {slot_id}/{variant_ordinal}"
)
image_asset_id = record["record_fields"][primary_key]
sheet_asset_id = sheet_asset_ids[sheet_id]
record.update({
"gallery_image_asset_id": image_asset_id,
"gallery_image_asset_name": names.get(image_asset_id, ""),
"thumbnail_sheet_id": sheet_id,
"thumbnail_sheet_asset_id": sheet_asset_id,
"thumbnail_sheet_asset_name": names.get(sheet_asset_id, ""),
"thumbnail_slot_id": slot_id,
"variant_ordinal": variant_ordinal,
})
if preview_asset_id := record["record_fields"].get(preview_key):
record["save_stage_preview_asset_id"] = preview_asset_id
record["save_stage_preview_asset_name"] = names.get(
preview_asset_id, ""
)
populated_ids = set(records_by_id)
populated_min = min(populated_ids)
populated_max = max(populated_ids)
sheet_definitions = []
for sheet_id, asset_id in sheet_asset_ids.items():
sheet_definitions.append({
"id": sheet_id,
"asset_id": asset_id,
"asset_name": names.get(asset_id, ""),
"atlas_columns": 6,
"atlas_rows": 5,
"slot_count": 30,
})
return records, {
"record_span": GALLERY_RECORD_SPAN,
"populated_id_range": [populated_min, populated_max],
"id_gaps_within_populated_range": [
record_id
for record_id in range(populated_min, populated_max + 1)
if record_id not in populated_ids
],
"asset_table_base": f"0x{GALLERY_ASSET_TABLE_BASE:x}",
"asset_table_stride": GALLERY_ASSET_STRIDE,
"thumbnail_sheet_array_base": (
f"0x{GALLERY_THUMBNAIL_SHEET_ARRAY_BASE:x}"
),
"thumbnail_slot_array_base": (
f"0x{GALLERY_THUMBNAIL_SLOT_ARRAY_BASE:x}"
),
"variant_ordinal_array_base": (
f"0x{GALLERY_VARIANT_ORDINAL_ARRAY_BASE:x}"
),
"record_field_columns": [primary_key, preview_key],
"static_write_count": static_write_count,
"classified_static_write_count": classified_write_count,
"preview_asset_count": sum(
preview_key in record["record_fields"] for record in records
),
"thumbnail_sheets": sheet_definitions,
"thumbnail_sheet_configuration": {
"source": "INIT2.BIN",
"base": f"0x{GALLERY_THUMBNAIL_SHEET_CONFIG_BASE:x}",
"reserved_span": GALLERY_THUMBNAIL_SHEET_CONFIG_SPAN,
},
"consumer_contract": {
"gallery": (
"CGMODE groups records by thumbnail sheet and one of its "
"thirty atlas slots, orders variants by the one-based ordinal, "
"tests the primary image's unlock state, and displays it."
),
"save_stage_preview": (
"SAVE and SELSTAGE match the current image against the primary "
"asset and use the optional second asset as a 112x84 preview."
),
},
}
def extract_alchemy_recipes(scr):
"""Extract ALINIT's sparse alchemy recipe registry."""
scalar_arrays = {
ALCHEMY_RECIPE_OUTPUT_ITEM_ARRAY_BASE: "output_item_id",
ALCHEMY_RECIPE_MINIMUM_LEVEL_ARRAY_BASE: "minimum_alchemy_level",
ALCHEMY_RECIPE_POINT_COST_ARRAY_BASE: "point_cost",
}
row_tables = {
ALCHEMY_RECIPE_REQUIRED_FLAGS_BASE: (
ALCHEMY_RECIPE_STORY_FLAG_STRIDE,
"required_story_flag_id",
),
ALCHEMY_RECIPE_FORBIDDEN_FLAGS_BASE: (
ALCHEMY_RECIPE_STORY_FLAG_STRIDE,
"forbidden_story_flag_id",
),
ALCHEMY_RECIPE_INGREDIENT_ITEM_IDS_BASE: (
ALCHEMY_RECIPE_INGREDIENT_STRIDE,
"ingredient_item_id",
),
ALCHEMY_RECIPE_INGREDIENT_QUANTITIES_BASE: (
ALCHEMY_RECIPE_INGREDIENT_STRIDE,
"ingredient_quantity",
),
}
records_by_id: dict[int, dict] = {}
static_write_count = 0
classified_write_count = 0
def record_for(record_id: int) -> dict:
if not (1 <= record_id < ALCHEMY_RECIPE_RECORD_SPAN):
raise ValueError(
f"{scr.path.name}: recipe id {record_id} outside reserved span"
)
return records_by_id.setdefault(record_id, {
"id": record_id,
"fields": {},
"record_fields": {},
})
for ins in scr.instructions:
write = _static_global_write(ins)
if write is None:
if sys4load.display_label(ins.opcode) != "exit":
raise ValueError(
f"{scr.path.name}: unclassified instruction at 0x{ins.offset:x}"
)
continue
static_write_count += 1
destination, value = write
if not isinstance(value, int):
raise ValueError(
f"{scr.path.name}: non-static recipe value at 0x{ins.offset:x}"
)
for base in scalar_arrays:
record_id = destination - base
if 1 <= record_id < ALCHEMY_RECIPE_RECORD_SPAN:
_store_unique(
record_for(record_id)["fields"],
f"0x{base:x}",
value,
record_id,
)
classified_write_count += 1
break
else:
for base, (stride, _) in row_tables.items():
relative = destination - base
if 0 <= relative < ALCHEMY_RECIPE_RECORD_SPAN * stride:
record_id, column = divmod(relative, stride)
record = record_for(record_id)
_store_unique(
record["record_fields"],
f"0x{base:x}/{stride}/{column}",
value,
record_id,
)
classified_write_count += 1
break
else:
raise ValueError(
f"{scr.path.name}: unclassified recipe write "
f"0x{destination:x} at 0x{ins.offset:x}"
)
item_records, _ = extract_name(sys4load.load(resolve("ITINIT")))
item_names = {record["id"]: record["name"] for record in item_records}
records = [records_by_id[record_id] for record_id in sorted(records_by_id)]
required_scalar_keys = {f"0x{base:x}" for base in scalar_arrays}
output_key = f"0x{ALCHEMY_RECIPE_OUTPUT_ITEM_ARRAY_BASE:x}"
level_key = f"0x{ALCHEMY_RECIPE_MINIMUM_LEVEL_ARRAY_BASE:x}"
cost_key = f"0x{ALCHEMY_RECIPE_POINT_COST_ARRAY_BASE:x}"
ingredient_reference_count = 0
joined_ingredient_reference_count = 0
for record in records:
if set(record["fields"]) != required_scalar_keys:
raise ValueError(
f"{scr.path.name}: recipe id {record['id']} has incomplete scalars"
)
output_item_id = record["fields"][output_key]
if output_item_id not in item_names:
raise ValueError(
f"{scr.path.name}: recipe id {record['id']} has unknown "
f"output item {output_item_id}"
)
record.update({
"output_item_id": output_item_id,
"output_item_name": item_names[output_item_id],
"minimum_alchemy_level": record["fields"][level_key],
"point_cost": record["fields"][cost_key],
})
for kind, base in (
("required", ALCHEMY_RECIPE_REQUIRED_FLAGS_BASE),
("forbidden", ALCHEMY_RECIPE_FORBIDDEN_FLAGS_BASE),
):
values = [
record["record_fields"][f"0x{base:x}/2/{column}"]
for column in range(ALCHEMY_RECIPE_STORY_FLAG_STRIDE)
if f"0x{base:x}/2/{column}" in record["record_fields"]
]
record[f"{kind}_story_flag_ids"] = values
ingredients = []
for slot in range(ALCHEMY_RECIPE_INGREDIENT_STRIDE):
item_key = (
f"0x{ALCHEMY_RECIPE_INGREDIENT_ITEM_IDS_BASE:x}/"
f"{ALCHEMY_RECIPE_INGREDIENT_STRIDE}/{slot}"
)
quantity_key = (
f"0x{ALCHEMY_RECIPE_INGREDIENT_QUANTITIES_BASE:x}/"
f"{ALCHEMY_RECIPE_INGREDIENT_STRIDE}/{slot}"
)
has_item = item_key in record["record_fields"]
has_quantity = quantity_key in record["record_fields"]
if has_item != has_quantity:
raise ValueError(
f"{scr.path.name}: recipe id {record['id']} has an "
f"unpaired ingredient slot {slot}"
)
if not has_item:
continue
ingredient_item_id = record["record_fields"][item_key]
if ingredient_item_id not in item_names:
raise ValueError(
f"{scr.path.name}: recipe id {record['id']} has unknown "
f"ingredient item {ingredient_item_id} in slot {slot}"
)
ingredient_reference_count += 1
ingredient = {
"slot": slot,
"item_id": ingredient_item_id,
"item_name": item_names[ingredient_item_id],
"quantity": record["record_fields"][quantity_key],
}
joined_ingredient_reference_count += 1
ingredients.append(ingredient)
record["ingredients"] = ingredients
record_columns = sorted(
{
key
for record in records
for key in record.get("record_fields", {})
},
key=lambda key: tuple(int(part, 0) for part in key.split("/")),
)
populated_ids = set(records_by_id)
return records, {
"record_span": ALCHEMY_RECIPE_RECORD_SPAN,
"populated_record_ids": sorted(populated_ids),
"populated_id_range": [min(populated_ids), max(populated_ids)],
"scalar_array_bases": {
role: f"0x{base:x}" for base, role in scalar_arrays.items()
},
"row_tables": {
role: {"base": f"0x{base:x}", "stride": stride}
for base, (stride, role) in row_tables.items()
},
"record_field_columns": record_columns,
"static_write_count": static_write_count,
"classified_static_write_count": classified_write_count,
"output_item_join_count": sum(
record["output_item_id"] in item_names for record in records
),
"ingredient_reference_count": ingredient_reference_count,
"joined_ingredient_reference_count": joined_ingredient_reference_count,
"consumer_contract": {
"availability": (
"ALCHEMY lists a recipe only when its minimum level, required "
"and forbidden story flags, point-capacity threshold, and "
"owned ingredient quantities pass."
),
"synthesis": (
"ALCHEMY removes each populated ingredient quantity, adds one "
"output item, deducts point_cost from the shared spendable "
"point pool, and advances alchemy-level progress."
),
},
}
def extract_affinity_definitions(scr):
"""Extract AFINIT's element, tuning-curve, and facility-threshold tables."""
attack_names = {}
defense_names = {}
effectiveness_rows = {}
tuning_bonus_rows = {}
tuning_cost_rows = {}
facility_threshold_rows = {}
string_write_count = 0
footer_array_count = 0
exit_count = 0
def signed_values(values):
return [
value - 0x100000000 if value >= 0x80000000 else value
for value in values
]
for ins in scr.instructions:
if (
ins.opcode == SET_STRING
and len(ins.args) >= 2
and ins.args[0][0] == T_GLOBAL_STRING
):
destination = ins.args[0][1]
text = scr.strings.get(ins.args[1][1], (None,))[0]
for base, target in (
(AFFINITY_ATTACK_ELEMENT_NAME_BASE, attack_names),
(AFFINITY_DEFENSE_ELEMENT_NAME_BASE, defense_names),
):
element_id = destination - base
if 0 <= element_id < AFFINITY_ELEMENT_NAME_SPAN:
_store_unique(target, element_id, text, element_id)
string_write_count += 1
break
else:
raise ValueError(
f"{scr.path.name}: unexpected string destination "
f"0x{destination:x}"
)
continue
if (
ins.opcode == COPY_LOCAL_ARRAY
and len(ins.args) >= 2
and ins.args[0][0] == T_GLOBAL_INT
and ins.args[1][0] == T_IMM
):
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 footer array 0x{footer_off:x}"
)
values = signed_values(values)
classified = False
relative = destination - AFFINITY_EFFECTIVENESS_BASE
if (
relative % AFFINITY_EFFECTIVENESS_STRIDE == 0
and 0 <= relative
< AFFINITY_EFFECTIVENESS_ROW_COUNT
* AFFINITY_EFFECTIVENESS_STRIDE
):
row = relative // AFFINITY_EFFECTIVENESS_STRIDE
if len(values) != AFFINITY_EFFECTIVENESS_AUTHORED_COLUMNS:
raise ValueError(
f"{scr.path.name}: effectiveness row {row} has "
f"{len(values)} values"
)
_store_unique(
effectiveness_rows, row, (footer_off, values), row
)
classified = True
if not classified:
for base, target in (
(ITEM_TUNING_BONUS_CURVE_BASE, tuning_bonus_rows),
(ITEM_TUNING_COST_CURVE_BASE, tuning_cost_rows),
):
relative = destination - base
if (
relative % ITEM_TUNING_CURVE_STRIDE == 0
and ITEM_TUNING_CURVE_STRIDE
<= relative
<= ITEM_TUNING_CURVE_COUNT
* ITEM_TUNING_CURVE_STRIDE
):
curve_id = relative // ITEM_TUNING_CURVE_STRIDE
if len(values) != ITEM_TUNING_AUTHORED_LEVELS:
raise ValueError(
f"{scr.path.name}: tuning curve {curve_id} has "
f"{len(values)} values"
)
_store_unique(
target, curve_id, (footer_off, values), curve_id
)
classified = True
break
if not classified:
relative = destination - FACILITY_LEVEL_THRESHOLD_BASE
if (
relative % FACILITY_LEVEL_THRESHOLD_STRIDE == 0
and 0 <= relative
< FACILITY_LEVEL_THRESHOLD_ROW_COUNT
* FACILITY_LEVEL_THRESHOLD_STRIDE
):
row = relative // FACILITY_LEVEL_THRESHOLD_STRIDE
if len(values) != FACILITY_LEVEL_THRESHOLD_AUTHORED_LEVELS:
raise ValueError(
f"{scr.path.name}: facility row {row} has "
f"{len(values)} values"
)
_store_unique(
facility_threshold_rows, row, (footer_off, values), row
)
classified = True
if not classified:
raise ValueError(
f"{scr.path.name}: unexpected footer destination "
f"0x{destination:x}"
)
footer_array_count += 1
continue
if sys4load.display_label(ins.opcode) == "exit":
exit_count += 1
else:
raise ValueError(
f"{scr.path.name}: unexpected opcode "
f"{sys4load.display_label(ins.opcode)} at 0x{ins.offset:x}"
)
expected_effectiveness_rows = set(range(AFFINITY_EFFECTIVENESS_ROW_COUNT))
expected_tuning_curves = set(range(1, ITEM_TUNING_CURVE_COUNT + 1))
expected_facility_rows = set(range(FACILITY_LEVEL_THRESHOLD_ROW_COUNT))
if set(effectiveness_rows) != expected_effectiveness_rows:
raise ValueError(f"{scr.path.name}: incomplete effectiveness matrix")
if (
set(tuning_bonus_rows) != expected_tuning_curves
or set(tuning_cost_rows) != expected_tuning_curves
):
raise ValueError(f"{scr.path.name}: incomplete tuning curves")
if set(facility_threshold_rows) != expected_facility_rows:
raise ValueError(f"{scr.path.name}: incomplete facility thresholds")
if exit_count != 1:
raise ValueError(f"{scr.path.name}: expected one exit, got {exit_count}")
records = []
for defense_element_id in sorted(effectiveness_rows):
footer_off, values = effectiveness_rows[defense_element_id]
record = {
"id": defense_element_id,
"name": defense_names.get(defense_element_id, ""),
"defense_element_id": defense_element_id,
"footer_arrays": {
(
f"0x{AFFINITY_EFFECTIVENESS_BASE:x}/"
f"{defense_element_id * AFFINITY_EFFECTIVENESS_STRIDE}"
): {
"footer_off": f"0x{footer_off:x}",
"values": values,
}
},
"attack_effectiveness": [
{
"attack_element_id": attack_element_id,
"attack_element_name": attack_names.get(
attack_element_id, ""
),
"percent": percent,
}
for attack_element_id, percent in enumerate(values)
],
}
if record["name"]:
record["string_fields"] = {
f"0x{AFFINITY_DEFENSE_ELEMENT_NAME_BASE:x}": record["name"]
}
records.append(record)
tuning_curves = []
for curve_id in sorted(tuning_bonus_rows):
bonus_footer_off, bonuses = tuning_bonus_rows[curve_id]
cost_footer_off, costs = tuning_cost_rows[curve_id]
tuning_curves.append({
"curve_id": curve_id,
"level_bonuses": bonuses,
"level_costs": costs,
"bonus_raw_key": (
f"0x{ITEM_TUNING_BONUS_CURVE_BASE:x}/"
f"{curve_id * ITEM_TUNING_CURVE_STRIDE}"
),
"bonus_footer_off": f"0x{bonus_footer_off:x}",
"cost_raw_key": (
f"0x{ITEM_TUNING_COST_CURVE_BASE:x}/"
f"{curve_id * ITEM_TUNING_CURVE_STRIDE}"
),
"cost_footer_off": f"0x{cost_footer_off:x}",
})
facility_names = ("item_tuning", "alchemy", "magic")
facility_thresholds = []
for row in sorted(facility_threshold_rows):
footer_off, thresholds = facility_threshold_rows[row]
facility_thresholds.append({
"system_id": row,
"system": facility_names[row],
"level_progress_thresholds": thresholds,
"raw_key": (
f"0x{FACILITY_LEVEL_THRESHOLD_BASE:x}/"
f"{row * FACILITY_LEVEL_THRESHOLD_STRIDE}"
),
"footer_off": f"0x{footer_off:x}",
})
return records, {
"schema": "affinity-and-progression-tables",
"attack_element_names": [
{"id": element_id, "name": name}
for element_id, name in sorted(attack_names.items())
],
"defense_element_names": [
{"id": element_id, "name": name}
for element_id, name in sorted(defense_names.items())
],
"effectiveness_matrix": {
"base": f"0x{AFFINITY_EFFECTIVENESS_BASE:x}",
"reserved_shape": [
AFFINITY_EFFECTIVENESS_STRIDE,
AFFINITY_EFFECTIVENESS_STRIDE,
],
"authored_rows": AFFINITY_EFFECTIVENESS_ROW_COUNT,
"authored_columns": AFFINITY_EFFECTIVENESS_AUTHORED_COLUMNS,
},
"item_tuning_curves": tuning_curves,
"usable_item_tuning_curve_ids": [
curve["curve_id"]
for curve in tuning_curves
if any(curve["level_bonuses"])
],
"reserved_item_tuning_curve_ids": [
curve["curve_id"]
for curve in tuning_curves
if not any(curve["level_bonuses"])
and not any(curve["level_costs"])
],
"facility_level_thresholds": facility_thresholds,
"string_write_count": string_write_count,
"footer_array_count": footer_array_count,
"exit_count": exit_count,
"classified_instruction_count": (
string_write_count + footer_array_count + exit_count
),
"array_layouts": {
f"0x{AFFINITY_EFFECTIVENESS_BASE:x}": {
"length": (
AFFINITY_EFFECTIVENESS_STRIDE
* AFFINITY_EFFECTIVENESS_STRIDE
),
"stride": AFFINITY_EFFECTIVENESS_STRIDE,
"rows": AFFINITY_EFFECTIVENESS_STRIDE,
},
f"0x{ITEM_TUNING_BONUS_CURVE_BASE:x}": {
"length": (
(ITEM_TUNING_CURVE_COUNT + 1)
* ITEM_TUNING_CURVE_STRIDE
),
"stride": ITEM_TUNING_CURVE_STRIDE,
"rows": ITEM_TUNING_CURVE_COUNT + 1,
},
f"0x{ITEM_TUNING_COST_CURVE_BASE:x}": {
"length": (
(ITEM_TUNING_CURVE_COUNT + 1)
* ITEM_TUNING_CURVE_STRIDE
),
"stride": ITEM_TUNING_CURVE_STRIDE,
"rows": ITEM_TUNING_CURVE_COUNT + 1,
},
f"0x{FACILITY_LEVEL_THRESHOLD_BASE:x}": {
"length": (
FACILITY_LEVEL_THRESHOLD_ROW_COUNT
* FACILITY_LEVEL_THRESHOLD_STRIDE
),
"stride": FACILITY_LEVEL_THRESHOLD_STRIDE,
"rows": FACILITY_LEVEL_THRESHOLD_ROW_COUNT,
},
},
"consumer_contract": {
"affinity": (
"CALCBTPARAM and AI providers index the effectiveness matrix "
"by defense element then attack element; INFOAF displays the "
"consumer-selected rows and the eight shipped attack elements."
),
"item_tuning": (
"TUNE, IMPROVE, DRAWTIP, and CALCREVISE combine each ITINIT "
"curve id with a zero-based tuning level to obtain the stat "
"bonus and point cost."
),
"facility_progression": (
"IMPROVE, ALCHEMY, and MAGIC/USEMAGIC index rows 0, 1, and 2 "
"respectively by current facility level."
),
},
}
def extract_name_entry_palette(scr):
"""Extract CTINIT's five-page, 70-cell name-entry character palette."""
rows = [
[None] * NAME_ENTRY_CHARACTER_PALETTE_STRIDE
for _ in NAME_ENTRY_CHARACTER_PALETTE_ROW_NAMES
]
string_write_count = 0
exit_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
):
destination = ins.args[0][1]
relative = destination - NAME_ENTRY_CHARACTER_PALETTE_BASE
if not (
0 <= relative
< len(rows) * NAME_ENTRY_CHARACTER_PALETTE_STRIDE
):
raise ValueError(
f"{scr.path.name}: unexpected character destination "
f"0x{destination:x}"
)
row, column = divmod(
relative, NAME_ENTRY_CHARACTER_PALETTE_STRIDE
)
if rows[row][column] is not None:
raise ValueError(
f"{scr.path.name}: duplicate character cell {row}/{column}"
)
rows[row][column] = scr.strings.get(
ins.args[1][1], (None,)
)[0]
string_write_count += 1
continue
if sys4load.display_label(ins.opcode) == "exit":
exit_count += 1
else:
raise ValueError(
f"{scr.path.name}: unexpected opcode "
f"{sys4load.display_label(ins.opcode)} at 0x{ins.offset:x}"
)
if exit_count != 1:
raise ValueError(f"{scr.path.name}: expected one exit, got {exit_count}")
records = []
for row_id, (name, characters) in enumerate(zip(
NAME_ENTRY_CHARACTER_PALETTE_ROW_NAMES, rows
)):
populated = [
{"slot": slot, "character": character}
for slot, character in enumerate(characters)
if character is not None
]
records.append({
"id": row_id,
"name": name,
"characters": characters,
"populated_characters": populated,
"string_fields": {
(
f"0x{NAME_ENTRY_CHARACTER_PALETTE_BASE:x}/"
f"{NAME_ENTRY_CHARACTER_PALETTE_STRIDE}/{entry['slot']}"
): entry["character"]
for entry in populated
},
})
return records, {
"schema": "name-entry-character-palette",
"palette_base": f"0x{NAME_ENTRY_CHARACTER_PALETTE_BASE:x}",
"reserved_shape": [
len(NAME_ENTRY_CHARACTER_PALETTE_ROW_NAMES),
NAME_ENTRY_CHARACTER_PALETTE_STRIDE,
],
"row_names": list(NAME_ENTRY_CHARACTER_PALETTE_ROW_NAMES),
"string_write_count": string_write_count,
"exit_count": exit_count,
"classified_instruction_count": string_write_count + exit_count,
"populated_cells_per_row": [
sum(character is not None for character in row) for row in rows
],
"empty_slots_per_row": [
[
slot
for slot, character in enumerate(row)
if character is None
]
for row in rows
],
"consumer_contract": {
"script": "INPUTNAME.BIN",
"lookup": (
"INPUTNAME selects one of five palette pages, indexes its "
"70-cell row by cursor slot, rejects empty cells, and copies "
"a selected character into the seven-character name buffer."
),
"page_selection": (
"Cursor slots 70..74 select palette rows 0..4."
),
},
}
def extract_voice_configuration(scr):
"""Extract CVINIT's preview-voice and character-setting registry."""
preview_by_slot: dict[int, int] = {}
unit_by_slot: dict[int, int] = {}
setting_by_unit: dict[int, int] = {}
static_write_count = 0
classified_write_count = 0
exit_count = 0
for ins in scr.instructions:
write = _static_global_write(ins)
if write is None:
if sys4load.display_label(ins.opcode) == "exit":
exit_count += 1
continue
raise ValueError(
f"{scr.path.name}: unclassified instruction at 0x{ins.offset:x}"
)
static_write_count += 1
destination, value = write
if not isinstance(value, int):
raise ValueError(
f"{scr.path.name}: non-static voice-config value "
f"at 0x{ins.offset:x}"
)
preview_slot = destination - VOICE_CONFIG_PREVIEW_ASSET_ARRAY_BASE
if 0 <= preview_slot < VOICE_CONFIG_SLOT_COUNT:
_store_unique(preview_by_slot, preview_slot, value, preview_slot)
classified_write_count += 1
continue
unit_slot = destination - VOICE_CONFIG_SLOT_UNIT_ARRAY_BASE
if 1 <= unit_slot <= VOICE_CONFIG_NAMED_SLOT_COUNT:
_store_unique(unit_by_slot, unit_slot, value, unit_slot)
classified_write_count += 1
continue
unit_id = destination - VOICE_CONFIG_UNIT_SETTING_ARRAY_BASE
if (
0 <= unit_id < CHARACTER_NAME_RECORD_SPAN
and 1 <= value <= VOICE_CONFIG_NAMED_SLOT_COUNT
):
_store_unique(setting_by_unit, unit_id, value, unit_id)
classified_write_count += 1
continue
raise ValueError(
f"{scr.path.name}: unclassified voice-config write "
f"0x{destination:x} at 0x{ins.offset:x}"
)
expected_preview_slots = set(range(VOICE_CONFIG_SLOT_COUNT))
expected_named_slots = set(range(1, VOICE_CONFIG_NAMED_SLOT_COUNT + 1))
if set(preview_by_slot) != expected_preview_slots:
raise ValueError(
f"{scr.path.name}: preview slots are "
f"{sorted(preview_by_slot)}, expected 0..{VOICE_CONFIG_SLOT_COUNT - 1}"
)
if set(unit_by_slot) != expected_named_slots:
raise ValueError(
f"{scr.path.name}: named slots are {sorted(unit_by_slot)}, "
f"expected 1..{VOICE_CONFIG_NAMED_SLOT_COUNT}"
)
if set(setting_by_unit.values()) != expected_named_slots:
raise ValueError(
f"{scr.path.name}: inverse setting ids are "
f"{sorted(setting_by_unit.values())}, expected "
f"1..{VOICE_CONFIG_NAMED_SLOT_COUNT}"
)
if len(setting_by_unit) != VOICE_CONFIG_NAMED_SLOT_COUNT:
raise ValueError(
f"{scr.path.name}: expected {VOICE_CONFIG_NAMED_SLOT_COUNT} "
f"unit-to-setting writes, found {len(setting_by_unit)}"
)
for slot, unit_id in unit_by_slot.items():
if setting_by_unit.get(unit_id) != slot:
raise ValueError(
f"{scr.path.name}: slot {slot} -> unit {unit_id} does not "
f"round-trip through the inverse map"
)
if exit_count != 1:
raise ValueError(
f"{scr.path.name}: expected one exit, found {exit_count}"
)
unit_records, _ = extract_name(sys4load.load(resolve("EBINIT")))
unit_names = {record["id"]: record["name"] for record in unit_records}
missing_unit_ids = sorted(set(unit_by_slot.values()) - set(unit_names))
if missing_unit_ids:
raise ValueError(
f"{scr.path.name}: unknown EBINIT unit ids {missing_unit_ids}"
)
asset_names = callscript_names()
preview_key = f"0x{VOICE_CONFIG_PREVIEW_ASSET_ARRAY_BASE:x}"
unit_key = f"0x{VOICE_CONFIG_SLOT_UNIT_ARRAY_BASE:x}"
inverse_base = f"0x{VOICE_CONFIG_UNIT_SETTING_ARRAY_BASE:x}"
records = []
for slot in range(VOICE_CONFIG_SLOT_COUNT):
preview_asset_id = preview_by_slot[slot]
record = {
"id": slot,
"name": (
"system_voice" if slot == 0 else unit_names[unit_by_slot[slot]]
),
"slot_kind": "system" if slot == 0 else "character",
"preview_voice_asset_id": preview_asset_id,
"preview_voice_asset_name": asset_names.get(preview_asset_id, ""),
"fields": {preview_key: preview_asset_id},
"array_fields": {},
}
if slot:
unit_id = unit_by_slot[slot]
inverse_key = f"{inverse_base}/{unit_id}"
record["fields"][unit_key] = unit_id
record["array_fields"][inverse_key] = slot
record.update({
"unit_id": unit_id,
"unit_name": unit_names[unit_id],
"voice_suppression_setting_id": slot,
"speaker_seen_flag_address": (
f"0x{VOICE_CONFIG_SPEAKER_SEEN_ARRAY_BASE + unit_id:x}"
),
})
records.append(record)
return records, {
"schema": "character-voice-configuration",
"slot_count": VOICE_CONFIG_SLOT_COUNT,
"system_slot": 0,
"named_character_slots": list(
range(1, VOICE_CONFIG_NAMED_SLOT_COUNT + 1)
),
"preview_asset_array_base": (
f"0x{VOICE_CONFIG_PREVIEW_ASSET_ARRAY_BASE:x}"
),
"slot_unit_array_base": f"0x{VOICE_CONFIG_SLOT_UNIT_ARRAY_BASE:x}",
"unit_setting_array_base": (
f"0x{VOICE_CONFIG_UNIT_SETTING_ARRAY_BASE:x}"
),
"speaker_seen_array_base": (
f"0x{VOICE_CONFIG_SPEAKER_SEEN_ARRAY_BASE:x}"
),
"array_layouts": {
inverse_base: {"length": CHARACTER_NAME_RECORD_SPAN},
},
"array_field_columns": [
f"{inverse_base}/{unit_id}" for unit_id in sorted(setting_by_unit)
],
"static_write_count": static_write_count,
"classified_static_write_count": classified_write_count,
"exit_count": exit_count,
"classified_instruction_count": classified_write_count + exit_count,
"preview_asset_join_count": sum(
bool(record["preview_voice_asset_name"]) for record in records
),
"unit_join_count": len(unit_by_slot),
"round_trip_mapping_count": sum(
setting_by_unit[unit_id] == slot
for slot, unit_id in unit_by_slot.items()
),
"consumer_contract": {
"script": "CONFIG.BIN",
"preview": (
"CONFIG indexes the thirteen preview assets by voice-setting "
"slot and plays the selected clip before changing that slot's "
"suppression flag."
),
"character_rows": (
"CONFIG lists slots 1..12 by resolving their unit ids through "
"the shared unit display-name table. A persisted per-unit "
"speaker-seen flag controls whether each row is available."
),
"runtime_voice_filter": (
"Story, history, field, and battle paths normalize a unit to "
"its voice family, map that representative unit through the "
"CVINIT inverse table, and test the selected one of thirteen "
"character_voice_suppressed settings."
),
},
}
@cache
def gallery_thumbnail_sheet_assets() -> dict[int, int]:
"""Read CGMODE's enabled thumbnail-sheet assets from INIT2."""
script = sys4load.load(resolve("INIT2"))
assets = {}
for ins in script.instructions:
write = _static_global_write(ins)
if write is None:
continue
destination, value = write
index = destination - GALLERY_THUMBNAIL_SHEET_CONFIG_BASE
if (
0 <= index < GALLERY_THUMBNAIL_SHEET_CONFIG_SPAN
and isinstance(value, int)
and value
):
assets[index + 1] = value
if not assets:
raise ValueError("INIT2.BIN: no configured CGMODE thumbnail sheets")
return assets
@cache
def callscript_names() -> dict[int, str]:
"""Load the generated packed script-resource id join."""
try:
data = json.loads(
(paths.BUILD / "callscript-names.json").read_text(encoding="utf8")
)
except (OSError, json.JSONDecodeError):
return {}
return {int(key): value for key, value in data.items()}
@cache
def scjump_decision_chapters() -> tuple[dict[int, set[int]], int]:
"""Load SCJUMP's generated decision sites as independent correlation evidence."""
try:
data = json.loads(
(paths.BUILD / "scjump-decisions.json").read_text(encoding="utf8")
)
except (OSError, json.JSONDecodeError):
return {}, 0
chapters: dict[int, set[int]] = {}
for decision in data.get("decisions", []):
chapter = decision.get("chapter")
if isinstance(chapter, int):
chapters.setdefault(decision["decision"], set()).add(chapter)
return chapters, len(data.get("decisions", []))
def extract_dispatch(scr):
"""Extract SCINIT's decision -> scene-script registry without losing overwrites."""
paired = _paired_parallel_writes(scr)
if paired is None:
return [], {}
writes, span = paired
primary_base = writes[0][1]
chapter_base = primary_base + span
names = callscript_names()
scjump_chapters, decision_site_count = scjump_decision_chapters()
records_by_id: dict[int, dict] = {}
assignment_count = 0
for index in range(0, len(writes), 2):
primary, chapter = writes[index:index + 2]
decision_id = primary[1] - primary_base
script_resource_id = primary[2]
assignment = {
"offset": f"0x{primary[0]:x}",
"script_resource_id": script_resource_id,
"script_name": names.get(script_resource_id, ""),
"authored_chapter": chapter[2],
}
record = records_by_id.setdefault(decision_id, {
"id": decision_id,
"assignments": [],
})
record["assignments"].append(assignment)
assignment_count += 1
chapter_match_count = 0
chapter_mismatches = []
resolved_script_count = 0
overwritten_record_count = 0
conflicting_chapter_record_count = 0
for decision_id, record in records_by_id.items():
assignments = record["assignments"]
final = assignments[-1]
script_resource_id = final["script_resource_id"]
authored_chapter = final["authored_chapter"]
record.update({
"name": final["script_name"],
"script_resource_id": script_resource_id,
"script_name": final["script_name"],
"authored_chapter": authored_chapter,
"assignment_count": len(assignments),
"fields": {
f"0x{primary_base:x}": script_resource_id,
f"0x{chapter_base:x}": authored_chapter,
},
})
if final["script_name"]:
resolved_script_count += 1
if len(assignments) > 1:
overwritten_record_count += 1
if len({assignment["authored_chapter"] for assignment in assignments}) > 1:
conflicting_chapter_record_count += 1
if decision_id in scjump_chapters:
expected = sorted(scjump_chapters[decision_id])
record["scjump_chapters"] = expected
matches = authored_chapter in scjump_chapters[decision_id]
record["authored_chapter_matches_scjump"] = matches
if matches:
chapter_match_count += 1
else:
chapter_mismatches.append({
"decision_id": decision_id,
"authored_chapter": authored_chapter,
"scjump_chapters": expected,
})
records = [records_by_id[key] for key in sorted(records_by_id)]
return records, {
"selector_global": "0x62ccf",
"script_resource_array_base": f"0x{primary_base:x}",
"authored_chapter_array_base": f"0x{chapter_base:x}",
"reserved_array_span": span,
"assignment_count": assignment_count,
"overwritten_record_count": overwritten_record_count,
"conflicting_chapter_record_count": conflicting_chapter_record_count,
"resolved_script_count": resolved_script_count,
"scjump_decision_site_count": decision_site_count,
"scjump_distinct_decision_count": len(scjump_chapters),
"scjump_joined_record_count": sum(
record["id"] in scjump_chapters for record in records
),
"scjump_chapter_match_count": chapter_match_count,
"scjump_chapter_mismatches": sorted(
chapter_mismatches, key=lambda row: row["decision_id"]
),
}
def _movement_provider_names(names: dict[int, str]) -> dict[int, str]:
providers = {
selector: names.get(0x32FB + selector, "")
for selector in range(1, 19)
}
providers.update({
51: names.get(0x330E, ""),
52: names.get(0x330F, ""),
53: names.get(0x3310, ""),
61: names.get(0x3311, ""),
})
return providers
def _join_movement_provider_semantics(step: dict) -> tuple[int, int, int]:
"""Add selector-specific RTN_M semantics while retaining every raw bank."""
selector = step.get("movement_provider_selector")
schema = MOVEMENT_PROVIDER_PARAMETER_SCHEMAS.get(selector)
if schema is None:
return 0, 0, 0
step["provider_behavior"] = schema["behavior"]
joined = 0
defaulted = 0
defaults = schema.get("parameter_defaults", {})
for raw_field, semantic_field in schema["parameter_fields"].items():
if raw_field in step:
step[semantic_field] = step[raw_field]
joined += 1
elif raw_field in defaults:
step[semantic_field] = defaults[raw_field]
defaulted += 1
ignored_fields = {
raw_field: step[raw_field]
for raw_field in schema.get("ignored_parameter_fields", {})
if raw_field in step
}
if ignored_fields:
step["ignored_movement_parameters"] = ignored_fields
return joined, defaulted, len(ignored_fields)
def extract_banked(scr):
"""Extract RTINIT's sparse routine sets across twenty parallel step banks."""
writes = _routine_bank_writes(scr)
if writes is None:
return [], {}
names = callscript_names()
movement_providers = _movement_provider_names(names)
battle_providers = {
selector: names.get(0x32F6 + selector, "")
for selector in range(1, 5)
}
records_by_id: dict[int, dict] = {}
cell_assignments: dict[tuple[int, int, int], list[int]] = collections.defaultdict(list)
bank_cells: dict[int, set[tuple[int, int]]] = collections.defaultdict(set)
decoded_movement_step_count = 0
decoded_movement_parameter_count = 0
decoded_movement_defaulted_parameter_count = 0
ignored_movement_parameter_count = 0
for offset, destination, value, bank_index, record_id, slot in writes:
bank_base = ROUTINE_BANK_ROOT + bank_index * ROUTINE_BANK_SPAN
key = f"0x{bank_base:x}/{ROUTINE_RECORD_STRIDE}/{slot}"
assignment = {
"offset": f"0x{offset:x}",
"bank_index": bank_index,
"bank_base": f"0x{bank_base:x}",
"role": ROUTINE_BANK_ROLES[bank_index],
"slot": slot,
"value": value,
}
record = records_by_id.setdefault(record_id, {
"id": record_id,
"assignments": [],
"record_fields": {},
})
record["assignments"].append(assignment)
record["record_fields"][key] = value
cell_assignments[(bank_index, record_id, slot)].append(value)
bank_cells[bank_index].add((record_id, slot))
for record in records_by_id.values():
final_by_bank_slot = {}
for assignment in record["assignments"]:
final_by_bank_slot[
(assignment["bank_index"], assignment["slot"])
] = assignment["value"]
movement_steps = []
battle_steps = []
for slot in range(ROUTINE_RECORD_STRIDE):
movement = {
ROUTINE_BANK_ROLES[bank]: final_by_bank_slot[(bank, slot)]
for bank in range(10)
if (bank, slot) in final_by_bank_slot
}
if movement:
selector = movement.get("movement_provider_selector")
step = {
"slot": slot,
**movement,
**(
{"provider_script": movement_providers.get(selector, "")}
if selector is not None else {}
),
}
(
joined_parameter_count,
defaulted_parameter_count,
ignored_parameter_count,
) = _join_movement_provider_semantics(step)
if selector in MOVEMENT_PROVIDER_PARAMETER_SCHEMAS:
decoded_movement_step_count += 1
decoded_movement_parameter_count += joined_parameter_count
decoded_movement_defaulted_parameter_count += (
defaulted_parameter_count
)
ignored_movement_parameter_count += ignored_parameter_count
movement_steps.append(step)
battle = {
ROUTINE_BANK_ROLES[bank]: final_by_bank_slot[(bank, slot)]
for bank in range(10, 20)
if (bank, slot) in final_by_bank_slot
}
if battle:
selector = battle.get("battle_provider_selector")
battle_steps.append({
"slot": slot,
**battle,
**(
{"provider_script": battle_providers.get(selector, "")}
if selector is not None else {}
),
})
if movement_steps:
record["movement_steps"] = movement_steps
if battle_steps:
record["battle_steps"] = battle_steps
records = [records_by_id[key] for key in sorted(records_by_id)]
record_ids = set(records_by_id)
used_movement_providers = sorted({
step["movement_provider_selector"]
for record in records
for step in record.get("movement_steps", [])
})
used_battle_providers = sorted({
step["battle_provider_selector"]
for record in records
for step in record.get("battle_steps", [])
})
bank_layouts = {}
for bank_index, role in enumerate(ROUTINE_BANK_ROLES):
base = ROUTINE_BANK_ROOT + bank_index * ROUTINE_BANK_SPAN
cells = bank_cells.get(bank_index, set())
bank_layouts[f"0x{base:x}"] = {
"bank_index": bank_index,
"family": "movement" if bank_index < 10 else "battle",
"role": role,
"reserved_empty": not cells,
"populated_cell_count": len(cells),
"populated_record_count": len({record_id for record_id, _ in cells}),
"populated_slots": sorted({slot for _, slot in cells}),
}
record_columns = sorted(
{
key
for record in records
for key in record.get("record_fields", {})
},
key=lambda key: tuple(int(part, 0) for part in key.split("/")),
)
return records, {
"schema": "routine-step-banks",
"selector_global": f"0x{ROUTINE_SET_ID:x}",
"step_index_global": f"0x{ROUTINE_STEP_INDEX:x}",
"execution_state_global": f"0x{ROUTINE_EXECUTION_STATE:x}",
"bank_root_base": f"0x{ROUTINE_BANK_ROOT:x}",
"bank_span": ROUTINE_BANK_SPAN,
"bank_count": ROUTINE_BANK_COUNT,
"record_stride": ROUTINE_RECORD_STRIDE,
"reserved_record_span": ROUTINE_RECORD_SPAN,
"first_record_id": min(record_ids),
"last_record_id": max(record_ids),
"missing_record_ids": sorted(
set(range(min(record_ids), max(record_ids) + 1)) - record_ids
),
"assignment_count": len(writes),
"populated_cell_count": len(cell_assignments),
"overwritten_cell_count": sum(
len(values) > 1 for values in cell_assignments.values()
),
"conflicting_overwrite_count": sum(
len(set(values)) > 1 for values in cell_assignments.values()
),
"movement_step_count": sum(
len(record.get("movement_steps", [])) for record in records
),
"battle_step_count": sum(
len(record.get("battle_steps", [])) for record in records
),
"movement_provider_scripts": {
str(selector): name
for selector, name in sorted(movement_providers.items())
},
"movement_provider_parameter_schemas": {
str(selector): {
"provider_script": movement_providers.get(selector, ""),
**schema,
}
for selector, schema in sorted(
MOVEMENT_PROVIDER_PARAMETER_SCHEMAS.items()
)
},
"decoded_movement_provider_count": len(
MOVEMENT_PROVIDER_PARAMETER_SCHEMAS
),
"decoded_movement_step_count": decoded_movement_step_count,
"decoded_movement_parameter_count": decoded_movement_parameter_count,
"decoded_movement_defaulted_parameter_count": (
decoded_movement_defaulted_parameter_count
),
"ignored_movement_parameter_count": ignored_movement_parameter_count,
"battle_provider_scripts": {
str(selector): name
for selector, name in sorted(battle_providers.items())
},
"used_movement_provider_selectors": used_movement_providers,
"used_battle_provider_selectors": used_battle_providers,
"bank_layouts": bank_layouts,
"record_field_columns": record_columns,
}
def extract_footer(scr):
records = []
for i, ins in enumerate(scr.instructions):
if ins.opcode == COPY_LOCAL_ARRAY and ins.args and ins.args[0][0] == T_GLOBAL_INT:
addr = ins.args[0][1]
foff = ins.args[1][1]
vals = read_footer_array(scr, foff)
records.append({"id": i, "global_addr": f"0x{addr:x}",
"footer_off": f"0x{foff:x}",
"length": len(vals) if vals else 0,
"values": vals if vals else []})
return records, {}
def extract_terrain_definitions(scr):
"""Extract LAINIT's terrain definitions and shared texture-slot assets."""
names: dict[int, str] = {}
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, {}),
}
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
):
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:
_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
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:
_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",
1: "open_area",
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(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 {}
),
})
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]:
"""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)
by_id = {message["id"]: message for message in messages}
joined = 0
for record in records:
if message := by_id.get(record["id"]):
record["message"] = {
key: value for key, value in message.items() if key != "id"
}
joined += 1
init_ids = {record["id"] for record in records}
message_ids = set(by_id)
return {
"source": message_scr.path.name,
**message_meta,
"joined_count": joined,
"init_ids_without_message": sorted(init_ids - message_ids),
"message_ids_without_init": sorted(message_ids - init_ids),
}
@cache
def _global_registry() -> dict:
path = paths.BUILD / "globals.json"
try:
return json.loads(path.read_text(encoding="utf8")).get("globals", {})
except (OSError, json.JSONDecodeError):
return {}
def field_semantics(
records: list[dict], array_layouts: dict[str, dict] | None = None
) -> dict[str, str]:
"""Map raw extracted field keys to canonical semantic names when available."""
footer_keys = {
key
for record in records
for key in record.get("footer_arrays", {})
}
keys = {
key
for record in records
for key in (
*record.get("string_fields", {}),
*record.get("fields", {}),
*record.get("array_fields", {}),
*record.get("footer_arrays", {}),
*record.get("record_fields", {}),
)
}
registry = _global_registry()
semantics = {}
for key in sorted(keys, key=lambda value: tuple(
int(part, 0) for part in value.split("/")
)):
parts = key.split("/")
entry = registry.get(f"0x{int(parts[0], 16):x}", {})
name = entry.get("name")
if not name:
continue
if len(parts) == 2:
index = int(parts[1])
layout = (array_layouts or {}).get(f"0x{int(parts[0], 16):x}", {})
if stride := layout.get("stride"):
row, column = divmod(index, stride)
if key in footer_keys:
# A footer copy owns the complete row beginning at this offset.
name = f"{name}.row_{row}"
else:
column_name = entry.get("columns", {}).get(
str(column), f"column_{column}"
)
name = f"{name}.row_{row}.{column_name}"
else:
index_name = entry.get("columns", {}).get(str(index), f"index_{index}")
name = f"{name}.{index_name}"
elif len(parts) == 3:
column = parts[2]
column_name = entry.get("columns", {}).get(column, f"column_{column}")
name = f"{name}.{column_name}"
semantics[key] = name
return semantics
def attach_semantic_fields(records: list[dict], semantics: dict[str, str]) -> None:
"""Add a generated name-keyed view while retaining raw address provenance."""
containers = (
"string_fields", "fields", "array_fields", "footer_arrays", "record_fields"
)
for record in records:
semantic_fields = {}
for container in containers:
for key, value in record.get(container, {}).items():
if semantic_name := semantics.get(key):
if semantic_name in semantic_fields:
raise ValueError(
f"record {record['id']}: duplicate semantic field {semantic_name}"
)
semantic_fields[semantic_name] = (
value["values"] if container == "footer_arrays" else value
)
if semantic_fields:
record["semantic_fields"] = semantic_fields
else:
record.pop("semantic_fields", None)
def attach_stage_object_placements(
records: list[dict], definitions: dict[int, dict] | None = None
) -> None:
"""Assemble STINIT's parallel object buffers into modder-facing slot records."""
if definitions is None:
definitions = object_type_definitions()
known_fields = {
"type_id": "0xe7389",
"tile_x": "0xe7325",
"tile_y": "0xe7357",
"difficulty_mask": "0xe7483",
"reinforcement_interval_turns": "0xe741f",
"reinforcement_spawn_limit": "0xe7451",
}
for record in records:
fields = record.get("array_fields", {})
objects = []
for slot in range(1, 50):
type_key = f"{known_fields['type_id']}/{slot}"
if type_key not in fields:
continue
type_id = fields[type_key]
obj = {"slot": slot, "type_id": type_id}
if definition := definitions.get(type_id):
obj["type_name"] = definition["name"]
if description := definition.get("description"):
obj["type_description"] = description
for semantic_name, base in known_fields.items():
if semantic_name == "type_id":
continue
if (key := f"{base}/{slot}") in fields:
obj[semantic_name] = fields[key]
required = [
fields[key]
for column in range(7)
if (key := f"0xe74b5/{slot * 7 + column}") in fields
and fields[key] > 0
]
forbidden = [
fields[key]
for column in range(5)
if (key := f"0xe7613/{slot * 5 + column}") in fields
and fields[key] > 0
]
if required:
obj["required_story_flags"] = required
if forbidden:
obj["forbidden_story_flags"] = forbidden
payload = {
base: fields[key]
for base in ("0xe73bb", "0xe73ed")
if (key := f"{base}/{slot}") in fields
}
if type_id in (1, 2, 3, 4) and "0xe73bb" in payload:
obj["initial_faction_id"] = payload.pop("0xe73bb")
elif type_id in (6, 36):
if "0xe73bb" in payload:
obj["destination_tile_x"] = payload.pop("0xe73bb")
if "0xe73ed" in payload:
obj["destination_tile_y"] = payload.pop("0xe73ed")
elif type_id in (7, 8):
if "0xe73bb" in payload:
obj["item_id"] = payload.pop("0xe73bb")
if "0xe73ed" in payload:
obj["item_quantity"] = payload.pop("0xe73ed")
elif type_id == 28 and "0xe73bb" in payload:
obj["card_generation_list_id"] = payload.pop("0xe73bb")
elif 18 <= type_id <= 25 and "0xe73bb" in payload:
obj["non_triggering_faction_id"] = payload.pop("0xe73bb")
elif (definition
and definition["uses_runtime_state_sprite_row"]
and "0xe73bb" in payload):
obj["initial_object_state_id"] = payload.pop("0xe73bb")
elif type_id == 27 and payload:
# FIELD's dedicated otherworld-gate branch consumes the common
# schedule and coordinates, then calls ADDEN's hard-coded slot-0
# special-unit path. It never reads either tagged payload cell.
obj["ignored_payload_fields"] = payload
payload = {}
unknown = payload
if unknown:
obj["unknown_fields"] = unknown
objects.append(obj)
record["object_placements"] = objects
def attach_stage_enemy_spawns(records: list[dict]) -> None:
"""Assemble STINIT's parallel enemy buffers into modder-facing slot records."""
direct_fields = {
"unit_id": "0xe7811",
"faction_id": "0xe7799",
"difficulty_mask": "0xe77b7",
"min_level": "0xe782f",
"max_level": "0xe784d",
"auto_level_scale_divisor": "0xe786b",
}
optional_fields = {
"tile_x": "0xe773f",
"tile_y": "0xe775d",
"object_slot": "0xe777b",
"random_selection_weight": "0xe77f3",
}
routine_fields = {
"movement_routine_set_ids": ("0xe7889", 3),
"battle_routine_set_ids": ("0xe78e3", 3),
}
for record in records:
fields = record.get("array_fields", {})
footer_arrays = record.get("footer_arrays", {})
spawns = []
# Slot zero is reserved by ADDEN for its synthesized special-unit path.
for slot in range(1, 30):
unit_key = f"{direct_fields['unit_id']}/{slot}"
if unit_key not in fields:
continue
spawn = {"slot": slot}
for semantic_name, base in direct_fields.items():
key = f"{base}/{slot}"
# Faction zero is the buffer default and is meaningful to SETEN.
if key in fields:
spawn[semantic_name] = fields[key]
elif semantic_name == "faction_id":
spawn[semantic_name] = 0
for semantic_name, base in optional_fields.items():
if (key := f"{base}/{slot}") in fields:
spawn[semantic_name] = fields[key]
for semantic_name, (base, stride) in routine_fields.items():
key = f"{base}/{slot * stride}"
if key in footer_arrays:
spawn[semantic_name] = footer_arrays[key]["values"]
required = [
fields[key]
for column in range(7)
if (key := f"0xe793d/{slot * 7 + column}") in fields
and fields[key] > 0
]
forbidden = [
fields[key]
for column in range(5)
if (key := f"0xe7a0f/{slot * 5 + column}") in fields
and fields[key] > 0
]
if required:
spawn["required_story_flags"] = required
if forbidden:
spawn["forbidden_story_flags"] = forbidden
unknown = {}
if (mode_key := f"0xe77d5/{slot}") in fields:
mode = fields[mode_key]
if mode == 2:
spawn["first_clear_only"] = True
else:
unknown["0xe77d5"] = mode
if unknown:
spawn["unknown_fields"] = unknown
spawns.append(spawn)
record["enemy_spawns"] = spawns
def write_data_index(data_dir: Path) -> None:
"""Regenerate the disposable build/data index from current table JSONs."""
tables = []
for path in sorted(data_dir.glob("*.json")):
if path.name.endswith("-field-profile.json"):
continue
try:
data = json.loads(path.read_text(encoding="utf8"))
except (OSError, json.JSONDecodeError):
continue
if "table" in data and "record_count" in data:
tables.append((path.name, data))
lines = [
"<!-- DO NOT EDIT -- generated by tools/extract_init.py -->",
"# Parsed INIT data tables",
"",
"The JSON files in this directory are generated from SYS4 `*INIT` scripts. Raw global-array",
"bases remain available in every record; confirmed field meanings live in",
"`vm-map/globals.toml` and the generated `docs/global-reference.md`.",
"",
"| file | mode | records | messages | scalar/array fields | strings | buffer cells | footer arrays | record columns |",
"|---|---|---:|---:|---:|---:|---:|---:|---:|",
]
for filename, data in tables:
columns = len(data.get("field_columns") or [])
record_columns = len(data.get("record_field_columns") or [])
message_count = data.get("message_table", {}).get(
"joined_count", data.get("message_count", 0)
)
lines.append(
f"| `{filename}` | {data['mode']} | {data['record_count']} | "
f"{message_count} | {columns} | {len(data.get('string_field_columns') or [])} | "
f"{len(data.get('array_field_columns') or [])} | "
f"{len(data.get('footer_array_columns') or [])} | {record_columns} |"
)
lines += [
"",
"Name-mode tables expose one-based runtime `id` values, the lookup `name_array_base`,",
"the first populated `name_write_base`, and the reserved `record_span`. Fields are keyed",
"by the runtime lookup base used by `lookup-array`, not merely the first written cell.",
"Linked row-major fields are stored separately in `record_fields`, keyed as",
"`base/stride/column` from corpus-observed `lookup-array-2d` consumers.",
"Where a matching `*MES` dispatcher exists, `message` preserves its player-facing",
"layout-specific text (title/description, summary/strategy, biography, or description-only), furigana,",
"and bytecode dispatch offset separately from the",
"short description stored by the INIT script.",
"Top-level `field_semantics` maps raw array/row-column keys to canonical machine-readable",
"names from `vm-map/globals.toml`; each record's generated `semantic_fields` is the joined",
"name-keyed convenience view. Complete footer copies expose their row values there while",
"raw keys and footer metadata remain intact as bytecode provenance.",
"",
"ILINIT's dedicated condition schema exposes thirteen authored condition ids in the",
"reserved 30-by-5 layout. Each record keeps its raw scalar and row-table cells while",
"joining level names, durations, eleven-stat deltas, three-resource deltas, boss/recovery",
"policies, and icon ids. Top-level `recovery_protocol` validates RECOVER.BIN and links",
"current levels, equipment/passive baselines, remaining turns, and full resource restore.",
"",
"CNINIT's dedicated unit-name schema exposes 277 sparse EBINIT-keyed rows in two",
"parallel 1,000-cell arrays: story/display names and canonical voice-family unit ids.",
"Every row joins both its own EBINIT definition and the representative voice-family",
"definition; deliberately empty names and variant aliases remain explicit.",
"",
"CGINIT's dedicated gallery schema exposes 851 sparse ids in a reserved 2,000-row",
"layout. Each row joins its full-size image asset, one of four 30-cell thumbnail",
"atlases, its atlas slot and variant ordinal, and the optional 112-by-84 preview",
"used by SAVE and SELSTAGE. Raw global-array provenance remains beside these joins.",
"",
"ALINIT's dedicated alchemy schema exposes 107 sparse recipes in a reserved 1,000-row",
"layout. Output and ingredient item ids join to ITINIT names; minimum alchemy level,",
"point cost, required/forbidden story flags, and four fixed ingredient slots retain",
"their raw parallel-array and row-table coordinates.",
"",
"AFINIT's dedicated affinity/progression schema exposes its attack and defense",
"element vocabularies, signed effectiveness matrix, eighteen usable item-tuning",
"bonus/cost curves plus a reserved zero row, and three facility progression rows.",
"",
"CTINIT's dedicated name-entry schema exposes INPUTNAME's five 70-cell palette",
"pages (hiragana, katakana, Latin, numerals, and symbols), preserving all reserved",
"empty slots beside the 273 authored characters.",
"",
"CVINIT's dedicated character-voice schema exposes CONFIG's thirteen preview",
"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",
"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`",
"and `enemy_spawns`. Its object type ids join to OBINIT's authoritative names and",
"available descriptions; consumer-proven tagged payload variants receive semantic names while",
"engine-dead tagged writes remain in `ignored_payload_fields` and unresolved",
"type-specific/mode parameters remain in `unknown_fields`.",
"",
"Rule-mode tables preserve source-order rule ids and bytecode guard offsets while",
"joining their predicates and shared-buffer effects. CCINIT exposes unit/level/applied-slot-index",
"eligibility, titles, deployment-cost and named stat deltas, awarded SKINIT skills, and",
"the persistent state slot set by each class change. Raw output addresses remain beside",
"the joined EBINIT unit and SKINIT skill names.",
"",
"Dispatch-mode tables preserve SCINIT's complete source-ordered assignment history",
"while exposing the final sparse decision-id registry. Packed resource ids join to",
"SYS4INI script names, authored chapter tags correlate with SCJUMP's decoded decision",
"sites, and legacy/stale chapter mismatches remain explicit.",
"",
"Banked-mode tables preserve RTINIT's twenty parallel 1000-by-20 routine banks,",
"source-ordered overwrites, and final row/slot values. Joined movement and battle",
"steps resolve provider selectors to RTN_M/RTN_B scripts while provider-specific",
"parameter banks retain structural names until their individual consumers prove more.",
"Use `tools/init_table_profile.py <TABLE> --build` to generate value/population and",
"direct-consumer evidence.",
"",
]
(data_dir / "README.md").write_text("\n".join(lines), encoding="utf8")
def main() -> int:
argv = []
mode_arg = None
index = 1
while index < len(sys.argv):
arg = sys.argv[index]
if arg == "--mode":
if index + 1 >= len(sys.argv):
raise SystemExit("--mode requires a value")
mode_arg = sys.argv[index + 1]
index += 2
continue
if arg.startswith("--"):
raise SystemExit(f"unknown option: {arg}")
argv.append(arg)
index += 1
if not argv:
raise SystemExit(__doc__)
name = argv[0].upper().removesuffix(".BIN")
try:
outname = normalize_outname(argv[1]) if len(argv) > 1 else name
except ValueError as error:
raise SystemExit(str(error)) from error
scr = sys4load.load(resolve(name))
mode = mode_arg or detect_mode(scr)
extractor = {
"name": extract_name,
"numeric": extract_numeric,
"footer": extract_footer,
"mixed": extract_mixed,
"rules": extract_class_change_rules,
"dispatch": extract_dispatch,
"banked": extract_banked,
}[mode]
if mode == "name" and name == "VIINIT":
extractor = extract_vocabulary
elif mode == "name" and name == "CNINIT":
extractor = extract_character_names
elif mode == "name" and name == "CIINIT":
extractor = extract_character_profiles
elif mode == "name" and name == "MAINIT":
extractor = extract_magic_actions
elif mode == "name" and name == "ILINIT":
extractor = extract_condition_definitions
elif mode == "name" and name == "AFINIT":
extractor = extract_affinity_definitions
elif mode == "name" and name == "CTINIT":
extractor = extract_name_entry_palette
elif mode == "numeric" and name == "CGINIT":
extractor = extract_gallery_definitions
elif mode == "numeric" and name == "ALINIT":
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)
if mode == "name" and name in MESSAGE_TABLES:
message_name = MESSAGE_TABLES[name]
meta["message_table"] = join_messages(
recs, sys4load.load(extract_message_table.resolve(message_name))
)
cols = sorted({c for r in recs for c in r.get("fields", {})}, key=lambda h: int(h, 16))
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"
attach_stage_object_placements(recs)
attach_stage_enemy_spawns(recs)
out = {"table": name, "source": scr.path.name, "magic": scr.magic, "mode": mode,
"record_count": len(recs), **meta,
"field_columns": cols if mode != "footer" else None,
"field_semantics": semantics, "records": recs}
outpath = paths.BUILD / "data" / f"{outname}.json"
outpath.parent.mkdir(parents=True, exist_ok=True)
outpath.write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8")
write_data_index(outpath.parent)
print(f"{name}: mode={mode}, {len(recs)} records"
+ (
f", {len(meta.get('record_field_columns', []))} record-columns"
if mode == "banked"
else f", {len(cols)} field-columns" if mode != "footer" else ""
)
+ f" -> build/data/{outname}.json")
for r in recs[:4]:
if mode == "footer":
print(f" id {r['id']:>4} {r['global_addr']} <- footer {r['footer_off']} "
f"len {r['length']} head={r['values'][:8]}")
else:
fields = r.get("fields", {})
f4 = {k: fields[k] for k in list(fields)[:4]}
print(f" id {r['id']:>4} {r.get('name','')!r:12} desc={r.get('desc','')!r} {f4}")
return 0
if __name__ == "__main__":
sys.exit(main())