7090 lines
269 KiB
Python
7090 lines
269 KiB
Python
#!/usr/bin/env python3
|
|
"""Extract a *INIT data table to JSON. Auto-detects the table's shape.
|
|
|
|
*INIT scripts populate global arrays and work buffers with static game data. Eight shapes seen:
|
|
|
|
name — records keyed by a name string. Each record: set-string(name), static field writes,
|
|
set-string(desc). Arrays indexed by record id in lockstep (+1/record).
|
|
(SKINIT skills, ITINIT items, EBINIT units, OBINIT object definitions)
|
|
numeric— column table with NO names: mov/copy-to-global into parallel int arrays, keyed
|
|
by an incrementing index column. (CGINIT gallery)
|
|
footer — copy-local-array (op 0x64) bulk-loads length-prefixed arrays from the file
|
|
footer into per-record global arrays. The data lives in the footer. (MPINIT maps)
|
|
mixed — a sparse selector dispatch writes strings, scalars, fixed-buffer cells, and
|
|
footer arrays for one runtime record. (STINIT stages)
|
|
rules — conditional blocks select a unit promotion and add effects to shared output
|
|
buffers. (CCINIT class changes)
|
|
dispatch—paired parallel arrays map a sparse decision id to a packed script resource id
|
|
and authored chapter metadata. (SCINIT scene dispatch)
|
|
banked —twenty parallel 1000-by-20 banks define sparse movement and battle routine
|
|
step records, including provider joins and source overwrites. (RTINIT routines)
|
|
|
|
ILINIT is a special name-mode matrix: 30 reserved condition ids by five authored
|
|
levels, joined to the runtime condition-state ABI and RECOVER policy.
|
|
|
|
CVINIT is a special numeric-mode registry: thirteen voice-configuration preview
|
|
slots, twelve slot-to-unit joins, and the matching unit-to-setting inverse map.
|
|
|
|
MPINIT is a special footer-mode terrain atlas: each footer copy owns the fifty
|
|
authored cells of one 53-cell half-tile grid row. STINIT2's per-stage tile
|
|
bounds select rectangles after multiplying both coordinates by two.
|
|
|
|
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<col_base>": value}} or, for footer tables,
|
|
{id, global_addr, footer_off, values:[...]}. Column addresses are raw engine globals;
|
|
confirmed names come from the generated engine global registry while raw keys remain provenance.
|
|
|
|
Usage: py -3.11 -X utf8 tools/extract_init.py <TABLE> [OUTNAME] [--mode name|numeric|footer|mixed|rules|dispatch|banked]
|
|
"""
|
|
from __future__ import annotations
|
|
import collections
|
|
import json
|
|
import sys
|
|
from functools import cache
|
|
from pathlib import Path
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
sys.path.insert(0, str(HERE))
|
|
import paths
|
|
import extract_message_table
|
|
import sys4load
|
|
|
|
SET_STRING = 0x192
|
|
MOV = 0x55
|
|
SUB = 0x51
|
|
COPY_TO_GLOBAL = 0x6C
|
|
COPY_LOCAL_ARRAY = 0x64
|
|
T_GLOBAL_INT = 3
|
|
T_GLOBAL_STRING = 5
|
|
T_IMM = 0
|
|
T_LOCAL_INT = 9
|
|
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 = [
|
|
"<!-- DO NOT EDIT -- generated by tools/extract_init.py -->",
|
|
"# Parsed INIT data tables",
|
|
"",
|
|
"The JSON files in this directory are generated from SYS4 `*INIT` scripts. Raw global-array",
|
|
"bases remain available in every record; confirmed field meanings live in",
|
|
"`vm-map/globals.toml` and the generated `docs/global-reference.md`.",
|
|
"",
|
|
"| file | mode | records | messages | scalar/array fields | strings | buffer cells | footer arrays | record columns |",
|
|
"|---|---|---:|---:|---:|---:|---:|---:|---:|",
|
|
]
|
|
for filename, data in tables:
|
|
columns = len(data.get("field_columns") or [])
|
|
record_columns = len(data.get("record_field_columns") or [])
|
|
message_count = data.get("message_table", {}).get(
|
|
"joined_count", data.get("message_count", 0)
|
|
)
|
|
lines.append(
|
|
f"| `{filename}` | {data['mode']} | {data['record_count']} | "
|
|
f"{message_count} | {columns} | {len(data.get('string_field_columns') or [])} | "
|
|
f"{len(data.get('array_field_columns') or [])} | "
|
|
f"{len(data.get('footer_array_columns') or [])} | {record_columns} |"
|
|
)
|
|
lines += [
|
|
"",
|
|
"Name-mode tables expose one-based runtime `id` values, the lookup `name_array_base`,",
|
|
"the first populated `name_write_base`, and the reserved `record_span`. Fields are keyed",
|
|
"by the runtime lookup base used by `lookup-array`, not merely the first written cell.",
|
|
"Linked row-major fields are stored separately in `record_fields`, keyed as",
|
|
"`base/stride/column` from corpus-observed `lookup-array-2d` consumers.",
|
|
"Where a matching `*MES` dispatcher exists, `message` preserves its player-facing",
|
|
"layout-specific text (title/description, summary/strategy, biography, or description-only), furigana,",
|
|
"and bytecode dispatch offset separately from the",
|
|
"short description stored by the INIT script.",
|
|
"Top-level `field_semantics` maps raw array/row-column keys to canonical machine-readable",
|
|
"names from `vm-map/globals.toml`; each record's generated `semantic_fields` is the joined",
|
|
"name-keyed convenience view. Complete footer copies expose their row values there while",
|
|
"raw keys and footer metadata remain intact as bytecode provenance.",
|
|
"",
|
|
"ILINIT's dedicated condition schema exposes thirteen authored condition ids in the",
|
|
"reserved 30-by-5 layout. Each record keeps its raw scalar and row-table cells while",
|
|
"joining level names, durations, eleven-stat deltas, three-resource deltas, boss/recovery",
|
|
"policies, and icon ids. Top-level `recovery_protocol` validates RECOVER.BIN and links",
|
|
"current levels, equipment/passive baselines, remaining turns, and full resource restore.",
|
|
"",
|
|
"CNINIT's dedicated unit-name schema exposes 277 sparse EBINIT-keyed rows in two",
|
|
"parallel 1,000-cell arrays: story/display names and canonical voice-family unit ids.",
|
|
"Every row joins both its own EBINIT definition and the representative voice-family",
|
|
"definition; deliberately empty names and variant aliases remain explicit.",
|
|
"",
|
|
"CGINIT's dedicated gallery schema exposes 851 sparse ids in a reserved 2,000-row",
|
|
"layout. Each row joins its full-size image asset, one of four 30-cell thumbnail",
|
|
"atlases, its atlas slot and variant ordinal, and the optional 112-by-84 preview",
|
|
"used by SAVE and SELSTAGE. Raw global-array provenance remains beside these joins.",
|
|
"",
|
|
"ALINIT's dedicated alchemy schema exposes 107 sparse recipes in a reserved 1,000-row",
|
|
"layout. Output and ingredient item ids join to ITINIT names; minimum alchemy level,",
|
|
"point cost, required/forbidden story flags, and four fixed ingredient slots retain",
|
|
"their raw parallel-array and row-table coordinates.",
|
|
"",
|
|
"AFINIT's dedicated affinity/progression schema exposes its attack and defense",
|
|
"element vocabularies, signed effectiveness matrix, eighteen usable item-tuning",
|
|
"bonus/cost curves plus a reserved zero row, and three facility progression rows.",
|
|
"",
|
|
"CTINIT's dedicated name-entry schema exposes INPUTNAME's five 70-cell palette",
|
|
"pages (hiragana, katakana, Latin, numerals, and symbols), preserving all reserved",
|
|
"empty slots beside the 273 authored characters.",
|
|
"",
|
|
"CVINIT's dedicated character-voice schema exposes CONFIG's thirteen preview",
|
|
"clips, twelve slot-to-unit joins, and the matching unit-to-suppression-setting",
|
|
"inverse map used by story, history, field, and battle voice filters.",
|
|
"",
|
|
"LAINIT's dedicated terrain-definition schema exposes all twenty shipped terrain",
|
|
"ids inside the reserved thirty-row table. It preserves the sparse names and",
|
|
"effect descriptions, four parallel topology/rendering arrays, the ten-column",
|
|
"combat-stat matrix, SKINIT traversal-skill joins, and shared texture fallbacks.",
|
|
"",
|
|
"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 <TABLE> --build` to generate value/population and",
|
|
"direct-consumer evidence.",
|
|
"",
|
|
]
|
|
(data_dir / "README.md").write_text("\n".join(lines), encoding="utf8")
|
|
|
|
|
|
def main() -> int:
|
|
argv = []
|
|
mode_arg = None
|
|
index = 1
|
|
while index < len(sys.argv):
|
|
arg = sys.argv[index]
|
|
if arg == "--mode":
|
|
if index + 1 >= len(sys.argv):
|
|
raise SystemExit("--mode requires a value")
|
|
mode_arg = sys.argv[index + 1]
|
|
index += 2
|
|
continue
|
|
if arg.startswith("--"):
|
|
raise SystemExit(f"unknown option: {arg}")
|
|
argv.append(arg)
|
|
index += 1
|
|
if not argv:
|
|
raise SystemExit(__doc__)
|
|
name = argv[0].upper().removesuffix(".BIN")
|
|
try:
|
|
outname = normalize_outname(argv[1]) if len(argv) > 1 else name
|
|
except ValueError as error:
|
|
raise SystemExit(str(error)) from error
|
|
scr = sys4load.load(resolve(name))
|
|
|
|
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())
|