Decode CCINIT class change rules

This commit is contained in:
gamer147
2026-07-23 12:40:42 -04:00
parent ec349ea461
commit f5b986e9f1
11 changed files with 541 additions and 42 deletions

View File

@@ -1,7 +1,7 @@
#!/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. Four shapes seen:
*INIT scripts populate global arrays and work buffers with static game data. Five 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).
@@ -12,12 +12,14 @@
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)
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]
Usage: py -3.11 -X utf8 tools/extract_init.py <TABLE> [OUTNAME] [--mode name|numeric|footer|mixed|rules]
"""
from __future__ import annotations
import json
@@ -41,6 +43,22 @@ T_GLOBAL_STRING = 5
T_IMM = 0
T_LOCAL_INT = 9
CURRENT_UNIT_ID = 0x66715
CURRENT_UNIT_LEVELS = 0x6930
UNIT_CLASS_CHANGE_STATE = 0x573BB
CLASS_CHANGE_TITLE_OUT = 0x26B4
CLASS_CHANGE_LEVEL_OUT = 0xAB8E7
CLASS_CHANGE_COST_OUT = 0xAB8E8
CLASS_CHANGE_STATS_OUT = 0xAB8E9
CLASS_CHANGE_SKILLS_OUT = 0xAB8F7
CLASS_CHANGE_FLAGS_OUT = 0xAB8FB
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 = {
"ITINIT": "ITMES",
"SKINIT": "SKMES",
@@ -130,10 +148,35 @@ def _mixed_guards(scr):
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 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:
@@ -143,6 +186,157 @@ def detect_mode(scr):
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:
@@ -820,6 +1014,12 @@ def write_data_index(data_dir: Path) -> None:
"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.",
"Use `tools/init_table_profile.py <TABLE> --build` to generate value/population and",
"direct-consumer evidence.",
"",
@@ -858,6 +1058,7 @@ def main() -> int:
"numeric": extract_numeric,
"footer": extract_footer,
"mixed": extract_mixed,
"rules": extract_class_change_rules,
}[mode]
recs, meta = extractor(scr)
if mode == "name" and name in MESSAGE_TABLES:

View File

@@ -37,8 +37,8 @@ def load_table(name: str) -> dict:
if not path.exists():
raise SystemExit(f"missing extracted table: {path}")
data = json.loads(path.read_text(encoding="utf8"))
if data.get("mode") not in {"name", "numeric", "mixed"}:
raise SystemExit(f"{name}: field profiling requires name/numeric/mixed mode")
if data.get("mode") not in {"name", "numeric", "mixed", "rules"}:
raise SystemExit(f"{name}: unsupported field-profiling mode {data.get('mode')!r}")
return data
@@ -83,7 +83,11 @@ def profile_columns(data: dict) -> list[dict]:
key = f"0x{base:x}"
add(key, {
"key": key,
"kind": "scalar-field" if data.get("mode") == "mixed" else "parallel-array",
"kind": (
"scalar-field" if data.get("mode") == "mixed"
else "rule-output" if data.get("mode") == "rules"
else "parallel-array"
),
"base": key,
"stride": None, "column": None,
"semantic_name": field_semantics.get(key),
@@ -156,6 +160,31 @@ def profile_columns(data: dict) -> list[dict]:
return rows
def profile_rules(data: dict) -> dict:
"""Summarize predicates and joined effects for conditional rule programs."""
if data.get("mode") != "rules":
return {}
records = data["records"]
return {
"unit_count": len({record["unit_id"] for record in records}),
"titled_rule_count": sum(bool(record.get("title")) for record in records),
"level_independent_rule_count": sum(
"minimum_level" not in record for record in records
),
"minimum_levels": dict(sorted(collections.Counter(
str(record["minimum_level"])
for record in records if "minimum_level" in record
).items(), key=lambda item: int(item[0]))),
"class_change_slot_indices": dict(sorted(collections.Counter(
str(record["class_change_slot_index"])
for record in records if "class_change_slot_index" in record
).items(), key=lambda item: int(item[0]))),
"skill_award_count": sum(
len(record.get("skill_awards", [])) for record in records
),
}
def profile_messages(data: dict) -> dict:
"""Summarize the joined player-facing message evidence."""
records = data["records"]
@@ -270,14 +299,26 @@ def render_markdown(data: dict, rows: list[dict], limit: int) -> str:
"",
f"- records: {data['record_count']}",
f"- populated fields: {len(rows)}",
f"- player-facing messages: {message_profile['population']}/{data['record_count']} "
f"({message_profile['coverage']:.0%})",
f"- messages with furigana spans: {message_profile['furigana_records']}",
]
if rule_profile := profile_rules(data):
lines.extend([
f"- covered units: {rule_profile['unit_count']}",
f"- titled rules: {rule_profile['titled_rule_count']}/{data['record_count']}",
f"- level-independent rules: {rule_profile['level_independent_rule_count']}",
f"- awarded skills: {rule_profile['skill_award_count']}",
])
else:
lines.extend([
f"- player-facing messages: {message_profile['population']}/{data['record_count']} "
f"({message_profile['coverage']:.0%})",
f"- messages with furigana spans: {message_profile['furigana_records']}",
])
lines.extend([
f"- rows shown: {len(shown)} (ranked by record coverage, then consumer references)",
"",
"| field | meaning | populated | distinct | range | direct refs | readers | common values | examples |",
"|---|---|---:|---:|---|---:|---|---|---|",
]
])
for row in shown:
value_range = "" if row["min"] is None else f"{row['min']}..{row['max']}"
readers = ", ".join(entry["script"].removesuffix(".BIN")
@@ -325,7 +366,9 @@ def main() -> int:
"string_field_count": sum(row["kind"] == "string-field" for row in rows),
"array_cell_count": sum(row["kind"] == "array-cell" for row in rows),
"footer_array_count": sum(row["kind"] == "footer-array" for row in rows),
"rule_output_count": sum(row["kind"] == "rule-output" for row in rows),
"message_profile": messages,
"rule_profile": profile_rules(data),
"columns": sorted(rows, key=lambda row: (
int(row["base"], 16), row["stride"] or 0, row["column"] or 0
)),

View File

@@ -263,6 +263,52 @@ def test_real_mixed_table() -> None:
) == 485, "STINIT exposes every first-clear-only enemy gate")
def test_real_class_change_rules() -> None:
script = sys4load.load(extract_init.resolve("CCINIT"))
check(extract_init.detect_mode(script) == "rules",
"CCINIT auto-detects as a conditional rule program")
records, meta = extract_init.extract_class_change_rules(script)
check(len(records) == 71, "CCINIT extracts all 71 class-change rules")
check(len({record["unit_id"] for record in records}) == 33,
"CCINIT rules cover 33 unit definitions")
check(meta["selector_global"] == "0x66715"
and meta["persistent_state_table"] == "0x573bb",
"CCINIT exposes its unit selector and persistent state table")
check(records[0]["unit_name"] == "リリィ:少女時代"
and records[0]["selected_level"] == -1
and records[0]["excluded_when_unit_equals_global"] == "0x32f0",
"CCINIT preserves Lily's level-independent form rule")
sylphine = records[2]
check(sylphine["unit_id"] == 5
and sylphine["minimum_level"] == 50
and sylphine["class_change_slot_index"] == 0
and sylphine["title"] == "聖王女",
"CCINIT decodes unit, level, slot, and awarded title")
check(sylphine["deployment_cost_delta"] == 2
and sylphine["stat_bonuses"]["physical_attack"] == 3,
"CCINIT decodes cost and named stat bonuses")
check(sylphine["skill_awards"] == [{
"skill_slot": 3, "skill_id": 202, "skill_name": "光燐衝撃",
}], "CCINIT joins awarded skill ids to SKINIT names")
semantics = extract_init.field_semantics(records, meta["array_layouts"])
check(semantics["0x26b4"] == "class_change_title_output"
and semantics["0xab8e9/2"] == "class_change_stat_bonuses.physical_attack"
and semantics["0xab8f7/2"] == "class_change_skill_awards.skill_slot_3",
"CCINIT raw outputs join to canonical global and column names")
extract_init.attach_semantic_fields(records, semantics)
check(sylphine["semantic_fields"]["class_change_title_output"] == "聖王女"
and sylphine["semantic_fields"][
"class_change_stat_bonuses.physical_attack"
] == 3,
"CCINIT rules expose a single semantic field view")
check(all(
record["minimum_level"] == record["selected_level"]
for record in records[2:]
), "CCINIT normal promotion thresholds match their selected levels")
check(sum(len(record.get("skill_awards", [])) for record in records) == 30,
"CCINIT accounts for all 30 awarded skills")
def test_real_message_tables() -> None:
scripts = paths.scripts()
expected = {
@@ -436,6 +482,7 @@ if __name__ == "__main__":
test_static_negative_write()
test_output_name_validation()
test_real_mixed_table()
test_real_class_change_rules()
test_real_message_tables()
test_message_join()
test_field_semantics()

View File

@@ -70,6 +70,31 @@ def main() -> int:
assert mixed["0x100/6"]["common"][0]["value"] == "[1, 2, 3]"
assert mixed["0x40"]["examples"][0]["name"] == "Win"
rule_fixture = {
"table": "RULES",
"mode": "rules",
"records": [
{
"id": 1, "unit_id": 3, "title": "", "fields": {"0x10": -1},
"class_change_slot_index": 0, "skill_awards": [{"skill_id": 2}],
},
{
"id": 2, "unit_id": 5, "title": "Promoted",
"minimum_level": 50, "class_change_slot_index": 1,
"fields": {"0x10": 50},
},
],
}
rule_rows = {row["key"]: row for row in profile.profile_columns(rule_fixture)}
assert rule_rows["0x10"]["kind"] == "rule-output"
rule_summary = profile.profile_rules(rule_fixture)
assert rule_summary["unit_count"] == 2
assert rule_summary["titled_rule_count"] == 1
assert rule_summary["level_independent_rule_count"] == 1
assert rule_summary["minimum_levels"] == {"50": 1}
assert rule_summary["class_change_slot_indices"] == {"0": 1, "1": 1}
assert rule_summary["skill_award_count"] == 1
messages = profile.profile_messages(fixture)
assert messages["population"] == 1
assert messages["coverage"] == 1 / 3