#!/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. TRINIT is a special name-mode registry: 21 training/sexual-magic actions each own six display-text slots and a contiguous block of eligibility, cost, effect, award, and ten-slot event arrays consumed by TRAIN and restored by GAMESTART. CDINIT is a special numeric-mode registry: nine selector-dispatched card generation lists populate a parallel card-id array and three-column weight schedule. FIELD filters the joined CDINIT2 definitions by story flags and uses the current stage turn to grow each candidate's weighted-selection share. CDINIT2 is a special name-mode registry: 81 cards occupy one contiguous 100-row definition block with story gates, item/event/point rewards, ranged HP/SP/FS and spirit effects, conditions, warp behavior, and visual assets. BTANINIT2 is a special numeric-mode registry: 122 sparse battle animations occupy three reserved 1,000-row arrays for six effect ids, six start delays, and one total duration. BTANINIT dispatches 202 effect ids into BTL's six-slot visual/audio/hit-pulse work record. STINIT2 is a special name-mode registry: 74 stages occupy a sparse 1,000-row catalog with six description lines (three before clear and three after), availability/story gates, map and minimap geometry, entry/clear/failure SCJUMP decisions, clear rewards, and a shared STINIT stage-loader reference. Records are {id, name?, desc?, fields:{"0x": value}} or, for footer tables, {id, global_addr, footer_off, values:[...]}. Column addresses are raw engine globals; confirmed names come from the generated engine global registry while raw keys remain provenance. Usage: py -3.11 -X utf8 tools/extract_init.py [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 T_LOCAL_PTR = 12 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 STAGE_DEFINITION_NAME_BASE = 0x27BD STAGE_DESCRIPTION_BASE = 0x2BA5 STAGE_DEFINITION_CAPACITY = 1000 STAGE_DESCRIPTION_STRIDE = 6 STAGE_DESCRIPTION_COLUMNS = ( "uncleared_line_1", "uncleared_line_2", "uncleared_line_3", "cleared_line_1", "cleared_line_2", "cleared_line_3", ) STAGE_DEFINITION_ARRAYS = { "unlock_group_id": (0xE7E8D, 1), "main_progression_flag": (0xE8275, 1), "forbidden_story_flag_ids": (0xE865D, 7), "required_story_flag_ids": (0xEA1B5, 7), "display_number_major": (0xEBD0D, 1), "display_number_minor": (0xEC0F5, 1), "map_min_tile_x": (MAP_STAGE_MIN_X, 1), "map_max_tile_x": (MAP_STAGE_MAX_X, 1), "map_min_tile_y": (MAP_STAGE_MIN_Y, 1), "map_max_tile_y": (MAP_STAGE_MAX_Y, 1), "minimap_atlas_origin_y": (0xED47D, 1), "clear_base_spendable_point_reward": (0xED865, 1), "authoring_difficulty_tier": (0xEDC4D, 1), "scjump_decision_ids": (0xEE035, 3), "extra_dungeon_flag": (0xEEBED, 1), "clear_coin_quantities": (0xEEFD5, 3), "stage_loader_script_id": (0xEFB8D, 1), } STAGE_SCJUMP_COLUMNS = ("entry", "clear", "failure") STAGE_CLEAR_COIN_ITEM_IDS = (91, 92, 93) 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 H_SCENE_GALLERY_SCRIPT_BASE = 0x6638B H_SCENE_GALLERY_PAGE_COUNT = 8 H_SCENE_GALLERY_SLOTS_PER_PAGE = 15 H_SCENE_GALLERY_THUMBNAIL_BASE = 0x66421 TRAINING_ACTION_STRING_BASE = 0x453B TRAINING_ACTION_STRING_STRIDE = 6 TRAINING_ACTION_COUNT = 21 TRAINING_ACTION_ARRAYS = { "required_story_flag_ids": (0x155BBC, 3), "forbidden_story_flag_ids": (0x155BFB, 3), "minimum_unit_level": (0x155C3A, 1), "maximum_unit_level": (0x155C4F, 1), "minimum_alignment_encoded": (0x155C64, 1), "maximum_alignment_encoded": (0x155C79, 1), "minimum_training_progress": (0x155C8E, 1), "maximum_training_progress": (0x155CA3, 1), "minimum_unit_stats": (0x155CB8, 10), "maximum_unit_stats": (0x155D8A, 10), "required_item_id": (0x155E5C, 1), "required_skill_id": (0x155E71, 1), "spirit_delta": (0x155E86, 1), "unit_stat_deltas": (0x155E9B, 14), "alignment_delta_hundredths": (0x155FC1, 1), "training_progress_delta_hundredths": (0x155FD6, 1), "awarded_skill_id": (0x155FEB, 1), "awarded_item_id": (0x156000, 1), "event_story_flag_ids": (0x156015, 10), } CARD_GENERATION_SELECTOR = 0x152485 CARD_GENERATION_WEIGHT_BASE = 0x152486 CARD_GENERATION_WEIGHT_STRIDE = 3 CARD_GENERATION_CARD_ID_BASE = 0x1525B2 CARD_GENERATION_SCAN_CAPACITY = 100 CARD_GENERATION_CLEAR_COUNT = 50 CARD_DEFINITION_NAME_BASE = 0x4315 CARD_DEFINITION_RESULT_BASE = 0x4379 CARD_REQUIRED_STORY_FLAG_BASE = 0x151A5D CARD_FORBIDDEN_STORY_FLAG_BASE = 0x151B89 CARD_STORY_FLAG_STRIDE = 3 CARD_DEFINITION_COUNT = 81 CARD_DEFINITION_CAPACITY = 100 CARD_DEFINITION_ARRAYS = { "type_id": (0x1519F9, 1), "required_story_flag_ids": (0x151A5D, 3), "forbidden_story_flag_ids": (0x151B89, 3), "awarded_item_id": (0x151CB5, 1), "event_story_flag_id": (0x151D19, 1), "stage_clear_point_bonus": (0x151D7D, 1), "minimum_resource_recovery": (0x151DE1, 3), "maximum_resource_recovery": (0x151F0D, 3), "minimum_spirit_recovery": (0x152039, 1), "maximum_spirit_recovery": (0x15209D, 1), "minimum_resource_damage": (0x152101, 3), "maximum_resource_damage": (0x15222D, 3), "condition_id": (0x152359, 1), "condition_level": (0x1523BD, 1), "visual_asset_id": (0x152421, 1), } CARD_TYPE_NAMES = { 1: "story_event", 2: "item_award", 3: "stage_clear_point_bonus", 4: "resource_recovery", 5: "trap", 6: "random_warp", } CARD_RESOURCE_COLUMNS = ("hp", "sp", "fs") BATTLE_ANIMATION_SELECTOR = 0x15288C BATTLE_ANIMATION_EFFECT_ID_BASE = 0x15288E BATTLE_ANIMATION_EFFECT_DELAY_BASE = 0x153FFE BATTLE_ANIMATION_DURATION_BASE = 0x15576E BATTLE_ANIMATION_CAPACITY = 1000 BATTLE_ANIMATION_SLOT_COUNT = 6 BATTLE_EFFECT_HIT_PULSE_COUNT = 3 BATTLE_EFFECT_WORK_ARRAYS = { "visual_asset_id": (0x155B56, 1), "visual_mode_id": (0x155B5C, 1), "additive_blend_flag": (0x155B62, 1), "width": (0x155B68, 1), "height": (0x155B6E, 1), "atlas_column_count": (0x155B74, 1), "atlas_row_count": (0x155B7A, 1), "atlas_frame_count": (0x155B80, 1), "duration_ms": (0x155B86, 1), "combatant_anchor_flag": (0x155B8C, 1), "offset_x": (0x155B92, 1), "offset_y": (0x155B98, 1), "sound_asset_id": (0x155B9E, 1), "sound_delay_ms": (0x155BA4, 1), "hit_pulse_offsets_ms": (0x155BAA, BATTLE_EFFECT_HIT_PULSE_COUNT), } BATTLE_EFFECT_VISUAL_MODES = { 0: "movie", 1: "green_colorkey_sprite_sheet", 2: "opaque_sprite_sheet", } BATTLE_EFFECT_SEMANTIC_NAMES = { "visual_asset_id": "battle_effect_visual_asset_ids", "visual_mode_id": "battle_effect_visual_mode_ids", "additive_blend_flag": "battle_effect_additive_blend_flags", "width": "battle_effect_widths", "height": "battle_effect_heights", "atlas_column_count": "battle_effect_atlas_column_counts", "atlas_row_count": "battle_effect_atlas_row_counts", "atlas_frame_count": "battle_effect_atlas_frame_counts", "duration_ms": "battle_effect_durations_ms", "combatant_anchor_flag": "battle_effect_combatant_anchor_flags", "offset_x": "battle_effect_offset_x_pixels", "offset_y": "battle_effect_offset_y_pixels", "sound_asset_id": "battle_effect_sound_asset_ids", "sound_delay_ms": "battle_effect_sound_delays_ms", "hit_pulse_offsets_ms": "battle_effect_hit_pulse_offsets_ms", } 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 extract_h_scene_gallery(scr): """Extract SPINIT's eight-page, fifteen-slot HMODE script registry.""" values: dict[tuple[int, int], int] = {} classified_offsets = set() static_write_count = 0 for ins in scr.instructions: 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 H-gallery value " f"at 0x{ins.offset:x}" ) index = destination - H_SCENE_GALLERY_SCRIPT_BASE capacity = ( H_SCENE_GALLERY_PAGE_COUNT * H_SCENE_GALLERY_SLOTS_PER_PAGE ) if not 0 <= index < capacity: raise ValueError( f"{scr.path.name}: H-gallery write 0x{destination:x} " f"outside the {capacity}-cell registry" ) page, slot = divmod(index, H_SCENE_GALLERY_SLOTS_PER_PAGE) _store_unique(values, (page, slot), value, page) classified_offsets.add(ins.offset) 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)}" ) init_scr = sys4load.load(resolve("INIT2")) thumbnail_assets = {} for ins in init_scr.instructions: write = _static_global_write(ins) if write is None: continue destination, value = write page = destination - H_SCENE_GALLERY_THUMBNAIL_BASE if 0 <= page < H_SCENE_GALLERY_PAGE_COUNT: _store_unique(thumbnail_assets, page, value, page) expected_pages = set(range(H_SCENE_GALLERY_PAGE_COUNT)) if set(thumbnail_assets) != expected_pages: raise ValueError( f"INIT2.BIN: H-gallery thumbnail pages are " f"{sorted(thumbnail_assets)}, expected " f"0..{H_SCENE_GALLERY_PAGE_COUNT - 1}" ) names = callscript_names() table_key = f"0x{H_SCENE_GALLERY_SCRIPT_BASE:x}" records = [] empty_cells = [] for page in range(H_SCENE_GALLERY_PAGE_COUNT): script_ids = [] scenes = [] raw_fields = {} for slot in range(H_SCENE_GALLERY_SLOTS_PER_PAGE): script_id = values.get((page, slot), 0) script_ids.append(script_id) if not script_id: empty_cells.append({"page": page, "slot": slot}) continue script_name = names.get(script_id, "") scenes.append({ "slot": slot, "script_resource_id": script_id, "script_name": script_name, }) raw_fields[ f"{table_key}/{H_SCENE_GALLERY_SLOTS_PER_PAGE}/{slot}" ] = script_id thumbnail_asset_id = thumbnail_assets[page] records.append({ "id": page, "name": f"page_{page}", "thumbnail_sheet_asset_id": thumbnail_asset_id, "thumbnail_sheet_asset_name": names.get( thumbnail_asset_id, "" ), "script_resource_ids": script_ids, "scenes": scenes, "record_fields": raw_fields, }) return records, { "schema": "h-scene-gallery-pages", "page_count": H_SCENE_GALLERY_PAGE_COUNT, "slots_per_page": H_SCENE_GALLERY_SLOTS_PER_PAGE, "registry_capacity": ( H_SCENE_GALLERY_PAGE_COUNT * H_SCENE_GALLERY_SLOTS_PER_PAGE ), "populated_scene_count": len(values), "empty_cells": empty_cells, "script_registry_base": table_key, "thumbnail_sheet_array_base": ( f"0x{H_SCENE_GALLERY_THUMBNAIL_BASE:x}" ), "thumbnail_sheet_source": "INIT2.BIN", "array_layouts": { table_key: {"stride": H_SCENE_GALLERY_SLOTS_PER_PAGE}, }, "schema_field_semantics": { table_key: "h_scene_gallery_script_ids", }, "static_write_count": static_write_count, "classified_static_write_count": len(values), "classified_instruction_count": len(classified_offsets), "resolved_scene_script_count": sum( bool(scene["script_name"]) for record in records for scene in record["scenes"] ), "resolved_thumbnail_sheet_count": sum( bool(record["thumbnail_sheet_asset_name"]) for record in records ), "consumer_contract": { "HMODE.BIN": ( "compact the eight configured INIT2 thumbnail pages, scan " "their fifteen SPINIT script slots, filter each populated " "resource through opcode 0x19d, and call-script the selected " "available scene" ), }, } def extract_training_actions(scr): """Extract TRINIT's 21 training/sexual-magic action definitions.""" string_cells: dict[tuple[int, int], str] = {} numeric_cells = { field_name: {} for field_name in TRAINING_ACTION_ARRAYS } 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] index = destination - TRAINING_ACTION_STRING_BASE capacity = ( TRAINING_ACTION_COUNT * TRAINING_ACTION_STRING_STRIDE ) if not 0 <= index < capacity: raise ValueError( f"{scr.path.name}: training string write " f"0x{destination:x} outside the {capacity}-cell table" ) action_id, column = divmod( index, TRAINING_ACTION_STRING_STRIDE ) value = scr.strings[ins.args[1][1]][0] _store_unique( string_cells, (action_id, column), value, action_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 training value " f"at 0x{ins.offset:x}" ) for field_name, (base, stride) in TRAINING_ACTION_ARRAYS.items(): index = destination - base if 0 <= index < TRAINING_ACTION_COUNT * stride: action_id, column = divmod(index, stride) _store_unique( numeric_cells[field_name], (action_id, column), value, action_id, ) classified_offsets.add(ins.offset) break else: raise ValueError( f"{scr.path.name}: unclassified training write " f"0x{destination:x} at 0x{ins.offset:x}" ) 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)}" ) item_records, _ = extract_name(sys4load.load(resolve("ITINIT"))) item_names = { record["id"]: record["name"] for record in item_records } skill_records, _ = extract_name(sys4load.load(resolve("SKINIT"))) skill_names = { record["id"]: record["name"] for record in skill_records } dispatch_records, _ = extract_dispatch( sys4load.load(resolve("SCINIT")) ) event_dispatch = { record["id"]: record for record in dispatch_records } def values(field_name: str, action_id: int) -> list[int]: _, stride = TRAINING_ACTION_ARRAYS[field_name] cells = numeric_cells[field_name] return [ cells.get((action_id, column), 0) for column in range(stride) ] def scalar(field_name: str, action_id: int) -> int: return values(field_name, action_id)[0] records = [] for action_id in range(TRAINING_ACTION_COUNT): description_lines = [ string_cells.get((action_id, column)) for column in range(3) ] locked_hint_lines = [ string_cells.get((action_id, column)) for column in range(3, 6) ] description_lines = [ line for line in description_lines if line is not None ] locked_hint_lines = [ line for line in locked_hint_lines if line is not None ] raw_fields = {} raw_record_fields = {} raw_string_fields = {} for column in range(TRAINING_ACTION_STRING_STRIDE): cell = (action_id, column) if cell in string_cells: raw_string_fields[ f"0x{TRAINING_ACTION_STRING_BASE:x}/" f"{TRAINING_ACTION_STRING_STRIDE}/{column}" ] = string_cells[cell] for field_name, (base, stride) in TRAINING_ACTION_ARRAYS.items(): for column in range(stride): cell = (action_id, column) if cell not in numeric_cells[field_name]: continue value = numeric_cells[field_name][cell] if stride == 1: raw_fields[f"0x{base:x}"] = value else: raw_record_fields[ f"0x{base:x}/{stride}/{column}" ] = value required_flags = [ value for value in values( "required_story_flag_ids", action_id ) if value ] forbidden_flags = [ value for value in values( "forbidden_story_flag_ids", action_id ) if value ] minimum_stats = { UNIT_STAT_COLUMNS[column]: value for column, value in enumerate( values("minimum_unit_stats", action_id) ) if value } maximum_stats = { UNIT_STAT_COLUMNS[column]: value for column, value in enumerate( values("maximum_unit_stats", action_id) ) if value } stat_deltas = { UNIT_STAT_COLUMNS[column]: value for column, value in enumerate( values("unit_stat_deltas", action_id) ) if value } event_ids = values("event_story_flag_ids", action_id) events = [] for slot, event_id in enumerate(event_ids): if not event_id: continue dispatch = event_dispatch.get(event_id, {}) events.append({ "slot": slot, "story_flag_id": event_id, "script_resource_id": dispatch.get( "script_resource_id", 0 ), "script_name": dispatch.get("script_name", ""), }) required_item_id = scalar("required_item_id", action_id) required_skill_id = scalar("required_skill_id", action_id) awarded_item_id = scalar("awarded_item_id", action_id) awarded_skill_id = scalar("awarded_skill_id", action_id) spirit_delta = scalar("spirit_delta", action_id) minimum_alignment_encoded = scalar( "minimum_alignment_encoded", action_id ) maximum_alignment_encoded = scalar( "maximum_alignment_encoded", action_id ) alignment_delta = scalar( "alignment_delta_hundredths", action_id ) training_delta = scalar( "training_progress_delta_hundredths", action_id ) eligibility = { "required_story_flag_ids": required_flags, "forbidden_story_flag_ids": forbidden_flags, "minimum_unit_stats": minimum_stats, "maximum_unit_stats": maximum_stats, } for field_name in ( "minimum_unit_level", "maximum_unit_level", "minimum_training_progress", "maximum_training_progress", ): value = scalar(field_name, action_id) if value: eligibility[field_name] = value if minimum_alignment_encoded: eligibility["minimum_alignment"] = ( minimum_alignment_encoded - 100 ) if maximum_alignment_encoded: eligibility["maximum_alignment"] = ( maximum_alignment_encoded - 100 ) if required_item_id: eligibility.update({ "required_item_id": required_item_id, "required_item_name": item_names.get( required_item_id, "" ), }) if required_skill_id: eligibility.update({ "required_skill_id": required_skill_id, "required_skill_name": skill_names.get( required_skill_id, "" ), }) effects = { "spirit_delta": spirit_delta, "spirit_cost": -spirit_delta, "unit_stat_deltas": stat_deltas, "alignment_delta_hundredths": alignment_delta, "training_progress_delta_hundredths": training_delta, } if awarded_skill_id: effects.update({ "awarded_skill_id": awarded_skill_id, "awarded_skill_name": skill_names.get( awarded_skill_id, "" ), }) if awarded_item_id: effects.update({ "awarded_item_id": awarded_item_id, "awarded_item_name": item_names.get( awarded_item_id, "" ), }) records.append({ "id": action_id, "name": f"training_action_{action_id:02d}", "description_lines": description_lines, "description": "".join(description_lines), "locked_hint_lines": locked_hint_lines, "locked_hint": "".join(locked_hint_lines), "eligibility": eligibility, "effects": effects, "event_story_flag_ids": event_ids, "execution_limit": len(events), "events": events, "fields": raw_fields, "record_fields": raw_record_fields, "string_fields": raw_string_fields, }) string_key = f"0x{TRAINING_ACTION_STRING_BASE:x}" array_layouts = { string_key: {"stride": TRAINING_ACTION_STRING_STRIDE}, **{ f"0x{base:x}": {"stride": stride} for base, stride in TRAINING_ACTION_ARRAYS.values() if stride > 1 }, } semantic_names = { string_key: "training_action_text", **{ f"0x{base:x}": f"training_action_{field_name}" for field_name, (base, _) in TRAINING_ACTION_ARRAYS.items() }, } schema_field_semantics = {} for column in range(TRAINING_ACTION_STRING_STRIDE): family = ( "description_line" if column < 3 else "locked_hint_line" ) ordinal = column + 1 if column < 3 else column - 2 schema_field_semantics[ f"{string_key}/{TRAINING_ACTION_STRING_STRIDE}/{column}" ] = f"training_action_{family}_{ordinal}" for field_name, (base, stride) in TRAINING_ACTION_ARRAYS.items(): key = f"0x{base:x}" if stride == 1: schema_field_semantics[key] = semantic_names[key] continue for column in range(stride): schema_field_semantics[ f"{key}/{stride}/{column}" ] = f"training_action_{field_name}.column_{column}" event_values = [ event["story_flag_id"] for record in records for event in record["events"] ] authored_cell_counts = { field_name: len(cells) for field_name, cells in numeric_cells.items() } return records, { "schema": "training-action-definitions", "reserved_record_count": TRAINING_ACTION_COUNT, "string_table_base": string_key, "string_stride": TRAINING_ACTION_STRING_STRIDE, "numeric_block_start": ( f"0x{TRAINING_ACTION_ARRAYS['required_story_flag_ids'][0]:x}" ), "numeric_block_end_exclusive": "0x1560e7", "array_layouts": array_layouts, "schema_field_semantics": schema_field_semantics, "semantic_array_names": semantic_names, "authored_numeric_cell_counts": authored_cell_counts, "string_write_count": string_write_count, "static_write_count": static_write_count, "classified_static_write_count": sum( len(cells) for cells in numeric_cells.values() ), "classified_instruction_count": len(classified_offsets), "required_item_join_count": sum( bool(record["eligibility"].get("required_item_name")) for record in records ), "awarded_item_join_count": sum( bool(record["effects"].get("awarded_item_name")) for record in records ), "awarded_skill_join_count": sum( bool(record["effects"].get("awarded_skill_name")) for record in records ), "event_cell_count": len(event_values), "distinct_event_story_flag_ids": sorted(set(event_values)), "resolved_event_dispatch_count": sum( bool(event["script_name"]) for record in records for event in record["events"] ), "runtime_contract": { "selected_action_id": "0x53edd", "availability_state_by_action": "0x53ede", "familiar_alignment": "0x6722", "familiar_alignment_fraction": "0x6723", "training_progress": "0x6724", "training_progress_fraction": "0x6725", "total_execution_count": "0x6726", "execution_count_by_action": "0x6727", "current_spirit": "0x20530", "maximum_spirit": "0x20534", }, "consumer_contract": { "TRAIN.BIN": ( "evaluates every eligibility family, renders the available " "or locked three-line text, deducts spirit, applies fourteen-" "stat/alignment/training effects, awards items or skills, " "increments per-action execution counts, and dispatches the " "event id selected by the prior execution count" ), "GAMESTART.BIN": ( "restores all event story flags in slots below each saved " "per-action execution count so prior training scenes remain " "completed after load" ), }, } def extract_card_definitions(scr): """Extract CDINIT2's complete 81-card definition and effect registry.""" string_cells = {} numeric_cells = { field_name: {} for field_name in CARD_DEFINITION_ARRAYS } 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] if ( CARD_DEFINITION_NAME_BASE < destination <= CARD_DEFINITION_NAME_BASE + CARD_DEFINITION_COUNT ): card_id = destination - CARD_DEFINITION_NAME_BASE field_name = "name" elif ( CARD_DEFINITION_RESULT_BASE < destination <= CARD_DEFINITION_RESULT_BASE + CARD_DEFINITION_COUNT ): card_id = destination - CARD_DEFINITION_RESULT_BASE field_name = "result_message" else: raise ValueError( f"{scr.path.name}: unclassified string destination " f"0x{destination:x} at 0x{ins.offset:x}" ) _store_unique( string_cells, (card_id, field_name), scr.strings[ins.args[1][1]][0], card_id, ) classified_offsets.add(ins.offset) string_write_count += 1 continue write = _static_global_write(ins) if write is not None: static_write_count += 1 destination, value = write if not isinstance(value, int): raise ValueError( f"{scr.path.name}: non-static card value " f"at 0x{ins.offset:x}" ) for field_name, (base, stride) in ( CARD_DEFINITION_ARRAYS.items() ): index = destination - base if not ( 0 <= index < CARD_DEFINITION_CAPACITY * stride ): continue card_id, column = divmod(index, stride) if not 1 <= card_id <= CARD_DEFINITION_COUNT: raise ValueError( f"{scr.path.name}: write to reserved card row " f"{card_id} at 0x{ins.offset:x}" ) _store_unique( numeric_cells[field_name], (card_id, column), value, card_id, ) classified_offsets.add(ins.offset) break else: raise ValueError( f"{scr.path.name}: unclassified numeric destination " f"0x{destination:x} at 0x{ins.offset:x}" ) continue if sys4load.display_label(ins.opcode) == "exit": classified_offsets.add(ins.offset) 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) ) item_records, _ = extract_name(sys4load.load(resolve("ITINIT"))) item_names = { record["id"]: record["name"] for record in item_records } dispatch_records, _ = extract_dispatch( sys4load.load(resolve("SCINIT")) ) event_dispatch = { record["id"]: record for record in dispatch_records } condition_records, _ = extract_condition_definitions( sys4load.load(resolve("ILINIT")) ) condition_names = { record["id"]: record["condition"] for record in condition_records } asset_names = callscript_names() def values(field_name: str, card_id: int) -> list[int]: _, stride = CARD_DEFINITION_ARRAYS[field_name] return [ numeric_cells[field_name].get((card_id, column), 0) for column in range(stride) ] def scalar(field_name: str, card_id: int) -> int: return values(field_name, card_id)[0] records = [] for card_id in range(1, CARD_DEFINITION_COUNT + 1): raw_fields = {} raw_record_fields = {} for field_name, (base, stride) in ( CARD_DEFINITION_ARRAYS.items() ): for column in range(stride): cell = (card_id, column) if cell not in numeric_cells[field_name]: continue value = numeric_cells[field_name][cell] if stride == 1: raw_fields[f"0x{base:x}"] = value else: raw_record_fields[ f"0x{base:x}/{stride}/{column}" ] = value required_flags = values( "required_story_flag_ids", card_id ) forbidden_flags = values( "forbidden_story_flag_ids", card_id ) eligibility = { "required_story_flag_ids": [ value for value in required_flags[:2] if value ], "forbidden_story_flag_ids": [ value for value in forbidden_flags[:2] if value ], } if required_flags[2]: eligibility["ignored_required_story_flag_ids"] = [ required_flags[2] ] effects = {} awarded_item_id = scalar("awarded_item_id", card_id) if awarded_item_id: effects.update({ "awarded_item_id": awarded_item_id, "awarded_item_name": item_names.get( awarded_item_id, "" ), }) event_id = scalar("event_story_flag_id", card_id) if event_id: dispatch = event_dispatch.get(event_id, {}) effects.update({ "event_story_flag_id": event_id, "event_script_resource_id": dispatch.get( "script_resource_id", 0 ), "event_script_name": dispatch.get("script_name", ""), }) point_bonus = scalar("stage_clear_point_bonus", card_id) if point_bonus: effects["stage_clear_spendable_point_bonus"] = point_bonus for prefix, minimum_field, maximum_field in ( ( "resource_recovery", "minimum_resource_recovery", "maximum_resource_recovery", ), ( "resource_damage", "minimum_resource_damage", "maximum_resource_damage", ), ): minimums = values(minimum_field, card_id) maximums = values(maximum_field, card_id) ranges = { resource: { "minimum": minimums[column], "maximum_exclusive": maximums[column], } for column, resource in enumerate( CARD_RESOURCE_COLUMNS ) if minimums[column] or maximums[column] } if ranges: effects[prefix] = ranges minimum_spirit = scalar( "minimum_spirit_recovery", card_id ) maximum_spirit = scalar( "maximum_spirit_recovery", card_id ) if minimum_spirit or maximum_spirit: effects["spirit_recovery"] = { "minimum": minimum_spirit, "maximum_exclusive": maximum_spirit, } condition_id = scalar("condition_id", card_id) if condition_id: effects.update({ "condition_id": condition_id, "condition": condition_names.get(condition_id, ""), "condition_level": scalar( "condition_level", card_id ), }) type_id = scalar("type_id", card_id) if type_id == 6: effects["random_warp"] = True visual_asset_id = scalar("visual_asset_id", card_id) records.append({ "id": card_id, "name": string_cells[(card_id, "name")], "result_message": string_cells[ (card_id, "result_message") ], "type_id": type_id, "type": CARD_TYPE_NAMES.get(type_id, ""), "eligibility": eligibility, "effects": effects, "visual_asset_id": visual_asset_id, "visual_asset_name": asset_names.get(visual_asset_id, ""), "fields": raw_fields, "record_fields": raw_record_fields, "string_fields": { f"0x{CARD_DEFINITION_NAME_BASE:x}": ( string_cells[(card_id, "name")] ), f"0x{CARD_DEFINITION_RESULT_BASE:x}": ( string_cells[(card_id, "result_message")] ), }, }) array_layouts = { f"0x{base:x}": {"stride": stride} for base, stride in CARD_DEFINITION_ARRAYS.values() if stride > 1 } schema_field_semantics = { f"0x{CARD_DEFINITION_NAME_BASE:x}": "card_definition_names", f"0x{CARD_DEFINITION_RESULT_BASE:x}": ( "card_definition_result_messages" ), } semantic_names = { "type_id": "card_definition_type_ids", "required_story_flag_ids": ( "card_definition_required_story_flag_ids" ), "forbidden_story_flag_ids": ( "card_definition_forbidden_story_flag_ids" ), "awarded_item_id": "card_definition_awarded_item_ids", "event_story_flag_id": ( "card_definition_event_story_flag_ids" ), "stage_clear_point_bonus": ( "card_definition_stage_clear_point_bonuses" ), "minimum_resource_recovery": ( "card_definition_minimum_resource_recovery" ), "maximum_resource_recovery": ( "card_definition_maximum_resource_recovery" ), "minimum_spirit_recovery": ( "card_definition_minimum_spirit_recovery" ), "maximum_spirit_recovery": ( "card_definition_maximum_spirit_recovery" ), "minimum_resource_damage": ( "card_definition_minimum_resource_damage" ), "maximum_resource_damage": ( "card_definition_maximum_resource_damage" ), "condition_id": "card_definition_condition_ids", "condition_level": "card_definition_condition_levels", "visual_asset_id": "card_definition_visual_asset_ids", } for field_name, (base, stride) in ( CARD_DEFINITION_ARRAYS.items() ): key = f"0x{base:x}" if stride == 1: schema_field_semantics[key] = semantic_names[field_name] continue columns = ( ("required_flag_1", "required_flag_2", "engine_dead_required_flag_3") if field_name == "required_story_flag_ids" else ("forbidden_flag_1", "forbidden_flag_2", "reserved_forbidden_flag_3") if field_name == "forbidden_story_flag_ids" else CARD_RESOURCE_COLUMNS ) for column, column_name in enumerate(columns): schema_field_semantics[ f"{key}/{stride}/{column}" ] = f"{semantic_names[field_name]}.{column_name}" return records, { "schema": "card-definitions", "reserved_record_count": CARD_DEFINITION_CAPACITY, "authored_record_count": CARD_DEFINITION_COUNT, "numeric_block_start": "0x1519f9", "numeric_block_end_exclusive": "0x152485", "array_layouts": array_layouts, "schema_field_semantics": schema_field_semantics, "semantic_array_names": { f"0x{base:x}": semantic_names[field_name] for field_name, (base, _) in ( CARD_DEFINITION_ARRAYS.items() ) }, "string_write_count": string_write_count, "static_write_count": static_write_count, "classified_instruction_count": len(classified_offsets), "type_counts": dict(sorted(collections.Counter( record["type"] for record in records ).items())), "awarded_item_join_count": sum( bool(record["effects"].get("awarded_item_name")) for record in records ), "event_dispatch_join_count": sum( bool(record["effects"].get("event_script_name")) for record in records ), "condition_join_count": sum( bool(record["effects"].get("condition")) for record in records ), "visual_asset_join_count": sum( bool(record["visual_asset_name"]) for record in records ), "ignored_required_story_flag_count": sum( bool(record["eligibility"].get( "ignored_required_story_flag_ids" )) for record in records ), "consumer_contract": { "FIELD.BIN": ( "filters required/forbidden story flags, applies randomized " "HP/SP/FS recovery or damage, spirit recovery, item awards, " "stage-clear spendable-point bonuses, conditions, event " "dispatch, or random warp by card type, draws the visual " "asset, and presents the name/result text" ), "STAGECLEAR.BIN": ( "adds FIELD's accumulated card point bonus to the ordinary " "stage-clear award before increasing shared_spendable_points" ), }, } def extract_battle_effect_definitions(scr): """Extract BTANINIT's effect-id dispatch into six-slot BTL work fields.""" instructions = scr.instructions labels = [ sys4load.display_label(ins.opcode) for ins in instructions ] classified_offsets = set() clear_layout = [ (base, ( BATTLE_ANIMATION_SLOT_COUNT * stride if stride > 1 else BATTLE_ANIMATION_SLOT_COUNT )) for base, stride in BATTLE_EFFECT_WORK_ARRAYS.values() ] if len(clear_layout) != 15: raise AssertionError("battle-effect work layout must have 15 arrays") for index, (base, count) in enumerate(clear_layout): ins = instructions[index] expected_args = [ (T_GLOBAL_INT, base), (T_IMM, count), ] if labels[index] != "copy-to-global" or ins.args != expected_args: raise ValueError( f"{scr.path.name}: unexpected work clear at " f"0x{ins.offset:x}: {labels[index]} {ins.args!r}" ) classified_offsets.add(ins.offset) control_labels = ( "mov", "jmp", "add", "lt", "jcc", "call", "jmp", "jmp", "lookup-array-2d", "mov", ) control_start = len(clear_layout) for relative, expected_label in enumerate(control_labels): ins = instructions[control_start + relative] if labels[control_start + relative] != expected_label: raise ValueError( f"{scr.path.name}: unexpected dispatch control at " f"0x{ins.offset:x}: expected {expected_label}, got " f"{labels[control_start + relative]}" ) classified_offsets.add(ins.offset) input_lookup = instructions[control_start + 8] expected_lookup_args = [ (T_LOCAL_PTR, 0), (T_GLOBAL_INT, BATTLE_ANIMATION_EFFECT_ID_BASE), (T_GLOBAL_INT, BATTLE_ANIMATION_SELECTOR), (T_IMM, BATTLE_ANIMATION_SLOT_COUNT), (T_LOCAL_INT, 1), ] if input_lookup.args != expected_lookup_args: raise ValueError( f"{scr.path.name}: unexpected effect-id input lookup " f"{input_lookup.args!r}" ) cases = {} cursor = control_start + len(control_labels) while cursor < len(instructions) and labels[cursor] == "eq": compare = instructions[cursor] if ( len(compare.args) != 3 or compare.args[0] != (T_LOCAL_INT, 2) or compare.args[1] != (T_LOCAL_INT, 0) or compare.args[2][0] != T_IMM ): raise ValueError( f"{scr.path.name}: malformed effect comparison at " f"0x{compare.offset:x}" ) effect_id = compare.args[2][1] if effect_id in cases: raise ValueError( f"{scr.path.name}: duplicate effect id {effect_id}" ) classified_offsets.add(compare.offset) cursor += 1 branch = instructions[cursor] if labels[cursor] != "jcc": raise ValueError( f"{scr.path.name}: missing branch after effect " f"{effect_id}" ) classified_offsets.add(branch.offset) cursor += 1 values = {} raw_fields = {} raw_record_fields = {} while labels[cursor] != "ret": lookup = instructions[cursor] if labels[cursor] not in { "lookup-array", "lookup-array-2d" }: raise ValueError( f"{scr.path.name}: unexpected effect {effect_id} " f"instruction at 0x{lookup.offset:x}: " f"{labels[cursor]}" ) cursor += 1 write = instructions[cursor] if ( labels[cursor] != "mov" or write.args[0] != (T_LOCAL_PTR, 0) or write.args[1][0] != T_IMM ): raise ValueError( f"{scr.path.name}: malformed effect {effect_id} " f"write at 0x{write.offset:x}" ) base = lookup.args[1][1] field_name = next(( name for name, (candidate_base, _) in ( BATTLE_EFFECT_WORK_ARRAYS.items() ) if candidate_base == base ), None) if field_name is None: raise ValueError( f"{scr.path.name}: effect {effect_id} writes " f"unknown work base 0x{base:x}" ) _, stride = BATTLE_EFFECT_WORK_ARRAYS[field_name] if stride == 1: if ( labels[cursor - 1] != "lookup-array" or lookup.args != [ (T_LOCAL_PTR, 0), (T_GLOBAL_INT, base), (T_LOCAL_INT, 1), ] ): raise ValueError( f"{scr.path.name}: malformed {field_name} lookup " f"for effect {effect_id}" ) key = field_name raw_fields[f"0x{base:x}"] = write.args[1][1] else: if ( labels[cursor - 1] != "lookup-array-2d" or lookup.args[:4] != [ (T_LOCAL_PTR, 0), (T_GLOBAL_INT, base), (T_LOCAL_INT, 1), (T_IMM, stride), ] or lookup.args[4][0] != T_IMM ): raise ValueError( f"{scr.path.name}: malformed {field_name} lookup " f"for effect {effect_id}" ) column = lookup.args[4][1] if not 0 <= column < stride: raise ValueError( f"{scr.path.name}: effect {effect_id} " f"{field_name} column {column} out of range" ) key = f"{field_name}.{column}" raw_record_fields[ f"0x{base:x}/{stride}/{column}" ] = write.args[1][1] _store_unique( values, key, write.args[1][1], effect_id ) classified_offsets.update((lookup.offset, write.offset)) cursor += 1 classified_offsets.add(instructions[cursor].offset) cursor += 1 cases[effect_id] = { "values": values, "fields": raw_fields, "record_fields": raw_record_fields, } expected_tail = ( "comment", "instruction-marker-noop", "ret", "exit", ) if tuple(labels[cursor:]) != expected_tail: raise ValueError( f"{scr.path.name}: unexpected dispatch tail " f"{labels[cursor:]!r}" ) classified_offsets.update( ins.offset for ins in instructions[cursor:] ) if len(classified_offsets) != len(instructions): raise ValueError( f"{scr.path.name}: classified {len(classified_offsets)}/" f"{len(instructions)} instructions" ) if cases.get(0, {}).get("values"): raise ValueError( f"{scr.path.name}: effect id zero must remain the empty sentinel" ) asset_names = callscript_names() animation_references = collections.defaultdict(set) animation_scr = sys4load.load(resolve("BTANINIT2")) for ins in animation_scr.instructions: write = _static_global_write(ins) if write is None: continue destination, value = write index = destination - BATTLE_ANIMATION_EFFECT_ID_BASE if not ( 0 <= index < BATTLE_ANIMATION_CAPACITY * BATTLE_ANIMATION_SLOT_COUNT ): continue animation_id, _ = divmod( index, BATTLE_ANIMATION_SLOT_COUNT ) animation_references[value].add(animation_id) records = [] for effect_id, case in sorted(cases.items()): if effect_id == 0: continue values = case["values"] visual_asset_id = values["visual_asset_id"] visual_mode_id = values["visual_mode_id"] atlas = {} for field_name, output_name in ( ("atlas_column_count", "columns"), ("atlas_row_count", "authored_rows"), ("atlas_frame_count", "frame_count"), ("duration_ms", "duration_ms"), ): if field_name in values: atlas[output_name] = values[field_name] sound_asset_id = values.get("sound_asset_id", 0) hit_pulses = [ values.get(f"hit_pulse_offsets_ms.{column}", 0) for column in range(BATTLE_EFFECT_HIT_PULSE_COUNT) ] record = { "id": effect_id, "visual_asset_id": visual_asset_id, "visual_asset_name": asset_names.get( visual_asset_id, "" ), "visual_mode_id": visual_mode_id, "visual_mode": BATTLE_EFFECT_VISUAL_MODES.get( visual_mode_id, "" ), "additive_blend": bool( values.get("additive_blend_flag", 0) ), "width": values["width"], "height": values["height"], "anchor": ( "slot_combatant" if values.get("combatant_anchor_flag", 0) else "battlefield_center" ), "offset_x": values["offset_x"], "offset_y": values["offset_y"], "atlas": atlas, "sound_asset_id": sound_asset_id, "sound_asset_name": ( asset_names.get(sound_asset_id, "") if sound_asset_id else "" ), "sound_delay_ms": values.get("sound_delay_ms", 0), "hit_pulse_offsets_ms": [ value for value in hit_pulses if value ], "referenced_animation_ids": sorted( animation_references.get(effect_id, ()) ), "fields": case["fields"], "record_fields": case["record_fields"], } records.append(record) unreferenced = [ record["id"] for record in records if not record["referenced_animation_ids"] ] schema_field_semantics = {} semantic_array_names = {} for field_name, (base, stride) in ( BATTLE_EFFECT_WORK_ARRAYS.items() ): semantic_name = BATTLE_EFFECT_SEMANTIC_NAMES[field_name] semantic_array_names[f"0x{base:x}"] = semantic_name if stride == 1: schema_field_semantics[f"0x{base:x}"] = semantic_name else: for column in range(stride): schema_field_semantics[ f"0x{base:x}/{stride}/{column}" ] = f"{semantic_name}.pulse_{column + 1}" return records, { "schema": "battle-effect-definitions", "effect_definition_count": len(records), "zero_effect_id_is_empty": True, "runtime_work_slot_count": BATTLE_ANIMATION_SLOT_COUNT, "hit_pulse_capacity_per_slot": BATTLE_EFFECT_HIT_PULSE_COUNT, "definition_write_count": sum( len(record["fields"]) + len(record["record_fields"]) for record in records ), "classified_instruction_count": len(classified_offsets), "visual_mode_counts": dict(sorted(collections.Counter( record["visual_mode"] for record in records ).items())), "resolved_visual_asset_count": sum( bool(record["visual_asset_name"]) for record in records ), "resolved_sound_asset_count": sum( bool(record["sound_asset_name"]) for record in records ), "sound_effect_count": sum( bool(record["sound_asset_id"]) for record in records ), "hit_pulse_effect_count": sum( bool(record["hit_pulse_offsets_ms"]) for record in records ), "sprite_sheet_effect_count": sum( bool(record["atlas"]) for record in records ), "engine_dead_atlas_row_count": sum( "authored_rows" in record["atlas"] for record in records ), "referenced_effect_definition_count": ( len(records) - len(unreferenced) ), "unreferenced_effect_definition_ids": unreferenced, "array_layouts": { f"0x{base:x}": {"stride": stride} for base, stride in BATTLE_EFFECT_WORK_ARRAYS.values() if stride > 1 }, "schema_field_semantics": schema_field_semantics, "semantic_array_names": semantic_array_names, "consumer_contract": { "BTANINIT2.BIN": ( "selects up to six effect ids and their start delays for " "each sparse battle-animation row" ), "BTL.BIN": ( "draws a movie or sprite-sheet surface, applies optional " "additive blending, anchors slots 0/1 to the actor and " "slots 2..5 to the target when requested, schedules WAV " "start, and consumes up to three hit-pulse offsets" ), }, } def extract_battle_animations(scr): """Extract BTANINIT2's sparse 1000-row battle-animation timeline.""" cells = { "effect_ids": {}, "effect_start_delays_ms": {}, "duration_ms": {}, } classified_offsets = set() static_write_count = 0 layouts = { "effect_ids": ( BATTLE_ANIMATION_EFFECT_ID_BASE, BATTLE_ANIMATION_SLOT_COUNT, ), "effect_start_delays_ms": ( BATTLE_ANIMATION_EFFECT_DELAY_BASE, BATTLE_ANIMATION_SLOT_COUNT, ), "duration_ms": (BATTLE_ANIMATION_DURATION_BASE, 1), } for ins in scr.instructions: if sys4load.display_label(ins.opcode) == "exit": classified_offsets.add(ins.offset) continue write = _static_global_write(ins) if write is None: continue static_write_count += 1 destination, value = write for field_name, (base, stride) in layouts.items(): index = destination - base if not ( 0 <= index < BATTLE_ANIMATION_CAPACITY * stride ): continue animation_id, column = divmod(index, stride) if animation_id == 0: raise ValueError( f"{scr.path.name}: writes reserved animation row zero " f"at 0x{ins.offset:x}" ) _store_unique( cells[field_name], (animation_id, column), value, animation_id, ) classified_offsets.add(ins.offset) break else: raise ValueError( f"{scr.path.name}: unclassified animation write " f"0x{destination:x} at 0x{ins.offset:x}" ) 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) ) effect_records, effect_meta = extract_battle_effect_definitions( sys4load.load(resolve("BTANINIT")) ) effects_by_id = { record["id"]: record for record in effect_records } skill_records, _ = extract_name( sys4load.load(resolve("SKINIT")) ) skill_uses = collections.defaultdict(list) for record in skill_records: animation_id = record.get("fields", {}).get("0xaaa1e", 0) if animation_id: skill_uses[animation_id].append({ "skill_id": record["id"], "skill_name": record["name"], }) item_records, _ = extract_name( sys4load.load(resolve("ITINIT")) ) weapon_class_items = collections.defaultdict(list) for record in item_records: weapon_class = record.get("fields", {}).get("0xa6689", 0) if weapon_class: weapon_class_items[weapon_class].append({ "item_id": record["id"], "item_name": record["name"], }) animation_ids = sorted({ animation_id for field_cells in cells.values() for animation_id, _ in field_cells }) records = [] resolved_effect_reference_count = 0 for animation_id in animation_ids: raw_record_fields = {} slots = [] for slot in range(BATTLE_ANIMATION_SLOT_COUNT): effect_key = (animation_id, slot) if effect_key not in cells["effect_ids"]: continue effect_id = cells["effect_ids"][effect_key] delay_key = (animation_id, slot) start_delay_ms = cells[ "effect_start_delays_ms" ].get(delay_key, 0) effect = effects_by_id.get(effect_id, {}) if effect: resolved_effect_reference_count += 1 raw_record_fields[ f"0x{BATTLE_ANIMATION_EFFECT_ID_BASE:x}/" f"{BATTLE_ANIMATION_SLOT_COUNT}/{slot}" ] = effect_id if delay_key in cells["effect_start_delays_ms"]: raw_record_fields[ f"0x{BATTLE_ANIMATION_EFFECT_DELAY_BASE:x}/" f"{BATTLE_ANIMATION_SLOT_COUNT}/{slot}" ] = start_delay_ms slots.append({ "slot": slot, "anchor_side": ( "actor" if slot < 2 else "target" ), "effect_id": effect_id, "start_delay_ms": start_delay_ms, "effect": { key: value for key, value in effect.items() if key not in { "fields", "record_fields", "referenced_animation_ids", } }, }) duration_key = (animation_id, 0) duration_ms = cells["duration_ms"].get(duration_key, 0) raw_fields = {} if duration_key in cells["duration_ms"]: raw_fields[ f"0x{BATTLE_ANIMATION_DURATION_BASE:x}" ] = duration_ms uses = [] if 1 <= animation_id <= 21: uses.append("normal_attack_weapon_class") if skill_uses.get(animation_id): uses.append("skill") if animation_id == 809: uses.append("defeat") if not uses: uses.append("unjoined_authored") records.append({ "id": animation_id, "duration_ms": duration_ms, "effect_slots": slots, "uses": uses, "skill_uses": skill_uses.get(animation_id, []), "weapon_class_items": weapon_class_items.get( animation_id, [] ) if 1 <= animation_id <= 21 else [], "fields": raw_fields, "record_fields": raw_record_fields, }) effect_reference_values = list(cells["effect_ids"].values()) delay_columns = collections.Counter( column for _, column in cells["effect_start_delays_ms"] ) effect_columns = collections.Counter( column for _, column in cells["effect_ids"] ) unjoined_animation_ids = [ record["id"] for record in records if record["uses"] == ["unjoined_authored"] ] return records, { "schema": "battle-animation-timelines", "reserved_record_count": BATTLE_ANIMATION_CAPACITY, "authored_record_count": len(records), "effect_slots_per_record": BATTLE_ANIMATION_SLOT_COUNT, "effect_id_array_base": ( f"0x{BATTLE_ANIMATION_EFFECT_ID_BASE:x}" ), "effect_start_delay_array_base": ( f"0x{BATTLE_ANIMATION_EFFECT_DELAY_BASE:x}" ), "duration_array_base": ( f"0x{BATTLE_ANIMATION_DURATION_BASE:x}" ), "static_write_count": static_write_count, "classified_instruction_count": len(classified_offsets), "effect_reference_count": len(effect_reference_values), "distinct_effect_id_count": len(set(effect_reference_values)), "resolved_effect_reference_count": ( resolved_effect_reference_count ), "effect_slot_populations": { str(column): effect_columns.get(column, 0) for column in range(BATTLE_ANIMATION_SLOT_COUNT) }, "delay_slot_populations": { str(column): delay_columns.get(column, 0) for column in range(BATTLE_ANIMATION_SLOT_COUNT) }, "duration_cell_count": len(cells["duration_ms"]), "complete_timeline_count": sum( bool(record["duration_ms"]) for record in records ), "auxiliary_timeline_count": sum( not record["duration_ms"] for record in records ), "skill_reference_count": sum( len(record["skill_uses"]) for record in records ), "skill_animation_count": sum( bool(record["skill_uses"]) for record in records ), "weapon_class_animation_count": 21, "unjoined_authored_animation_ids": unjoined_animation_ids, "effect_definition_count": effect_meta[ "effect_definition_count" ], "unreferenced_effect_definition_ids": effect_meta[ "unreferenced_effect_definition_ids" ], "array_layouts": { f"0x{BATTLE_ANIMATION_EFFECT_ID_BASE:x}": { "stride": BATTLE_ANIMATION_SLOT_COUNT }, f"0x{BATTLE_ANIMATION_EFFECT_DELAY_BASE:x}": { "stride": BATTLE_ANIMATION_SLOT_COUNT }, }, "schema_field_semantics": { **{ f"0x{BATTLE_ANIMATION_EFFECT_ID_BASE:x}/" f"{BATTLE_ANIMATION_SLOT_COUNT}/{column}": ( "battle_animation_effect_ids." f"effect_slot_{column}" ) for column in range(BATTLE_ANIMATION_SLOT_COUNT) }, **{ f"0x{BATTLE_ANIMATION_EFFECT_DELAY_BASE:x}/" f"{BATTLE_ANIMATION_SLOT_COUNT}/{column}": ( "battle_animation_effect_start_delays_ms." f"effect_slot_{column}" ) for column in range(BATTLE_ANIMATION_SLOT_COUNT) }, f"0x{BATTLE_ANIMATION_DURATION_BASE:x}": ( "battle_animation_duration_ms" ), }, "semantic_array_names": { f"0x{BATTLE_ANIMATION_EFFECT_ID_BASE:x}": ( "battle_animation_effect_ids" ), f"0x{BATTLE_ANIMATION_EFFECT_DELAY_BASE:x}": ( "battle_animation_effect_start_delays_ms" ), f"0x{BATTLE_ANIMATION_DURATION_BASE:x}": ( "battle_animation_duration_ms" ), }, "consumer_contract": { "CALCDMG.BIN": ( "selects a skill's animation id or the equipped item's " "weapon-class id for an ordinary attack" ), "BTL.BIN": ( "calls BTANINIT for the selected row, starts each populated " "effect at its six-slot delay, schedules audio and hit " "pulses, and uses the row duration to stage voice and HP " "interpolation; animation 809 is the hardcoded defeat row" ), "SKINIT.BIN": ( "provides 101 skill references across 98 distinct animation " "rows, including passive reaction rows 801 through 808" ), "ITINIT.BIN": ( "provides the weapon-class ids that select normal-attack " "animation rows 1 through 21" ), }, } def extract_card_generation_lists(scr): """Extract CDINIT's selector-dispatched weighted card candidate lists.""" instructions = scr.instructions classified_offsets = set() cursor = 0 expected_prelude = ( ( "mul", [ (T_LOCAL_INT, 0), (T_IMM, CARD_GENERATION_CLEAR_COUNT), (T_IMM, CARD_GENERATION_WEIGHT_STRIDE), ], ), ( "copy-to-global", [ (T_GLOBAL_INT, CARD_GENERATION_WEIGHT_BASE), (T_LOCAL_INT, 0), ], ), ( "copy-to-global", [ (T_GLOBAL_INT, CARD_GENERATION_CARD_ID_BASE), (T_IMM, CARD_GENERATION_CLEAR_COUNT), ], ), ) for expected_label, expected_args in expected_prelude: if cursor >= len(instructions): raise ValueError(f"{scr.path.name}: truncated CDINIT prelude") ins = instructions[cursor] label = sys4load.display_label(ins.opcode) if label != expected_label or ins.args != expected_args: raise ValueError( f"{scr.path.name}: unexpected prelude instruction " f"at 0x{ins.offset:x}: {label} {ins.args}" ) classified_offsets.add(ins.offset) cursor += 1 list_cells: dict[int, dict[int, dict[str, int]]] = {} branch_offsets: dict[int, int] = {} while cursor < len(instructions): ins = instructions[cursor] if sys4load.display_label(ins.opcode) != "eq": break if ( len(ins.args) != 3 or ins.args[0] != (T_LOCAL_INT, 0) or ins.args[1] != (T_GLOBAL_INT, CARD_GENERATION_SELECTOR) or ins.args[2][0] != T_IMM ): raise ValueError( f"{scr.path.name}: malformed selector test at 0x{ins.offset:x}" ) selector = ins.args[2][1] if selector in list_cells: raise ValueError( f"{scr.path.name}: duplicate selector {selector}" ) list_cells[selector] = {} branch_offsets[selector] = ins.offset classified_offsets.add(ins.offset) cursor += 1 if cursor >= len(instructions): raise ValueError( f"{scr.path.name}: selector {selector} lacks a branch" ) branch = instructions[cursor] if ( sys4load.display_label(branch.opcode) != "jcc" or branch.args[:2] != [(T_LOCAL_INT, 0), (T_IMM, 0xFFFFFFFF)] ): raise ValueError( f"{scr.path.name}: malformed selector branch " f"at 0x{branch.offset:x}" ) classified_offsets.add(branch.offset) cursor += 1 while cursor < len(instructions): write_ins = instructions[cursor] if sys4load.display_label(write_ins.opcode) != "mov": break write = _static_global_write(write_ins) if write is None or not isinstance(write[1], int): raise ValueError( f"{scr.path.name}: non-static list write " f"at 0x{write_ins.offset:x}" ) destination, value = write if ( CARD_GENERATION_CARD_ID_BASE < destination < CARD_GENERATION_CARD_ID_BASE + CARD_GENERATION_SCAN_CAPACITY ): slot = destination - CARD_GENERATION_CARD_ID_BASE field_name = "card_id" elif ( CARD_GENERATION_WEIGHT_BASE <= destination < CARD_GENERATION_WEIGHT_BASE + CARD_GENERATION_SCAN_CAPACITY * CARD_GENERATION_WEIGHT_STRIDE ): index = destination - CARD_GENERATION_WEIGHT_BASE slot, column = divmod( index, CARD_GENERATION_WEIGHT_STRIDE ) field_name = ( "base_weight", "growth_interval_turns", "growth_weight", )[column] else: raise ValueError( f"{scr.path.name}: unclassified list write " f"0x{destination:x} at 0x{write_ins.offset:x}" ) if slot == 0: raise ValueError( f"{scr.path.name}: selector {selector} writes reserved " f"slot zero at 0x{write_ins.offset:x}" ) entry = list_cells[selector].setdefault(slot, {}) if field_name in entry: raise ValueError( f"{scr.path.name}: selector {selector} slot {slot} " f"overwrites {field_name}" ) entry[field_name] = value classified_offsets.add(write_ins.offset) cursor += 1 if cursor >= len(instructions): raise ValueError( f"{scr.path.name}: selector {selector} lacks terminal jump" ) terminal = instructions[cursor] if sys4load.display_label(terminal.opcode) != "jmp": raise ValueError( f"{scr.path.name}: selector {selector} lacks terminal jump " f"at 0x{terminal.offset:x}" ) classified_offsets.add(terminal.offset) cursor += 1 if not list_cells: raise ValueError(f"{scr.path.name}: no card-generation selectors") fallback_comment = "" while cursor < len(instructions): ins = instructions[cursor] label = sys4load.display_label(ins.opcode) if label == "comment": fallback_comment = scr.strings[ins.args[0][1]][0] elif label not in ("instruction-marker-noop", "exit"): raise ValueError( f"{scr.path.name}: unclassified trailing instruction " f"{label} at 0x{ins.offset:x}" ) classified_offsets.add(ins.offset) cursor += 1 unclassified = [ f"0x{ins.offset:x}" for ins in instructions if ins.offset not in classified_offsets ] if unclassified: raise ValueError( f"{scr.path.name}: unclassified instructions " + ", ".join(unclassified) ) required_fields = { "card_id", "base_weight", "growth_interval_turns", "growth_weight", } for selector, cells in list_cells.items(): expected_slots = list(range(1, len(cells) + 1)) if sorted(cells) != expected_slots: raise ValueError( f"{scr.path.name}: selector {selector} has non-contiguous " f"slots {sorted(cells)}" ) for slot, entry in cells.items(): if set(entry) != required_fields: raise ValueError( f"{scr.path.name}: selector {selector} slot {slot} " f"has fields {sorted(entry)}, expected " f"{sorted(required_fields)}" ) card_scr = sys4load.load(resolve("CDINIT2")) card_records, _ = extract_name(card_scr) card_definitions = { record["id"]: { "name": record.get("name") or "", "description": record.get("desc") or "", "required_story_flag_ids": [], "forbidden_story_flag_ids": [], "ignored_required_story_flag_ids": [], } for record in card_records } flag_cells = { "required_story_flag_ids": {}, "forbidden_story_flag_ids": {}, } for ins in card_scr.instructions: write = _static_global_write(ins) if write is None or not isinstance(write[1], int): continue destination, value = write for field_name, base in ( ("required_story_flag_ids", CARD_REQUIRED_STORY_FLAG_BASE), ("forbidden_story_flag_ids", CARD_FORBIDDEN_STORY_FLAG_BASE), ): index = destination - base if not ( 0 <= index < CARD_GENERATION_SCAN_CAPACITY * CARD_STORY_FLAG_STRIDE ): continue card_id, column = divmod(index, CARD_STORY_FLAG_STRIDE) flag_cells[field_name][(card_id, column)] = value break for card_id, definition in card_definitions.items(): for field_name in flag_cells: definition[field_name] = [ flag_cells[field_name].get((card_id, column), 0) for column in range(2) if flag_cells[field_name].get((card_id, column), 0) ] ignored_required = flag_cells[ "required_story_flag_ids" ].get((card_id, 2), 0) if ignored_required: definition["ignored_required_story_flag_ids"] = [ ignored_required ] stages, _ = extract_mixed(sys4load.load(resolve("STINIT"))) attach_stage_object_placements(stages) references_by_selector: dict[int, dict[int, list[int]]] = ( collections.defaultdict(lambda: collections.defaultdict(list)) ) for stage in stages: for obj in stage.get("object_placements", []): selector = obj.get("card_generation_list_id") if selector is None: continue references_by_selector[selector][stage["id"]].append( obj["slot"] ) records = [] all_card_ids = set() resolved_card_reference_count = 0 for selector in sorted(list_cells): entries = [] for slot, raw_entry in sorted(list_cells[selector].items()): card_id = raw_entry["card_id"] all_card_ids.add(card_id) definition = card_definitions.get(card_id, {}) if definition.get("name"): resolved_card_reference_count += 1 entries.append({ "slot": slot, "card_id": card_id, "card_name": definition.get("name", ""), "card_description": definition.get("description", ""), "base_weight": raw_entry["base_weight"], "growth_interval_turns": raw_entry[ "growth_interval_turns" ], "growth_weight": raw_entry["growth_weight"], "required_story_flag_ids": definition.get( "required_story_flag_ids", [] ), "forbidden_story_flag_ids": definition.get( "forbidden_story_flag_ids", [] ), "ignored_required_story_flag_ids": definition.get( "ignored_required_story_flag_ids", [] ), "source_addresses": { "card_id": ( f"0x{CARD_GENERATION_CARD_ID_BASE + slot:x}" ), "base_weight": ( f"0x{CARD_GENERATION_WEIGHT_BASE + slot * 3:x}" ), "growth_interval_turns": ( f"0x{CARD_GENERATION_WEIGHT_BASE + slot * 3 + 1:x}" ), "growth_weight": ( f"0x{CARD_GENERATION_WEIGHT_BASE + slot * 3 + 2:x}" ), }, }) stage_references = [ { "stage_id": stage_id, "object_slots": sorted(object_slots), } for stage_id, object_slots in sorted(references_by_selector.get(selector, {}).items()) ] records.append({ "id": selector, "name": f"card_generation_list_{selector}", "branch_offset": f"0x{branch_offsets[selector]:x}", "entry_count": len(entries), "stage_object_references": stage_references, "entries": entries, "fields": {}, }) used_selectors = sorted( selector for selector in list_cells if selector in references_by_selector ) entry_count = sum(len(cells) for cells in list_cells.values()) return records, { "schema": "card-generation-lists", "selector_global": f"0x{CARD_GENERATION_SELECTOR:x}", "card_id_array_base": f"0x{CARD_GENERATION_CARD_ID_BASE:x}", "weight_schedule_table_base": ( f"0x{CARD_GENERATION_WEIGHT_BASE:x}" ), "weight_schedule_stride": CARD_GENERATION_WEIGHT_STRIDE, "runtime_scan_capacity": CARD_GENERATION_SCAN_CAPACITY, "cleared_entry_prefix": CARD_GENERATION_CLEAR_COUNT, "selector_ids": sorted(list_cells), "used_selector_ids": used_selectors, "unreferenced_selector_ids": sorted( set(list_cells) - set(used_selectors) ), "entry_count": entry_count, "distinct_card_ids": sorted(all_card_ids), "resolved_card_reference_count": resolved_card_reference_count, "ignored_required_story_flag_definition_count": sum( bool(definition["ignored_required_story_flag_ids"]) for definition in card_definitions.values() ), "ignored_required_story_flag_entry_count": sum( bool(entry["ignored_required_story_flag_ids"]) for record in records for entry in record["entries"] ), "stage_definition_reference_count": sum( len(stage_map) for stage_map in references_by_selector.values() ), "stage_object_reference_count": sum( len(object_slots) for stage_map in references_by_selector.values() for object_slots in stage_map.values() ), "fallback_comment": fallback_comment, "classified_instruction_count": len(classified_offsets), "semantic_array_names": { f"0x{CARD_GENERATION_SELECTOR:x}": ( "current_card_generation_list_id" ), f"0x{CARD_GENERATION_CARD_ID_BASE:x}": ( "card_generation_card_ids" ), f"0x{CARD_GENERATION_WEIGHT_BASE:x}": ( "card_generation_weight_schedules" ), }, "weight_formula": ( "base_weight + floor(current_stage_turn / " "growth_interval_turns) * growth_weight; when " "growth_interval_turns is zero, use base_weight" ), "consumer_contract": { "FIELD.BIN": ( "load the STINIT type-28 object's card-generation list, " "scan 100 candidate slots, discard empty card ids and " "CDINIT2 definitions whose required/forbidden story flags " "fail, compute the current-turn-adjusted weight, and select " "one surviving card by cumulative weighted random choice" ), "CDINIT2.BIN": ( "provides card names, result text, effects, graphics, and " "the required/forbidden story-flag rows used by FIELD; " "FIELD tests only columns zero and one, leaving the eighteen " "authored required-flag values in column two engine-dead" ), "STINIT.BIN": ( "type-28 stage objects supply the selector id consumed by " "CDINIT" ), }, } def extract_stage_definitions(scr): """Extract STINIT2's sparse stage catalog and six-line text matrix.""" records_by_id: dict[int, dict] = {} numeric_cells = { field_name: {} for field_name in STAGE_DEFINITION_ARRAYS } classified_offsets = set() string_write_count = 0 static_write_count = 0 def record_for(stage_id: int) -> dict: if not (1 <= stage_id < STAGE_DEFINITION_CAPACITY): raise ValueError( f"{scr.path.name}: stage id {stage_id} outside reserved " f"1..{STAGE_DEFINITION_CAPACITY - 1} range" ) return records_by_id.setdefault(stage_id, { "id": stage_id, "name": "", "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 ): destination = ins.args[0][1] text = scr.strings.get(ins.args[1][1], (None,))[0] if ( STAGE_DEFINITION_NAME_BASE < destination < STAGE_DEFINITION_NAME_BASE + STAGE_DEFINITION_CAPACITY ): stage_id = destination - STAGE_DEFINITION_NAME_BASE record = record_for(stage_id) if record["name"]: raise ValueError( f"{scr.path.name}: duplicate stage name for id " f"{stage_id}" ) record["name"] = text else: relative = destination - STAGE_DESCRIPTION_BASE if not ( STAGE_DESCRIPTION_STRIDE <= relative < STAGE_DEFINITION_CAPACITY * STAGE_DESCRIPTION_STRIDE ): raise ValueError( f"{scr.path.name}: unexpected string write " f"0x{destination:x}" ) stage_id, column = divmod( relative, STAGE_DESCRIPTION_STRIDE ) record = record_for(stage_id) _store_unique( record["string_fields"], ( f"0x{STAGE_DESCRIPTION_BASE:x}/" f"{STAGE_DESCRIPTION_STRIDE}/{column}" ), text, stage_id, ) string_write_count += 1 classified_offsets.add(ins.offset) continue write = _static_global_write(ins) if write is not None: destination, value = write matches = [] for field_name, (base, stride) in ( STAGE_DEFINITION_ARRAYS.items() ): relative = destination - base if ( stride <= relative < STAGE_DEFINITION_CAPACITY * stride ): stage_id, column = divmod(relative, stride) matches.append( (field_name, base, stride, stage_id, column) ) if len(matches) != 1: raise ValueError( f"{scr.path.name}: numeric destination " f"0x{destination:x} matched {matches}" ) field_name, base, stride, stage_id, column = matches[0] record = record_for(stage_id) numeric_cells[field_name][(stage_id, column)] = value key = ( f"0x{base:x}" if stride == 1 else f"0x{base:x}/{stride}/{column}" ) target = ( record["fields"] if stride == 1 else record["record_fields"] ) _store_unique(target, key, value, stage_id) static_write_count += 1 classified_offsets.add(ins.offset) continue if sys4load.display_label(ins.opcode) == "exit": classified_offsets.add(ins.offset) 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) ) unnamed = sorted( stage_id for stage_id, record in records_by_id.items() if not record["name"] ) if unnamed: raise ValueError( f"{scr.path.name}: numeric/text rows without names: {unnamed}" ) item_records, _ = extract_name( sys4load.load(resolve("ITINIT")) ) item_names = { record["id"]: record["name"] for record in item_records } dispatch_records, _ = extract_dispatch( sys4load.load(resolve("SCINIT")) ) dispatch_by_id = { record["id"]: record for record in dispatch_records } resource_names = callscript_names() def values(field_name: str, stage_id: int) -> list[int]: _, stride = STAGE_DEFINITION_ARRAYS[field_name] return [ numeric_cells[field_name].get((stage_id, column), 0) for column in range(stride) ] def scalar(field_name: str, stage_id: int) -> int: return values(field_name, stage_id)[0] records = [] scjump_reference_count = 0 resolved_scjump_reference_count = 0 resolved_loader_script_count = 0 description_line_count = 0 for stage_id in sorted(records_by_id): record = records_by_id[stage_id] descriptions = [ record["string_fields"].get( ( f"0x{STAGE_DESCRIPTION_BASE:x}/" f"{STAGE_DESCRIPTION_STRIDE}/{column}" ), "", ) for column in range(STAGE_DESCRIPTION_STRIDE) ] description_line_count += sum(bool(text) for text in descriptions) record["descriptions"] = { "uncleared": descriptions[:3], "cleared": descriptions[3:], } major = scalar("display_number_major", stage_id) minor = scalar("display_number_minor", stage_id) if major < 0: display_kind = "extra" elif not major and not minor: display_kind = "event" else: display_kind = "numbered" record["display_number"] = { "kind": display_kind, "major": major, "minor": minor, } required_flags = [ value for value in values( "required_story_flag_ids", stage_id ) if value ] forbidden_flags = [ value for value in values( "forbidden_story_flag_ids", stage_id ) if value ] record["availability"] = { "main_progression": bool( scalar("main_progression_flag", stage_id) ), "extra_dungeon": bool( scalar("extra_dungeon_flag", stage_id) ), "unlock_group_id": scalar( "unlock_group_id", stage_id ), "required_story_flag_ids": required_flags, "forbidden_story_flag_ids": forbidden_flags, } bounds = { "min_x": scalar("map_min_tile_x", stage_id), "max_x": scalar("map_max_tile_x", stage_id), "min_y": scalar("map_min_tile_y", stage_id), "max_y": scalar("map_max_tile_y", stage_id), } if all(bounds.values()): record["map"] = { "tile_bounds": bounds, "grid_bounds": { key: value * MAP_TILE_TO_GRID_SCALE for key, value in bounds.items() }, "minimap_atlas_origin_y": scalar( "minimap_atlas_origin_y", stage_id ), } scjump_ids = values("scjump_decision_ids", stage_id) flow = {} for column, role in enumerate(STAGE_SCJUMP_COLUMNS): decision_id = scjump_ids[column] if not decision_id: continue joined = dispatch_by_id.get(decision_id, {}) flow[f"{role}_scjump_decision_id"] = decision_id flow[f"{role}_script_name"] = joined.get( "script_name", "" ) scjump_reference_count += 1 if joined: resolved_scjump_reference_count += 1 loader_id = scalar("stage_loader_script_id", stage_id) if loader_id: loader_name = resource_names.get(loader_id, "") flow["stage_loader_script_id"] = loader_id flow["stage_loader_script_name"] = loader_name if loader_name: resolved_loader_script_count += 1 record["flow"] = flow coin_quantities = values( "clear_coin_quantities", stage_id ) record["clear_rewards"] = { "base_spendable_points": scalar( "clear_base_spendable_point_reward", stage_id ), "coins": [ { "item_id": item_id, "item_name": item_names.get(item_id, ""), "quantity": coin_quantities[column], } for column, item_id in enumerate( STAGE_CLEAR_COIN_ITEM_IDS ) if coin_quantities[column] ], } difficulty_tier = scalar( "authoring_difficulty_tier", stage_id ) if difficulty_tier: record["authoring_difficulty_tier"] = difficulty_tier records.append(record) field_counts = { field_name: len(cells) for field_name, cells in numeric_cells.items() } schema_semantics = { f"0x{STAGE_DESCRIPTION_BASE:x}/" f"{STAGE_DESCRIPTION_STRIDE}/{column}": ( f"stage_description_{column_name}" ) for column, column_name in enumerate( STAGE_DESCRIPTION_COLUMNS ) } semantic_names = { "unlock_group_id": "stage_unlock_group_ids", "main_progression_flag": "stage_main_progression_flags", "forbidden_story_flag_ids": ( "stage_forbidden_story_flag_ids" ), "required_story_flag_ids": "stage_required_story_flag_ids", "display_number_major": "stage_display_number_major", "display_number_minor": "stage_display_number_minor", "map_min_tile_x": "stage_map_min_tile_x", "map_max_tile_x": "stage_map_max_tile_x", "map_min_tile_y": "stage_map_min_tile_y", "map_max_tile_y": "stage_map_max_tile_y", "minimap_atlas_origin_y": "stage_minimap_atlas_origin_y", "clear_base_spendable_point_reward": ( "stage_clear_base_spendable_point_rewards" ), "authoring_difficulty_tier": ( "stage_authoring_difficulty_tiers" ), "scjump_decision_ids": "stage_scjump_decision_ids", "extra_dungeon_flag": "stage_extra_dungeon_flags", "clear_coin_quantities": "stage_clear_coin_quantities", "stage_loader_script_id": "stage_loader_script_ids", } semantic_columns = { "forbidden_story_flag_ids": tuple( f"forbidden_flag_{column + 1}" for column in range( STAGE_DEFINITION_ARRAYS[ "forbidden_story_flag_ids" ][1] ) ), "required_story_flag_ids": tuple( f"required_flag_{column + 1}" for column in range( STAGE_DEFINITION_ARRAYS[ "required_story_flag_ids" ][1] ) ), "scjump_decision_ids": STAGE_SCJUMP_COLUMNS, "clear_coin_quantities": tuple( f"{coin_name}_coin_item_{item_id}" for coin_name, item_id in zip( ("bronze", "silver", "gold"), STAGE_CLEAR_COIN_ITEM_IDS, ) ), } for field_name, semantic_name in semantic_names.items(): base, stride = STAGE_DEFINITION_ARRAYS[field_name] if stride == 1: schema_semantics[f"0x{base:x}"] = semantic_name else: column_names = semantic_columns[field_name] for column, column_name in enumerate(column_names): schema_semantics[ f"0x{base:x}/{stride}/{column}" ] = f"{semantic_name}.{column_name}" return records, { "schema": "stage-definitions", "reserved_record_count": STAGE_DEFINITION_CAPACITY, "name_array_base": f"0x{STAGE_DEFINITION_NAME_BASE:x}", "description_array_base": ( f"0x{STAGE_DESCRIPTION_BASE:x}" ), "description_columns": list(STAGE_DESCRIPTION_COLUMNS), "record_field_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("/") )), "string_field_columns": sorted({ key for record in records for key in record["string_fields"] }, key=lambda key: tuple( int(part, 0) for part in key.split("/") )), "schema_field_semantics": schema_semantics, "string_write_count": string_write_count, "static_write_count": static_write_count, "classified_instruction_count": len(classified_offsets), "authored_numeric_cell_counts": field_counts, "description_line_count": description_line_count, "mapped_stage_count": sum("map" in record for record in records), "event_only_stage_count": sum( record["display_number"]["kind"] == "event" for record in records ), "main_progression_stage_count": sum( record["availability"]["main_progression"] for record in records ), "extra_dungeon_stage_count": sum( record["availability"]["extra_dungeon"] for record in records ), "story_flag_gated_stage_count": sum( bool(record["availability"]["required_story_flag_ids"]) or bool( record["availability"]["forbidden_story_flag_ids"] ) for record in records ), "scjump_reference_count": scjump_reference_count, "resolved_scjump_reference_count": ( resolved_scjump_reference_count ), "resolved_loader_script_count": resolved_loader_script_count, "clear_coin_reward_cell_count": field_counts[ "clear_coin_quantities" ], "authoring_difficulty_tier_population": field_counts[ "authoring_difficulty_tier" ], "authoring_difficulty_tier_counts": { str(tier): sum( record.get("authoring_difficulty_tier") == tier for record in records ) for tier in range(1, 9) }, "consumer_contract": { "FORT.BIN": ( "enumerates named rows, applies required/forbidden story " "flags, auto-selects available main-progression stages, " "renders stage numbers and coin rewards, and dispatches the " "entry SCJUMP decision" ), "FIELD.BIN": ( "calls the selected row's STINIT loader, initializes map and " "minimap geometry, propagates unlock groups, awards base " "spendable points, and dispatches clear/failure decisions" ), "SELSTAGE.BIN": ( "renders the six description lines selected by clear state, " "draws the numbered/EVENT/EX labels and clear rewards, and " "uses the minimap atlas origin to crop the selected map" ), }, } 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_stage_definitions(stage_scr) definitions = [] for record in stage_records: if "map" not in record: continue definitions.append({ "id": record["id"], "name": record.get("name", ""), "tile_bounds": record["map"]["tile_bounds"], "grid_bounds": record["map"]["grid_bounds"], }) 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 = [ "", "# 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.", "", "SPINIT's dedicated H-scene gallery schema exposes eight fifteen-slot pages,", "joins every page to its INIT2 SO027 thumbnail sheet, resolves all 118 populated", "scene resources, and retains the two implicit empty cells in the final page.", "", "TRINIT's dedicated training-action schema exposes 21 six-line text rows and", "the contiguous eligibility/cost/effect/award/event block consumed by TRAIN.", "Item and skill ids join to ITINIT/SKINIT; all 75 event slots join through", "SCINIT, and GAMESTART's restored-story-flag contract remains explicit.", "", "CDINIT's dedicated card-generation schema exposes nine sparse selector lists,", "joins their 383 weighted candidate slots to CDINIT2 card names and story-flag", "gates, and links the seven used selectors back to STINIT type-28 stage objects.", "FIELD's turn-scaled weight formula and 100-slot selection scan remain explicit.", "", "CDINIT2's dedicated card-definition schema exposes all 81 cards in the reserved", "100-row registry. Names, result messages, effective and engine-dead story gates,", "six effect types, and raw array coordinates remain together; item, event, condition,", "and visual ids join through ITINIT, SCINIT, ILINIT, and SYS4INI resources.", "", "BTANINIT2's dedicated battle-animation schema exposes 122 sparse timelines in", "three reserved 1,000-row arrays: six effect ids, six start delays, and total", "duration. BTANINIT's paired schema decodes 202 effect ids into BTL's six-slot", "movie/sprite, blend, geometry, audio, and hit-pulse work record.", "", "STINIT2's dedicated stage-definition schema exposes 74 sparse rows in a", "reserved 1,000-stage catalog. It preserves six pre/post-clear description", "slots, progression and story gates, numbered/EVENT/EX presentation, map and", "minimap geometry, point/coin rewards, and all 174 SCINIT-resolved entry, clear,", "and failure decisions. All rows resolve their shared STINIT loader reference;", "the unconsumed 0xedc4d column is retained as a medium-confidence,", "authoring-only difficulty tier rather than being assigned runtime behavior.", "", "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
--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)) if mode_arg is not None: mode = mode_arg elif name == "TRINIT": # TRINIT's six-column sparse string matrix is not the generic # one-name-per-record layout expected by name-mode auto-detection. mode = "name" elif name == "CDINIT": # CDINIT's selector branches look like one fragmented numeric table # to the generic parallel-array detector. mode = "numeric" else: mode = 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 == "name" and name == "TRINIT": extractor = extract_training_actions elif mode == "name" and name == "CDINIT2": extractor = extract_card_definitions elif mode == "numeric" and name == "CDINIT": extractor = extract_card_generation_lists elif mode == "numeric" and name == "BTANINIT": extractor = extract_battle_effect_definitions elif mode == "numeric" and name == "BTANINIT2": extractor = extract_battle_animations elif mode == "name" and name == "STINIT2": extractor = extract_stage_definitions elif mode == "numeric" and name == "SPINIT": extractor = extract_h_scene_gallery 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())