2365 lines
96 KiB
Python
2365 lines
96 KiB
Python
#!/usr/bin/env python3
|
||
"""Regression tests for INIT table extraction.
|
||
|
||
Run: py -3.11 -X utf8 tools/test_extract_init.py
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||
import extract_init
|
||
import extract_message_table
|
||
import paths
|
||
import sys4load
|
||
|
||
|
||
FAILS: list[str] = []
|
||
|
||
|
||
def check(condition: bool, message: str) -> None:
|
||
print((" ok " if condition else " FAIL ") + message)
|
||
if not condition:
|
||
FAILS.append(message)
|
||
|
||
|
||
def test_real_name_tables() -> None:
|
||
expected = {
|
||
"SKINIT.BIN": (300, 131),
|
||
"ITINIT.BIN": (1000, 287),
|
||
"EBINIT.BIN": (1000, 277),
|
||
"OBINIT.BIN": (100, 46),
|
||
}
|
||
scripts = paths.scripts()
|
||
for name, (span, count) in expected.items():
|
||
records, meta = extract_init.extract_name(sys4load.load(scripts[name]))
|
||
check(meta["record_span"] == span, f"{name}: record span is {span}")
|
||
check(len(records) == count, f"{name}: extracts {count} named records")
|
||
check(len({record["id"] for record in records}) == count,
|
||
f"{name}: record ids are unique")
|
||
|
||
items, _ = extract_init.extract_name(sys4load.load(scripts["ITINIT.BIN"]))
|
||
by_id = {record["id"]: record for record in items}
|
||
check(by_id[1]["name"] == "銅の鍵", "ITINIT item 1 is the copper key")
|
||
check(len(by_id[1]["fields"]) == 5, "ITINIT item 1 owns only its five fields")
|
||
check("desc" not in by_id[1], "ITINIT item 1 has no fabricated description")
|
||
check(by_id[101]["desc"] == "HP30回復", "ITINIT item 101 keeps its description")
|
||
check(by_id[1]["fields"]["0x8c879"] == 10,
|
||
"ITINIT columns use the runtime lookup base")
|
||
check(len({key for record in items for key in record["fields"]}) == 13,
|
||
"ITINIT has thirteen parallel-array fields")
|
||
check(len({key for record in items for key in record.get("record_fields", {})}) == 44,
|
||
"ITINIT linked row-major tables expose 44 populated columns")
|
||
check(by_id[101]["record_fields"]["0xa5301/3/0"] == 30,
|
||
"ITINIT item 101 stores HP recovery in row-major column zero")
|
||
check(by_id[108]["record_fields"]["0x906f9/30/8"] == -5,
|
||
"ITINIT item 108 preserves its paralysis-removal delta")
|
||
|
||
units, _ = extract_init.extract_name(sys4load.load(scripts["EBINIT.BIN"]))
|
||
unit_by_id = {record["id"]: record for record in units}
|
||
check(len({
|
||
key
|
||
for record in units
|
||
for key in {
|
||
**record.get("fields", {}),
|
||
**record.get("record_fields", {}),
|
||
}
|
||
}) == 108, "EBINIT exposes 108 genuine populated fields")
|
||
check(len({
|
||
key for record in units for key in record.get("record_fields", {})
|
||
}) == 82, "EBINIT exposes 82 genuine linked row-major columns")
|
||
check(
|
||
unit_by_id[456]["fields"]["0x6fb86"] == 12585
|
||
and "0x4e693/300/91" not in unit_by_id[456].get("record_fields", {}),
|
||
"EBINIT unit 456 keeps its battle sprite in the parallel asset column",
|
||
)
|
||
check(
|
||
unit_by_id[600]["fields"]["0x7a37e"] == 80
|
||
and "0x4e693/300/35" not in unit_by_id[600].get("record_fields", {}),
|
||
"EBINIT unit 600 keeps its starting level in the parallel level column",
|
||
)
|
||
|
||
objects, _ = extract_init.extract_name(sys4load.load(scripts["OBINIT.BIN"]))
|
||
object_by_id = {record["id"]: record for record in objects}
|
||
check(object_by_id[17]["name"] == "針"
|
||
and object_by_id[17]["desc"] == "HP-2",
|
||
"OBINIT object 17 preserves its name and effect description")
|
||
check(object_by_id[28]["name"] == "カード取得",
|
||
"OBINIT object ids provide authoritative STINIT type labels")
|
||
|
||
vocabulary, vocabulary_meta = extract_init.extract_vocabulary(
|
||
sys4load.load(scripts["VIINIT.BIN"])
|
||
)
|
||
vocabulary_by_id = {record["id"]: record for record in vocabulary}
|
||
check(
|
||
vocabulary_meta["record_span"] == 200
|
||
and len(vocabulary) == 65
|
||
and vocabulary[-1]["id"] == 120,
|
||
"VIINIT extracts 65 sparse glossary topics from its 200-row table",
|
||
)
|
||
check(
|
||
vocabulary_by_id[1]["name"] == "【迷宮】占有率"
|
||
and vocabulary_by_id[1]["record_fields"]["0x15a2a9/3/0"] == 201,
|
||
"VIINIT associates pre-name unlock writes with the correct topic",
|
||
)
|
||
|
||
|
||
def test_append_ebinit_fragment() -> None:
|
||
script = extract_init.load_packed_script(0x01000001)
|
||
_, base_layout = extract_init.extract_name(
|
||
sys4load.load(extract_init.resolve("EBINIT"))
|
||
)
|
||
base_layout["source"] = "EBINIT.BIN"
|
||
records, meta = extract_init.extract_name(script, base_layout)
|
||
by_id = {record["id"]: record for record in records}
|
||
semantics = extract_init.field_semantics(records)
|
||
extract_init.attach_semantic_fields(records, semantics)
|
||
|
||
check(
|
||
script.path.name == "$1$EBINIT.BIN" and len(script.instructions) == 358,
|
||
"packed resource 0x01000001 loads the append EBINIT payload",
|
||
)
|
||
check(
|
||
set(by_id) == {81, 900, 901, 902, 903, 904, 905},
|
||
"append EBINIT exposes its seven sparse additive unit rows",
|
||
)
|
||
check(
|
||
meta["fragment_layout_source"] == "EBINIT.BIN"
|
||
and meta["name_array_base"] == "0x84a"
|
||
and meta["record_span"] == 1000,
|
||
"append EBINIT reuses the canonical base-table layout",
|
||
)
|
||
check(
|
||
by_id[81]["semantic_fields"]["unit_starting_level"] == 40
|
||
and by_id[81]["semantic_fields"]["unit_base_stats.physical_attack"] == 23
|
||
and by_id[81]["semantic_fields"]["unit_stat_growth_rates.speed"] == 63,
|
||
"append unit 81 exposes named level, base-stat, and growth fields",
|
||
)
|
||
check(
|
||
by_id[81]["semantic_fields"]["unit_battle_sprite_asset_id"] == 0x0100002D,
|
||
"append unit 81 retains its selector-keyed battle sprite resource id",
|
||
)
|
||
|
||
|
||
def test_character_profiles() -> None:
|
||
scripts = paths.scripts()
|
||
records, meta = extract_init.extract_character_profiles(
|
||
sys4load.load(scripts["CIINIT.BIN"])
|
||
)
|
||
by_id = {record["id"]: record for record in records}
|
||
check(len(records) == 24, "CIINIT extracts all 24 character profiles")
|
||
check(
|
||
meta["record_span"] == 100
|
||
and meta["unit_id_array_base"] == "0x15a118"
|
||
and meta["portrait_asset_array_base"] == "0x15a17c",
|
||
"CIINIT exposes the four 100-cell profile columns",
|
||
)
|
||
check(
|
||
meta["implicit_defaults"]
|
||
== {"0x15a1e0": 0, "0x15a244": 0},
|
||
"CIINIT records its two unwritten portrait-placement defaults",
|
||
)
|
||
check(
|
||
by_id[1]["name"] == "エミリオ"
|
||
and by_id[1]["fields"]["0x15a118"] == 1
|
||
and by_id[1]["fields"]["0x15a17c"] == 0x2C88,
|
||
"CIINIT profile 1 joins Emilio to unit and portrait resources",
|
||
)
|
||
check(
|
||
by_id[16]["fields"]["0x15a118"] == 0x5F
|
||
and "0x15a17c" not in by_id[16]["fields"],
|
||
"CIINIT preserves the portrait-fallback profiles",
|
||
)
|
||
|
||
|
||
def test_magic_actions() -> None:
|
||
scripts = paths.scripts()
|
||
records, meta = extract_init.extract_magic_actions(
|
||
sys4load.load(scripts["MAINIT.BIN"])
|
||
)
|
||
by_id = {record["id"]: record for record in records}
|
||
check(len(records) == 11, "MAINIT extracts all 11 magic/research actions")
|
||
check(
|
||
meta["record_span"] == 30
|
||
and meta["name_array_base"] == "0x45b9"
|
||
and meta["handler_script_array_base"] == "0x1561f6",
|
||
"MAINIT exposes its reserved span and handler column",
|
||
)
|
||
check(
|
||
by_id[1]["name"] == "闇の治癒"
|
||
and by_id[1]["fields"]["0x156142"] == 10,
|
||
"MAINIT action 1 keeps its name and authored cost-like field",
|
||
)
|
||
check(
|
||
all(
|
||
record["fields"]["0x1561f6"] == 0x31A6
|
||
for record in records
|
||
),
|
||
"MAINIT routes every action to the MAMES information handler",
|
||
)
|
||
check(
|
||
"0x1561d8" not in by_id[9]["fields"]
|
||
and by_id[10]["fields"]["0x1561d8"] == 15
|
||
and by_id[11]["fields"]["0x1561d8"] == 40,
|
||
"MAINIT preserves the sparse growth-ritual threshold column",
|
||
)
|
||
|
||
|
||
def test_static_negative_write() -> None:
|
||
class Instruction:
|
||
opcode = extract_init.SUB
|
||
args = [(extract_init.T_GLOBAL_INT, 0x123),
|
||
(extract_init.T_IMM, 0), (extract_init.T_IMM, 7)]
|
||
|
||
check(extract_init._static_global_write(Instruction()) == (0x123, -7),
|
||
"INIT subtraction writes preserve negative values")
|
||
|
||
|
||
def test_output_name_validation() -> None:
|
||
check(extract_init.normalize_outname("ITINIT.json") == "ITINIT",
|
||
"INIT output names tolerate one JSON suffix")
|
||
try:
|
||
extract_init.normalize_outname("build/data/ITINIT.json")
|
||
except ValueError:
|
||
rejected = True
|
||
else:
|
||
rejected = False
|
||
check(rejected, "INIT output names reject nested paths")
|
||
|
||
|
||
def test_real_mixed_table() -> None:
|
||
script = sys4load.load(extract_init.resolve("STINIT"))
|
||
check(extract_init.detect_mode(script) == "mixed",
|
||
"STINIT auto-detects as a mixed selector table")
|
||
records, meta = extract_init.extract_mixed(script)
|
||
check(len(records) == 74, "STINIT extracts all 74 sparse stage records")
|
||
check(records[0]["id"] == 1 and records[-1]["id"] == 170,
|
||
"STINIT preserves sparse runtime stage ids")
|
||
check(meta["selector_global"] == "0x4dfbc",
|
||
"STINIT records are keyed by scjump_progress_a")
|
||
check(meta["array_layouts"]["0xe74b5"] == {
|
||
"length": 350, "stride": 7, "rows": 50,
|
||
}, "STINIT preamble recovers a consumer-confirmed row buffer")
|
||
stage1 = records[0]
|
||
check(list(stage1["string_fields"].values()) == [
|
||
"オークの撃破", "", "自軍拠点の制圧", "50ターン経過",
|
||
], "STINIT stage 1 preserves all four condition strings")
|
||
check(stage1["fields"]["0xe7302"] == 12
|
||
and stage1["fields"]["0xe730c"] == 50
|
||
and stage1["fields"]["0xe730d"] == 0,
|
||
"STINIT stage 1 preserves BGM and turn-limit scalars")
|
||
check(stage1["array_fields"]["0xe7305/3"] == -2,
|
||
"STINIT fixed-buffer cells preserve negative values")
|
||
check(stage1["footer_arrays"]["0xe7889/3"]["values"] == [1, 1, 1],
|
||
"STINIT length-prefixed footer arrays retain their destination")
|
||
check(sum(len(record.get("footer_arrays", {})) for record in records) == 1396,
|
||
"STINIT accounts for every footer-array copy")
|
||
extract_init.attach_stage_object_placements(records)
|
||
first_object = records[0]["object_placements"][0]
|
||
check(first_object == {
|
||
"slot": 1,
|
||
"type_id": 1,
|
||
"type_name": "拠点",
|
||
"type_description": "▲命中・回避・防御",
|
||
"tile_x": 13,
|
||
"tile_y": 1,
|
||
"difficulty_mask": 7,
|
||
"initial_faction_id": 1,
|
||
}, "STINIT joins confirmed object buffers into one placement record")
|
||
stage1_slot6 = next(
|
||
obj for obj in records[0]["object_placements"] if obj["slot"] == 6
|
||
)
|
||
check(stage1_slot6["item_id"] == 222
|
||
and stage1_slot6["item_quantity"] == 1,
|
||
"STINIT object payloads decode by object type")
|
||
stage1_slot5 = next(
|
||
obj for obj in records[0]["object_placements"] if obj["slot"] == 5
|
||
)
|
||
check(stage1_slot5["reinforcement_interval_turns"] == 10
|
||
and stage1_slot5["reinforcement_spawn_limit"] == 3,
|
||
"STINIT object placements expose reinforcement schedules")
|
||
stage1_slot8 = next(
|
||
obj for obj in records[0]["object_placements"] if obj["slot"] == 8
|
||
)
|
||
check(stage1_slot8["card_generation_list_id"] == 1,
|
||
"STINIT card objects expose their generation-list id")
|
||
check(stage1_slot8["type_name"] == "カード取得",
|
||
"STINIT object placements join OBINIT type names")
|
||
stage1_slot9 = next(
|
||
obj for obj in records[0]["object_placements"] if obj["slot"] == 9
|
||
)
|
||
check(stage1_slot9["destination_tile_x"] == 13
|
||
and stage1_slot9["destination_tile_y"] == 7,
|
||
"STINIT teleport payloads expose destination coordinates")
|
||
stage2_slot3 = next(
|
||
obj for obj in records[1]["object_placements"] if obj["slot"] == 3
|
||
)
|
||
check(stage2_slot3["required_story_flags"] == [902],
|
||
"STINIT object placements join positive story prerequisites")
|
||
all_objects = [
|
||
obj for record in records for obj in record["object_placements"]
|
||
]
|
||
check(sum("non_triggering_faction_id" in obj for obj in all_objects) == 104,
|
||
"STINIT hazard/barrier payloads expose their FIELD-proven faction gate")
|
||
check({
|
||
obj["non_triggering_faction_id"]
|
||
for obj in all_objects
|
||
if "non_triggering_faction_id" in obj
|
||
} == {1, 2, 3}, "STINIT faction gates retain all observed faction ids")
|
||
state_objects = [
|
||
obj for obj in all_objects if "initial_object_state_id" in obj
|
||
]
|
||
check(len(state_objects) == 78,
|
||
"STINIT state-row metadata decodes every remaining initialized object state")
|
||
check({
|
||
obj["type_id"] for obj in state_objects
|
||
} == {11, 17, 26}, "STINIT state-row payloads remain scoped to proven object types")
|
||
deployment_flags = [
|
||
obj for obj in state_objects if obj["type_id"] == 26
|
||
]
|
||
check(len(deployment_flags) == 61
|
||
and {obj["initial_object_state_id"] for obj in deployment_flags} == {2},
|
||
"STINIT deployment flags expose their initial object state")
|
||
spike = next(
|
||
obj for obj in state_objects if obj["type_id"] == 17
|
||
)
|
||
check(spike["initial_object_state_id"] == 2
|
||
and "non_triggering_faction_id" not in spike,
|
||
"STINIT spikes expose state without inventing the excluded faction gate")
|
||
ignored_objects = [
|
||
obj for obj in all_objects if "ignored_payload_fields" in obj
|
||
]
|
||
check(len(ignored_objects) == 3
|
||
and {obj["type_id"] for obj in ignored_objects} == {27}
|
||
and {tuple(obj["ignored_payload_fields"].items()) for obj in ignored_objects}
|
||
== {(("0xe73bb", 2),)},
|
||
"STINIT preserves explicitly written but engine-ignored type-27 payloads")
|
||
unknown_objects = [obj for obj in all_objects if "unknown_fields" in obj]
|
||
check(not unknown_objects,
|
||
"STINIT has no unresolved populated tagged object payloads")
|
||
extract_init.attach_stage_enemy_spawns(records)
|
||
first_spawn = records[0]["enemy_spawns"][0]
|
||
check(first_spawn == {
|
||
"slot": 1,
|
||
"unit_id": 205,
|
||
"faction_id": 2,
|
||
"difficulty_mask": 7,
|
||
"min_level": 1,
|
||
"max_level": 10,
|
||
"auto_level_scale_divisor": 1,
|
||
"object_slot": 2,
|
||
"movement_routine_set_ids": [1, 1, 1],
|
||
"forbidden_story_flags": [11],
|
||
"first_clear_only": True,
|
||
}, "STINIT joins confirmed enemy buffers into one spawn record")
|
||
stage1_slot2 = records[0]["enemy_spawns"][1]
|
||
check(stage1_slot2["random_selection_weight"] == 1
|
||
and stage1_slot2["object_slot"] == 5,
|
||
"STINIT preserves weighted object-linked enemy alternatives")
|
||
check(sum(len(record["enemy_spawns"]) for record in records) == 1378,
|
||
"STINIT assembles every populated enemy spawn slot")
|
||
linked_deployment_spawns = []
|
||
for record in records:
|
||
objects_by_slot = {
|
||
obj["slot"]: obj for obj in record["object_placements"]
|
||
}
|
||
linked_deployment_spawns.extend(
|
||
spawn
|
||
for spawn in record["enemy_spawns"]
|
||
if (object_slot := spawn.get("object_slot")) in objects_by_slot
|
||
and objects_by_slot[object_slot]["type_id"] == 26
|
||
)
|
||
check(len(linked_deployment_spawns) == 63
|
||
and {spawn["faction_id"] for spawn in linked_deployment_spawns} == {2},
|
||
"STINIT deployment flags correlate with all linked enemy-faction spawns")
|
||
check(sum(
|
||
spawn.get("first_clear_only", False)
|
||
for record in records
|
||
for spawn in record["enemy_spawns"]
|
||
) == 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_scene_dispatch() -> None:
|
||
script = sys4load.load(extract_init.resolve("SCINIT"))
|
||
check(extract_init.detect_mode(script) == "dispatch",
|
||
"SCINIT auto-detects as paired scene dispatch arrays")
|
||
records, meta = extract_init.extract_dispatch(script)
|
||
by_id = {record["id"]: record for record in records}
|
||
check(len(records) == 1209 and meta["assignment_count"] == 2179,
|
||
"SCINIT preserves all assignments and 1,209 final decision rows")
|
||
check(meta["script_resource_array_base"] == "0x87a57"
|
||
and meta["authored_chapter_array_base"] == "0x8a167"
|
||
and meta["reserved_array_span"] == 10000,
|
||
"SCINIT exposes its paired 10,000-cell array layout")
|
||
check(by_id[0]["script_resource_id"] == 34
|
||
and by_id[0]["script_name"] == "SC0000.BIN"
|
||
and by_id[0]["authored_chapter"] == 1,
|
||
"SCINIT joins packed resource ids to scene names and chapter metadata")
|
||
check(by_id[1]["assignment_count"] == 3
|
||
and [entry["authored_chapter"] for entry in by_id[1]["assignments"]]
|
||
== [1, 5, 5],
|
||
"SCINIT retains source-ordered overwrites rather than only the final cell")
|
||
check(meta["resolved_script_count"] == 1209
|
||
and len({record["script_resource_id"] for record in records}) == 135,
|
||
"every final SCINIT row resolves to one of 135 numbered scene scripts")
|
||
check(meta["scjump_joined_record_count"] == 847
|
||
and meta["scjump_chapter_match_count"] == 844
|
||
and [row["decision_id"] for row in meta["scjump_chapter_mismatches"]]
|
||
== [250, 1001, 1005],
|
||
"SCINIT chapter tags cross-check against every live SCJUMP decision")
|
||
semantics = extract_init.field_semantics(records)
|
||
check(semantics["0x87a57"] == "scjump_scene_script_resource_ids"
|
||
and semantics["0x8a167"] == "scjump_authored_chapters",
|
||
"SCINIT's paired columns join to canonical semantic names")
|
||
|
||
|
||
def test_real_routine_banks() -> None:
|
||
script = sys4load.load(extract_init.resolve("RTINIT"))
|
||
check(extract_init.detect_mode(script) == "banked",
|
||
"RTINIT auto-detects as parallel routine-step banks")
|
||
records, meta = extract_init.extract_banked(script)
|
||
by_id = {record["id"]: record for record in records}
|
||
check(len(records) == 172
|
||
and meta["first_record_id"] == 1
|
||
and meta["last_record_id"] == 176
|
||
and meta["missing_record_ids"] == [150, 151, 152, 153],
|
||
"RTINIT preserves its sparse one-based routine-set ids")
|
||
check(meta["bank_root_base"] == "0xeff78"
|
||
and meta["bank_span"] == 20000
|
||
and meta["bank_count"] == 20
|
||
and meta["record_stride"] == 20
|
||
and meta["reserved_record_span"] == 1000,
|
||
"RTINIT exposes twenty parallel 1000-by-20 banks")
|
||
check(meta["assignment_count"] == 3336
|
||
and meta["populated_cell_count"] == 3307
|
||
and meta["overwritten_cell_count"] == 29
|
||
and meta["conflicting_overwrite_count"] == 11,
|
||
"RTINIT preserves source assignments and final overwrite accounting")
|
||
check(meta["movement_step_count"] == 1043
|
||
and meta["battle_step_count"] == 14
|
||
and len(meta["used_movement_provider_selectors"]) == 19
|
||
and len(meta["used_battle_provider_selectors"]) == 4
|
||
and len(meta["record_field_columns"]) == 117,
|
||
"RTINIT assembles every populated movement and battle step")
|
||
check(meta["decoded_movement_provider_count"] == 19
|
||
and meta["decoded_movement_step_count"] == 1043
|
||
and meta["decoded_movement_parameter_count"] == 974
|
||
and meta["decoded_movement_defaulted_parameter_count"] == 13
|
||
and meta["ignored_movement_parameter_count"] == 3,
|
||
"RTINIT reports selector-specific semantic coverage")
|
||
check([
|
||
layout["bank_index"]
|
||
for layout in meta["bank_layouts"].values()
|
||
if layout["reserved_empty"]
|
||
] == [6, 13, 14, 15, 16, 17],
|
||
"RTINIT keeps all six reserved empty banks in its structural layout")
|
||
movement = by_id[1]["movement_steps"][0]
|
||
battle = by_id[1]["battle_steps"][0]
|
||
check(movement["movement_provider_selector"] == 1
|
||
and movement["movement_activation_percent"] == 100
|
||
and movement["provider_script"] == "RTN_M001.BIN",
|
||
"RTINIT joins movement selectors and activation percentages")
|
||
check(battle["battle_provider_selector"] == 1
|
||
and battle["battle_activation_percent"] == 100
|
||
and battle["provider_script"] == "RTN_B001.BIN",
|
||
"RTINIT joins battle selectors and activation percentages")
|
||
check(by_id[2]["battle_steps"][0]["battle_parameter_1"] == 219
|
||
and by_id[2]["battle_steps"][0]["provider_script"] == "RTN_B004.BIN",
|
||
"RTINIT retains provider-specific battle parameters")
|
||
check(by_id[173]["movement_steps"][0]["movement_parameter_1"] == 2
|
||
and by_id[173]["movement_steps"][0]["movement_parameter_2"] == 158,
|
||
"RTINIT final rows reflect source-ordered conflicting overwrites")
|
||
provider_5 = by_id[5]["movement_steps"][2]
|
||
check(provider_5["movement_provider_selector"] == 5
|
||
and provider_5["provider_behavior"] == "approach_destination_tile"
|
||
and provider_5["destination_tile_x"] == 24
|
||
and provider_5["destination_tile_y"] == 123
|
||
and provider_5["movement_parameter_1"] == 24
|
||
and provider_5["movement_parameter_2"] == 123,
|
||
"RTINIT joins RTN_M005 destination semantics without replacing raw banks")
|
||
provider_11 = by_id[87]["movement_steps"][2]
|
||
check(provider_11["movement_provider_selector"] == 11
|
||
and provider_11["provider_behavior"] == "cycle_destination_waypoints"
|
||
and provider_11["destination_tile_x"] == 3
|
||
and provider_11["destination_tile_y"] == 499
|
||
and provider_11["waypoint_ordinal"] == 1
|
||
and provider_11["path_cost_limit_override"] == 2,
|
||
"RTINIT joins all four RTN_M011 waypoint parameters")
|
||
provider_7 = by_id[33]["movement_steps"][4]
|
||
check(provider_7["movement_provider_selector"] == 7
|
||
and provider_7["provider_behavior"] == "approach_injured_ally"
|
||
and provider_7["maximum_target_route_steps"] == 5
|
||
and provider_7["maximum_target_hp_percent"] == 80,
|
||
"RTINIT joins RTN_M007 injured-ally search semantics")
|
||
provider_10 = by_id[99]["movement_steps"][0]
|
||
check(provider_10["movement_provider_selector"] == 10
|
||
and provider_10["provider_behavior"] == "approach_healing_feather"
|
||
and provider_10["resource_index"] == 0
|
||
and provider_10["maximum_resource_percent"] == 50
|
||
and "movement_parameter_1" not in provider_10,
|
||
"RTINIT projects RTN_M010's implicit HP default beside the raw banks")
|
||
provider_12 = by_id[168]["movement_steps"][0]
|
||
check(provider_12["movement_provider_selector"] == 12
|
||
and provider_12["provider_behavior"]
|
||
== "approach_destination_tile_avoiding_foreign_entities"
|
||
and provider_12["destination_tile_x"] == 11
|
||
and provider_12["destination_tile_y"] == 68,
|
||
"RTINIT joins RTN_M012's route-mode destination semantics")
|
||
provider_4 = by_id[5]["movement_steps"][0]
|
||
check(provider_4["movement_provider_selector"] == 4
|
||
and provider_4["provider_behavior"] == "approach_stage_object_slot"
|
||
and provider_4["stage_object_slot_index"] == 1,
|
||
"RTINIT joins RTN_M004's stage-object slot semantics")
|
||
provider_4_default = by_id[17]["movement_steps"][1]
|
||
check(provider_4_default["movement_provider_selector"] == 4
|
||
and provider_4_default["stage_object_slot_index"] == 0
|
||
and "movement_parameter_1" not in provider_4_default,
|
||
"RTINIT projects RTN_M004's implicit stage-object slot zero")
|
||
provider_6 = by_id[6]["movement_steps"][0]
|
||
check(provider_6["movement_provider_selector"] == 6
|
||
and provider_6["provider_behavior"] == "approach_nearest_enemy"
|
||
and provider_6["maximum_target_route_steps"] == 5,
|
||
"RTINIT joins RTN_M006's enemy-search radius")
|
||
provider_15 = by_id[2]["movement_steps"][0]
|
||
check(provider_15["movement_provider_selector"] == 15
|
||
and provider_15["provider_behavior"] == "approach_foreign_magic_pillar"
|
||
and provider_15["maximum_target_route_steps"] == 3,
|
||
"RTINIT joins RTN_M015's foreign Magic Pillar search radius")
|
||
provider_1_residue = by_id[173]["movement_steps"][2]
|
||
check(provider_1_residue["movement_provider_selector"] == 1
|
||
and provider_1_residue["provider_behavior"] == "advance_step_progress"
|
||
and provider_1_residue["ignored_movement_parameters"] == {
|
||
"movement_parameter_1": 10,
|
||
"movement_parameter_2": 711,
|
||
},
|
||
"RTINIT marks RTN_M001's authored but unread parameter cells")
|
||
provider_8_residue = by_id[112]["movement_steps"][6]
|
||
check(provider_8_residue["movement_provider_selector"] == 8
|
||
and provider_8_residue["provider_behavior"]
|
||
== "approach_nearest_foreign_magic_pillar"
|
||
and provider_8_residue["ignored_movement_parameters"]
|
||
== {"movement_parameter_1": 1},
|
||
"RTINIT marks RTN_M008's authored but unread parameter cell")
|
||
provider_13 = by_id[104]["movement_steps"][3]
|
||
check(provider_13["movement_provider_selector"] == 13
|
||
and provider_13["provider_behavior"]
|
||
== "approach_faction_traversable_tile"
|
||
and provider_13["target_faction_filter"] == 1,
|
||
"RTINIT joins RTN_M013's explicit faction filter")
|
||
provider_13_default = by_id[23]["movement_steps"][1]
|
||
check(provider_13_default["movement_provider_selector"] == 13
|
||
and provider_13_default["target_faction_filter"] == 0
|
||
and "movement_parameter_1" not in provider_13_default,
|
||
"RTINIT projects RTN_M013's any-foreign-faction default")
|
||
provider_14 = by_id[130]["movement_steps"][5]
|
||
check(provider_14["movement_provider_selector"] == 14
|
||
and provider_14["provider_behavior"] == "retreat_from_nearby_enemies"
|
||
and provider_14["maximum_threat_route_steps"] == 6,
|
||
"RTINIT joins RTN_M014's threat-detection radius")
|
||
provider_3 = by_id[3]["movement_steps"][3]
|
||
check(provider_3["movement_provider_selector"] == 3
|
||
and provider_3["provider_behavior"]
|
||
== "route_toward_reachable_normal_attack_target"
|
||
and "movement_parameter_1" not in provider_3,
|
||
"RTINIT joins RTN_M003's parameterless attack-route behavior")
|
||
provider_51 = by_id[3]["movement_steps"][1]
|
||
check(provider_51["movement_provider_selector"] == 51
|
||
and provider_51["provider_behavior"]
|
||
== "select_effective_attack_target_and_action"
|
||
and "movement_parameter_1" not in provider_51,
|
||
"RTINIT joins RTN_M051's parameterless attack-selection behavior")
|
||
provider_17 = by_id[3]["movement_steps"][2]
|
||
check(provider_17["movement_provider_selector"] == 17
|
||
and provider_17["provider_behavior"]
|
||
== "route_toward_lowest_hp_reachable_normal_attack_target"
|
||
and "movement_parameter_1" not in provider_17,
|
||
"RTINIT joins RTN_M017's low-HP attack-route behavior")
|
||
provider_52 = by_id[3]["movement_steps"][0]
|
||
check(provider_52["movement_provider_selector"] == 52
|
||
and provider_52["provider_behavior"]
|
||
== "select_lowest_hp_effective_attack_target_and_action"
|
||
and "movement_parameter_1" not in provider_52,
|
||
"RTINIT joins RTN_M052's low-HP attack-selection behavior")
|
||
provider_2 = by_id[2]["movement_steps"][1]
|
||
check(provider_2["movement_provider_selector"] == 2
|
||
and provider_2["provider_behavior"]
|
||
== "roam_to_random_reachable_tile"
|
||
and "movement_parameter_1" not in provider_2,
|
||
"RTINIT joins RTN_M002's randomized roaming behavior")
|
||
provider_9 = by_id[98]["movement_steps"][1]
|
||
check(provider_9["movement_provider_selector"] == 9
|
||
and provider_9["provider_behavior"]
|
||
== "approach_collectible_treasure"
|
||
and "movement_parameter_1" not in provider_9,
|
||
"RTINIT joins RTN_M009's collectible-treasure behavior")
|
||
provider_61 = by_id[33]["movement_steps"][0]
|
||
check(provider_61["movement_provider_selector"] == 61
|
||
and provider_61["provider_behavior"]
|
||
== "select_lowest_hp_ally_and_healing_skill"
|
||
and "movement_parameter_1" not in provider_61,
|
||
"RTINIT joins RTN_M061's immediate healing behavior")
|
||
check(all(
|
||
"provider_behavior" in step
|
||
for record in records
|
||
for step in record.get("movement_steps", [])
|
||
), "RTINIT joins behavior semantics onto every shipped movement step")
|
||
check(
|
||
meta["movement_provider_parameter_schemas"]["11"]["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",
|
||
},
|
||
"RTINIT publishes the reusable RTN_M011 parameter schema",
|
||
)
|
||
check(
|
||
meta["movement_provider_parameter_schemas"]["10"]["parameter_defaults"]
|
||
== {"movement_parameter_1": 0},
|
||
"RTINIT publishes RTN_M010's implicit resource-index default",
|
||
)
|
||
semantics = extract_init.field_semantics(records)
|
||
check(
|
||
semantics["0xeff78/20/0"]
|
||
== "movement_routine_provider_selectors.column_0"
|
||
and semantics["0x125ad8/20/0"]
|
||
== "battle_routine_activation_percents.column_0",
|
||
"RTINIT raw banks join to canonical structural field names",
|
||
)
|
||
|
||
|
||
def test_real_message_tables() -> None:
|
||
scripts = paths.scripts()
|
||
expected = {
|
||
"ITMES.BIN": (0x8C877, 287, "fallthrough"),
|
||
"SKMES.BIN": (0xA6E59, 131, "fallthrough"),
|
||
"VIMES.BIN": (0x15A2A8, 65, "branch-target"),
|
||
"EIMES.BIN": (0x15A759, 192, "branch-target"),
|
||
"CIMES.BIN": (0x15A117, 24, "branch-target"),
|
||
"MAMES.BIN": (0x1560E7, 9, "fallthrough"),
|
||
}
|
||
for name, (selector, count, dispatch_layout) in expected.items():
|
||
records, meta = extract_message_table.extract_messages(
|
||
sys4load.load(scripts[name])
|
||
)
|
||
check(meta["selector_global"] == f"0x{selector:x}",
|
||
f"{name}: discovers selector global 0x{selector:x}")
|
||
check(len(records) == count, f"{name}: extracts {count} messages")
|
||
check(len(records) == meta["dispatch_guard_count"],
|
||
f"{name}: every dispatch guard yields a message")
|
||
check(len({record['id'] for record in records}) == len(records),
|
||
f"{name}: message ids are unique")
|
||
check(meta["dispatch_layout"] == dispatch_layout,
|
||
f"{name}: recognizes its {dispatch_layout} dispatch layout")
|
||
|
||
item_messages, _ = extract_message_table.extract_messages(
|
||
sys4load.load(scripts["ITMES.BIN"])
|
||
)
|
||
items = {record["id"]: record for record in item_messages}
|
||
check(items[1]["title"] == "【重要:銅の鍵】 LEVEL-E",
|
||
"ITMES item 1 keeps its display title")
|
||
check(items[1]["description"] == " 銅の扉を開閉することが可能",
|
||
"ITMES item 1 keeps its player-facing behavior")
|
||
check("濃緑色" in items[32]["title"],
|
||
"ITMES reconstructs furigana surface text inside a title")
|
||
check(items[32]["furigana"][0]["reading"] == "のうりょくしょく",
|
||
"ITMES preserves furigana readings")
|
||
|
||
skill_messages, _ = extract_message_table.extract_messages(
|
||
sys4load.load(scripts["SKMES.BIN"])
|
||
)
|
||
skills = {record["id"]: record for record in skill_messages}
|
||
check(skills[1]["title"] == "【移動スキル:飛行】",
|
||
"SKMES skill 1 keeps its display title")
|
||
check(skills[1]["description"] == " 床のない地形を移動可能になる",
|
||
"SKMES skill 1 keeps its player-facing behavior")
|
||
|
||
vocabulary_messages, vocabulary_meta = extract_message_table.extract_messages(
|
||
sys4load.load(scripts["VIMES.BIN"])
|
||
)
|
||
vocabulary = {record["id"]: record for record in vocabulary_messages}
|
||
check(vocabulary[1]["title"] == "『【迷宮】占有率』"
|
||
and "各勢力の占領度合" in vocabulary[1]["description"],
|
||
"VIMES follows branch targets and reconstructs glossary help text")
|
||
check(vocabulary_meta["message_layout"] == "title-description",
|
||
"VIMES retains the title/description message layout")
|
||
|
||
enemy_messages, enemy_meta = extract_message_table.extract_messages(
|
||
sys4load.load(scripts["EIMES.BIN"])
|
||
)
|
||
enemies = {record["id"]: record for record in enemy_messages}
|
||
check(enemies[101]["summary"] == "高い能力を秘めた隣国の姫騎士"
|
||
and enemies[101]["strategy"] == "初遭遇時にはまず勝てない",
|
||
"EIMES exposes its two lines as enemy summary and strategy")
|
||
check(enemy_meta["message_layout"] == "enemy-commentary"
|
||
and "title" not in enemies[101],
|
||
"EIMES does not mislabel its first commentary line as a title")
|
||
|
||
character_messages, character_meta = extract_message_table.extract_messages(
|
||
sys4load.load(scripts["CIMES.BIN"])
|
||
)
|
||
characters = {record["id"]: record for record in character_messages}
|
||
check(
|
||
characters[1]["biography"].startswith("かつては人々を恐怖に陥れた")
|
||
and "title" not in characters[1],
|
||
"CIMES exposes its complete untitled character biography",
|
||
)
|
||
check(
|
||
character_meta["message_layout"] == "character-biography",
|
||
"CIMES records the biography-only message layout",
|
||
)
|
||
|
||
magic_messages, magic_meta = extract_message_table.extract_messages(
|
||
sys4load.load(scripts["MAMES.BIN"])
|
||
)
|
||
magic = {record["id"]: record for record in magic_messages}
|
||
check(
|
||
magic_meta["message_layout"] == "description"
|
||
and "title" not in magic[1]
|
||
and len(magic[1]["description"]) > 0,
|
||
"MAMES exposes complete untitled action descriptions",
|
||
)
|
||
|
||
|
||
def test_message_infrastructure() -> None:
|
||
scripts = paths.scripts()
|
||
info = sys4load.load(scripts["INFOMES.BIN"])
|
||
lookups = [
|
||
instruction
|
||
for instruction in info.instructions
|
||
if sys4load.display_label(instruction.opcode) == "lookup-array-2d"
|
||
]
|
||
check(
|
||
not info.strings
|
||
and len(lookups) == 2
|
||
and all(
|
||
(extract_init.T_GLOBAL_INT, 0x15A097) in instruction.args
|
||
and (extract_init.T_GLOBAL_INT, 0x15A095) in instruction.args
|
||
and (extract_init.T_IMM, 4) in instruction.args
|
||
for instruction in lookups
|
||
),
|
||
"INFOMES is a text-free 32x4 tab-handler registry walker",
|
||
)
|
||
check(
|
||
any(
|
||
sys4load.display_label(instruction.opcode) == "call-script"
|
||
and instruction.args[0][0] != extract_init.T_IMM
|
||
for instruction in info.instructions
|
||
),
|
||
"INFOMES invokes registry entries through an indirect call-script",
|
||
)
|
||
init2 = sys4load.load(scripts["INIT2.BIN"])
|
||
initial_handlers = {
|
||
destination: value
|
||
for instruction in init2.instructions
|
||
if (write := extract_init._static_global_write(instruction)) is not None
|
||
for destination, value in [write]
|
||
if 0x15A097 <= destination <= 0x15A099
|
||
}
|
||
check(
|
||
initial_handlers
|
||
== {0x15A097: 0x334A, 0x15A098: 0x334B, 0x15A099: 0x334C},
|
||
"INIT2 installs CIMES, EIMES, and VIMES in handler row zero",
|
||
)
|
||
check(
|
||
any(
|
||
instruction.args
|
||
and instruction.args[0] == (extract_init.T_GLOBAL_INT, 0x15A096)
|
||
and extract_init._static_global_write(instruction) == (0x15A096, 0)
|
||
for instruction in info.instructions
|
||
),
|
||
"INFOMES clears the first-handler-wins completion flag",
|
||
)
|
||
|
||
modal = sys4load.load(scripts["MES.BIN"])
|
||
labels = {
|
||
sys4load.display_label(instruction.opcode)
|
||
for instruction in modal.instructions
|
||
}
|
||
references = {
|
||
operand
|
||
for instruction in modal.instructions
|
||
for operand in instruction.args
|
||
}
|
||
check(
|
||
"show-text" not in labels
|
||
and "draw-string" in labels
|
||
and {
|
||
(extract_init.T_GLOBAL_STRING, 0x7DB),
|
||
(extract_init.T_GLOBAL_INT, 0x665D6),
|
||
} <= references,
|
||
"MES is a generic renderer for caller-populated modal lines",
|
||
)
|
||
check(
|
||
{
|
||
(extract_init.T_GLOBAL_STRING, 0x7E5),
|
||
(extract_init.T_GLOBAL_INT, 0x665E2),
|
||
(extract_init.T_GLOBAL_INT, 0x665E3),
|
||
(extract_init.T_GLOBAL_INT, 0x66647),
|
||
} <= references,
|
||
"MES retains the optional annotation text and placement ABI",
|
||
)
|
||
modal_writes = {
|
||
write
|
||
for instruction in modal.instructions
|
||
if (write := extract_init._static_global_write(instruction)) is not None
|
||
}
|
||
check(
|
||
(0x665D6, 0) in modal_writes and (0x665E2, 0) in modal_writes,
|
||
"MES clears both modal buffers after dismissal",
|
||
)
|
||
|
||
|
||
def test_message_join() -> None:
|
||
scripts = paths.scripts()
|
||
expected = {
|
||
"IT": (287, " 銅の扉を開閉することが可能"),
|
||
"SK": (131, " 床のない地形を移動可能になる"),
|
||
}
|
||
joined = {}
|
||
for prefix, (count, _) in expected.items():
|
||
records, _ = extract_init.extract_name(
|
||
sys4load.load(scripts[f"{prefix}INIT.BIN"])
|
||
)
|
||
meta = extract_init.join_messages(
|
||
records, sys4load.load(scripts[f"{prefix}MES.BIN"])
|
||
)
|
||
check(meta["joined_count"] == count,
|
||
f"{prefix}INIT joins all {count} {prefix}MES messages")
|
||
check(not meta["init_ids_without_message"] and not meta["message_ids_without_init"],
|
||
f"{prefix}INIT and {prefix}MES ids match exactly")
|
||
joined[prefix] = {record["id"]: record for record in records}
|
||
check(joined["IT"][1]["message"]["description"] == expected["IT"][1],
|
||
"INIT/MES join uses the shared runtime id")
|
||
|
||
vocabulary, _ = extract_init.extract_vocabulary(
|
||
sys4load.load(scripts["VIINIT.BIN"])
|
||
)
|
||
vocabulary_meta = extract_init.join_messages(
|
||
vocabulary, sys4load.load(scripts["VIMES.BIN"])
|
||
)
|
||
check(
|
||
vocabulary_meta["joined_count"] == 65
|
||
and not vocabulary_meta["init_ids_without_message"]
|
||
and not vocabulary_meta["message_ids_without_init"],
|
||
"VIINIT and VIMES form a complete 65-topic runtime-id join",
|
||
)
|
||
|
||
units, _ = extract_init.extract_name(sys4load.load(scripts["EBINIT.BIN"]))
|
||
enemy_meta = extract_init.join_messages(
|
||
units, sys4load.load(scripts["EIMES.BIN"])
|
||
)
|
||
unit_by_id = {record["id"]: record for record in units}
|
||
check(
|
||
enemy_meta["joined_count"] == 192
|
||
and len(enemy_meta["init_ids_without_message"]) == 85
|
||
and not enemy_meta["message_ids_without_init"],
|
||
"EIMES joins 192 sparse enemy-commentary rows to EBINIT",
|
||
)
|
||
check(
|
||
unit_by_id[101]["message"]["strategy"] == "初遭遇時にはまず勝てない",
|
||
"EBINIT records expose EIMES strategy text by unit id",
|
||
)
|
||
|
||
characters, _ = extract_init.extract_character_profiles(
|
||
sys4load.load(scripts["CIINIT.BIN"])
|
||
)
|
||
character_meta = extract_init.join_messages(
|
||
characters, sys4load.load(scripts["CIMES.BIN"])
|
||
)
|
||
character_by_id = {record["id"]: record for record in characters}
|
||
check(
|
||
character_meta["joined_count"] == 24
|
||
and not character_meta["init_ids_without_message"]
|
||
and not character_meta["message_ids_without_init"],
|
||
"CIINIT and CIMES form a complete 24-profile runtime-id join",
|
||
)
|
||
check(
|
||
character_by_id[1]["message"]["biography"].startswith(
|
||
"かつては人々を恐怖に陥れた"
|
||
),
|
||
"CIINIT records expose CIMES biography text by profile id",
|
||
)
|
||
|
||
magic_actions, _ = extract_init.extract_magic_actions(
|
||
sys4load.load(scripts["MAINIT.BIN"])
|
||
)
|
||
magic_meta = extract_init.join_messages(
|
||
magic_actions, sys4load.load(scripts["MAMES.BIN"])
|
||
)
|
||
magic_by_id = {record["id"]: record for record in magic_actions}
|
||
check(
|
||
magic_meta["joined_count"] == 9
|
||
and magic_meta["init_ids_without_message"] == [10, 11]
|
||
and not magic_meta["message_ids_without_init"],
|
||
"MAINIT and MAMES form the expected sparse 9-of-11 action join",
|
||
)
|
||
check(
|
||
magic_by_id[1]["message"]["description"]
|
||
and "title" not in magic_by_id[1]["message"],
|
||
"MAINIT records expose MAMES descriptions by action id",
|
||
)
|
||
|
||
|
||
def test_character_names() -> None:
|
||
scripts = paths.scripts()
|
||
records, meta = extract_init.extract_character_names(
|
||
sys4load.load(scripts["CNINIT.BIN"])
|
||
)
|
||
by_id = {record["id"]: record for record in records}
|
||
check(
|
||
len(records) == 277
|
||
and meta["record_span"] == 1000
|
||
and meta["integer_write_count"] == 277
|
||
and meta["string_write_count"] == 274,
|
||
"CNINIT extracts its complete sparse 1,000-row pair of arrays",
|
||
)
|
||
check(
|
||
meta["joined_unit_definition_count"] == 277
|
||
and not meta["cninit_ids_without_unit_definition"]
|
||
and not meta["unit_definition_ids_without_cninit"],
|
||
"CNINIT ids join exactly to all 277 EBINIT definitions",
|
||
)
|
||
check(
|
||
meta["unnamed_record_ids"] == [2, 3, 4]
|
||
and all(by_id[unit_id]["name"] is None for unit_id in (2, 3, 4)),
|
||
"CNINIT preserves Lily's three deliberately unnamed form rows",
|
||
)
|
||
check(
|
||
by_id[1]["name"] == "エミリオ"
|
||
and by_id[1]["fields"]["0x624bf"] == 1
|
||
and by_id[1]["string_fields"]["0x315"] == "エミリオ",
|
||
"CNINIT keeps display names and voice-family ids as parallel fields",
|
||
)
|
||
check(
|
||
meta["voice_family_alias_count"] == 175
|
||
and by_id[21]["canonical_voice_unit_id"] == 5
|
||
and by_id[21]["unit_definition_name"] == "シルフィーヌ(洗脳状態)"
|
||
and by_id[21]["canonical_voice_unit_name"] == "シルフィーヌ",
|
||
"CNINIT joins variant rows to their canonical voice-family definitions",
|
||
)
|
||
check(
|
||
by_id[800]["name"] == "アセンブリア"
|
||
and by_id[800]["canonical_voice_unit_id"] == 240,
|
||
"CNINIT preserves late EX/BOSS aliases without collapsing sparse ids",
|
||
)
|
||
|
||
semantics = extract_init.field_semantics(records)
|
||
check(
|
||
semantics == {
|
||
"0x315": "unit_story_display_names",
|
||
"0x624bf": "unit_voice_family_unit_ids",
|
||
},
|
||
"CNINIT's two raw arrays join to their canonical semantic names",
|
||
)
|
||
extract_init.attach_semantic_fields(records, semantics)
|
||
check(
|
||
by_id[21]["semantic_fields"]["unit_story_display_names"] == "シルフィーヌ"
|
||
and by_id[21]["semantic_fields"]["unit_voice_family_unit_ids"] == 5,
|
||
"CNINIT records retain raw arrays beside one semantic view",
|
||
)
|
||
|
||
|
||
def test_gallery_definitions() -> None:
|
||
scripts = paths.scripts()
|
||
script = sys4load.load(scripts["CGINIT.BIN"])
|
||
check(
|
||
extract_init.detect_mode(script) == "numeric",
|
||
"CGINIT remains compatible with numeric-mode auto-detection",
|
||
)
|
||
records, meta = extract_init.extract_gallery_definitions(script)
|
||
by_id = {record["id"]: record for record in records}
|
||
check(
|
||
len(records) == 851
|
||
and meta["record_span"] == 2000
|
||
and meta["populated_id_range"] == [1, 855]
|
||
and meta["id_gaps_within_populated_range"] == [205, 585, 603, 604],
|
||
"CGINIT extracts 851 sparse gallery rows from its reserved 2,000 ids",
|
||
)
|
||
check(
|
||
meta["static_write_count"] == 3941
|
||
and meta["classified_static_write_count"] == 3941
|
||
and meta["preview_asset_count"] == 537,
|
||
"CGINIT classifies every integer write and all optional previews",
|
||
)
|
||
check(
|
||
[sheet["asset_name"] for sheet in meta["thumbnail_sheets"]]
|
||
== ["SO026A.AGF", "SO026B.AGF", "SO026C.AGF", "SO026D.AGF"]
|
||
and all(sheet["slot_count"] == 30 for sheet in meta["thumbnail_sheets"]),
|
||
"CGINIT joins the four configured 6x5 thumbnail atlases",
|
||
)
|
||
check(
|
||
by_id[1]["gallery_image_asset_id"] == 5407
|
||
and by_id[1]["gallery_image_asset_name"] == "EV001AA.AGF"
|
||
and by_id[1]["thumbnail_sheet_id"] == 1
|
||
and by_id[1]["thumbnail_slot_id"] == 1
|
||
and by_id[1]["variant_ordinal"] == 3,
|
||
"CGINIT row 1 joins its full image and thumbnail placement",
|
||
)
|
||
check(
|
||
by_id[4]["save_stage_preview_asset_id"] == 11266
|
||
and by_id[4]["save_stage_preview_asset_name"] == "EVM001BA.AGF"
|
||
and "save_stage_preview_asset_id" not in by_id[1],
|
||
"CGINIT preserves optional SAVE/SELSTAGE preview assets",
|
||
)
|
||
|
||
semantics = extract_init.field_semantics(records)
|
||
check(
|
||
semantics == {
|
||
"0x62cd1/2/0": "gallery_image_assets.gallery_image_asset_id",
|
||
"0x62cd1/2/1": "gallery_image_assets.save_stage_preview_asset_id",
|
||
"0x63c71": "gallery_thumbnail_sheet_ids",
|
||
"0x64441": "gallery_thumbnail_slot_ids",
|
||
"0x64c11": "gallery_variant_ordinals",
|
||
},
|
||
"CGINIT raw arrays join to their canonical semantic names",
|
||
)
|
||
extract_init.attach_semantic_fields(records, semantics)
|
||
check(
|
||
by_id[4]["semantic_fields"][
|
||
"gallery_image_assets.save_stage_preview_asset_id"
|
||
] == 11266
|
||
and by_id[4]["semantic_fields"]["gallery_thumbnail_slot_ids"] == 1,
|
||
"CGINIT retains raw addresses beside one semantic field view",
|
||
)
|
||
|
||
|
||
def test_alchemy_recipes() -> None:
|
||
scripts = paths.scripts()
|
||
script = sys4load.load(scripts["ALINIT.BIN"])
|
||
check(
|
||
extract_init.detect_mode(script) == "numeric",
|
||
"ALINIT remains compatible with numeric-mode auto-detection",
|
||
)
|
||
records, meta = extract_init.extract_alchemy_recipes(script)
|
||
by_id = {record["id"]: record for record in records}
|
||
check(
|
||
len(records) == 107
|
||
and meta["record_span"] == 1000
|
||
and meta["populated_id_range"] == [1, 467],
|
||
"ALINIT extracts 107 sparse recipes from its reserved 1,000 ids",
|
||
)
|
||
check(
|
||
meta["static_write_count"] == 914
|
||
and meta["classified_static_write_count"] == 914
|
||
and len(meta["record_field_columns"]) == 10,
|
||
"ALINIT classifies every scalar and populated row-table write",
|
||
)
|
||
check(
|
||
meta["output_item_join_count"] == 107
|
||
and meta["ingredient_reference_count"] == 286
|
||
and meta["joined_ingredient_reference_count"] == 286,
|
||
"ALINIT resolves every output and ingredient reference through ITINIT",
|
||
)
|
||
check(
|
||
by_id[1]["output_item_id"] == 2
|
||
and by_id[1]["output_item_name"] == "白銀の鍵"
|
||
and by_id[1]["minimum_alchemy_level"] == 2
|
||
and by_id[1]["point_cost"] == 30
|
||
and by_id[1]["required_story_flag_ids"] == [1902]
|
||
and by_id[1]["forbidden_story_flag_ids"] == [1903],
|
||
"ALINIT recipe 1 exposes its output, gating, and point cost",
|
||
)
|
||
check(
|
||
[
|
||
(ingredient["slot"], ingredient["item_id"], ingredient["quantity"])
|
||
for ingredient in by_id[1]["ingredients"]
|
||
] == [(1, 608, 1), (2, 625, 1)]
|
||
and by_id[14]["output_item_name"] == "シルバーコイン"
|
||
and [
|
||
(ingredient["slot"], ingredient["item_id"], ingredient["quantity"])
|
||
for ingredient in by_id[14]["ingredients"]
|
||
] == [(0, 91, 5)],
|
||
"ALINIT preserves sparse ingredient slots and their paired quantities",
|
||
)
|
||
|
||
semantics = extract_init.field_semantics(records)
|
||
check(
|
||
semantics == {
|
||
"0x156214": "alchemy_recipe_output_item_ids",
|
||
"0x1565fc": "alchemy_recipe_minimum_levels",
|
||
"0x1569e4/2/0": (
|
||
"alchemy_recipe_required_story_flags.required_flag_1"
|
||
),
|
||
"0x1571b4/2/0": (
|
||
"alchemy_recipe_forbidden_story_flags.forbidden_flag_1"
|
||
),
|
||
"0x157d6c": "alchemy_recipe_point_costs",
|
||
"0x158154/4/0": (
|
||
"alchemy_recipe_ingredient_item_ids.ingredient_1"
|
||
),
|
||
"0x158154/4/1": (
|
||
"alchemy_recipe_ingredient_item_ids.ingredient_2"
|
||
),
|
||
"0x158154/4/2": (
|
||
"alchemy_recipe_ingredient_item_ids.ingredient_3"
|
||
),
|
||
"0x158154/4/3": (
|
||
"alchemy_recipe_ingredient_item_ids.ingredient_4"
|
||
),
|
||
"0x1590f4/4/0": (
|
||
"alchemy_recipe_ingredient_quantities.ingredient_1"
|
||
),
|
||
"0x1590f4/4/1": (
|
||
"alchemy_recipe_ingredient_quantities.ingredient_2"
|
||
),
|
||
"0x1590f4/4/2": (
|
||
"alchemy_recipe_ingredient_quantities.ingredient_3"
|
||
),
|
||
"0x1590f4/4/3": (
|
||
"alchemy_recipe_ingredient_quantities.ingredient_4"
|
||
),
|
||
},
|
||
"ALINIT raw arrays join to their canonical semantic names",
|
||
)
|
||
extract_init.attach_semantic_fields(records, semantics)
|
||
check(
|
||
by_id[1]["semantic_fields"][
|
||
"alchemy_recipe_required_story_flags.required_flag_1"
|
||
] == 1902
|
||
and by_id[14]["semantic_fields"][
|
||
"alchemy_recipe_ingredient_quantities.ingredient_1"
|
||
] == 5,
|
||
"ALINIT retains raw cells beside one semantic recipe view",
|
||
)
|
||
|
||
|
||
def test_affinity_definitions() -> None:
|
||
scripts = paths.scripts()
|
||
script = sys4load.load(scripts["AFINIT.BIN"])
|
||
check(
|
||
extract_init.detect_mode(script) == "name",
|
||
"AFINIT remains compatible with name-mode auto-detection",
|
||
)
|
||
records, meta = extract_init.extract_affinity_definitions(script)
|
||
by_id = {record["id"]: record for record in records}
|
||
check(
|
||
len(records) == 13
|
||
and meta["string_write_count"] == 27
|
||
and meta["footer_array_count"] == 54
|
||
and meta["classified_instruction_count"] == 82,
|
||
"AFINIT classifies every vocabulary, footer-array, and exit instruction",
|
||
)
|
||
check(
|
||
[entry["id"] for entry in meta["attack_element_names"]]
|
||
== [*range(1, 9), *range(11, 18)]
|
||
and [entry["id"] for entry in meta["defense_element_names"]]
|
||
== list(range(1, 13)),
|
||
"AFINIT preserves its sparse attack and defense element vocabularies",
|
||
)
|
||
check(
|
||
by_id[3]["name"] == "火炎"
|
||
and by_id[3]["attack_effectiveness"][3]["percent"] == -100
|
||
and by_id[3]["attack_effectiveness"][4]["percent"] == 150
|
||
and by_id[11]["attack_effectiveness"][1]["percent"] == 1
|
||
and by_id[11]["attack_effectiveness"][7]["percent"] == 200,
|
||
"AFINIT exposes signed elemental immunities, weaknesses, and resistances",
|
||
)
|
||
|
||
tuning = {
|
||
curve["curve_id"]: curve for curve in meta["item_tuning_curves"]
|
||
}
|
||
check(
|
||
len(tuning) == 19
|
||
and tuning[1]["level_bonuses"] == [1, 0, 0, 0, 0, 0, 0, 0, 0, 0]
|
||
and tuning[9]["level_bonuses"] == list(range(1, 11))
|
||
and tuning[18]["level_bonuses"] == list(range(3, 31, 3))
|
||
and tuning[18]["level_costs"]
|
||
== [10, 25, 45, 70, 100, 140, 190, 250, 320, 400]
|
||
and tuning[19]["level_bonuses"] == [0] * 10
|
||
and tuning[19]["level_costs"] == [0] * 10
|
||
and meta["usable_item_tuning_curve_ids"] == list(range(1, 19))
|
||
and meta["reserved_item_tuning_curve_ids"] == [19],
|
||
"AFINIT pairs all nineteen item-tuning bonus and point-cost curves",
|
||
)
|
||
check(
|
||
[
|
||
row["level_progress_thresholds"]
|
||
for row in meta["facility_level_thresholds"]
|
||
] == [
|
||
[40, 80, 120, 160, 200, 300],
|
||
[20, 40, 60, 90, 120, 200],
|
||
[20, 50, 100, 150, 200, 400],
|
||
],
|
||
"AFINIT exposes the tuning, alchemy, and magic progression rows",
|
||
)
|
||
|
||
semantics = extract_init.field_semantics(
|
||
records, meta["array_layouts"]
|
||
)
|
||
check(
|
||
len(semantics) == 14
|
||
and semantics["0x26a4"] == "defense_element_names"
|
||
and semantics["0xab5ba/0"]
|
||
== "attack_element_effectiveness_percent.row_0"
|
||
and semantics["0xab5ba/240"]
|
||
== "attack_element_effectiveness_percent.row_12",
|
||
"AFINIT raw vocabulary and matrix rows join to canonical semantics",
|
||
)
|
||
extract_init.attach_semantic_fields(records, semantics)
|
||
check(
|
||
by_id[3]["semantic_fields"]["defense_element_names"] == "火炎"
|
||
and by_id[3]["semantic_fields"][
|
||
"attack_element_effectiveness_percent.row_3"
|
||
][3] == -100,
|
||
"AFINIT retains raw footer provenance beside signed semantic rows",
|
||
)
|
||
|
||
|
||
def test_name_entry_palette() -> None:
|
||
scripts = paths.scripts()
|
||
script = sys4load.load(scripts["CTINIT.BIN"])
|
||
check(
|
||
extract_init.detect_mode(script) == "name",
|
||
"CTINIT remains compatible with name-mode auto-detection",
|
||
)
|
||
records, meta = extract_init.extract_name_entry_palette(script)
|
||
by_id = {record["id"]: record for record in records}
|
||
check(
|
||
len(records) == 5
|
||
and meta["reserved_shape"] == [5, 70]
|
||
and meta["string_write_count"] == 273
|
||
and meta["classified_instruction_count"] == 274,
|
||
"CTINIT classifies all five reserved palette pages and every instruction",
|
||
)
|
||
check(
|
||
meta["row_names"]
|
||
== ["hiragana", "katakana", "latin", "numerals", "symbols"]
|
||
and meta["populated_cells_per_row"] == [56, 56, 52, 40, 69],
|
||
"CTINIT names each page and preserves its authored cell population",
|
||
)
|
||
check(
|
||
by_id[0]["characters"][0] == "あ"
|
||
and by_id[0]["characters"][17] is None
|
||
and by_id[1]["characters"][50] == "ァ"
|
||
and by_id[2]["characters"][0] == "A"
|
||
and by_id[2]["characters"][30] == "a"
|
||
and by_id[3]["characters"][20] == "Ⅰ"
|
||
and by_id[3]["characters"][30] == "①"
|
||
and by_id[4]["characters"][68] == "ω"
|
||
and by_id[4]["characters"][69] is None,
|
||
"CTINIT retains representative characters and intentional empty slots",
|
||
)
|
||
|
||
semantics = extract_init.field_semantics(records)
|
||
check(
|
||
len(semantics) == 69
|
||
and semantics["0x43dd/70/0"]
|
||
== "name_entry_character_palette.column_0"
|
||
and semantics["0x43dd/70/68"]
|
||
== "name_entry_character_palette.column_68",
|
||
"CTINIT raw palette slots join to one canonical table name",
|
||
)
|
||
extract_init.attach_semantic_fields(records, semantics)
|
||
check(
|
||
by_id[0]["semantic_fields"][
|
||
"name_entry_character_palette.column_0"
|
||
] == "あ"
|
||
and by_id[4]["semantic_fields"][
|
||
"name_entry_character_palette.column_68"
|
||
] == "ω",
|
||
"CTINIT retains raw cells beside the semantic palette view",
|
||
)
|
||
|
||
|
||
def test_voice_configuration() -> None:
|
||
scripts = paths.scripts()
|
||
script = sys4load.load(scripts["CVINIT.BIN"])
|
||
check(
|
||
extract_init.detect_mode(script) == "numeric",
|
||
"CVINIT remains compatible with numeric-mode auto-detection",
|
||
)
|
||
records, meta = extract_init.extract_voice_configuration(script)
|
||
by_id = {record["id"]: record for record in records}
|
||
check(
|
||
len(records) == 13
|
||
and meta["static_write_count"] == 37
|
||
and meta["classified_static_write_count"] == 37
|
||
and meta["classified_instruction_count"] == 38,
|
||
"CVINIT classifies every preview, forward-map, inverse-map, and exit instruction",
|
||
)
|
||
check(
|
||
[
|
||
record["preview_voice_asset_name"]
|
||
for record in records
|
||
] == [
|
||
"EUA0065.OGG",
|
||
"LILA1424.OGG",
|
||
"SYL0675.OGG",
|
||
"SAS0328.OGG",
|
||
"VID0345.OGG",
|
||
"EST0562.OGG",
|
||
"NEL0192.OGG",
|
||
"TIO0209.OGG",
|
||
"COL0836.OGG",
|
||
"BRI0687.OGG",
|
||
"OKT0357.OGG",
|
||
"FEM0328.OGG",
|
||
"DEI0501.OGG",
|
||
],
|
||
"CVINIT joins all thirteen preview clips to shipped OGG assets",
|
||
)
|
||
check(
|
||
[by_id[slot]["unit_id"] for slot in range(1, 13)]
|
||
== [2, 5, 6, 7, 12, 14, 13, 10, 8, 9, 15, 11]
|
||
and meta["unit_join_count"] == 12,
|
||
"CVINIT joins its twelve named configuration slots to EBINIT units",
|
||
)
|
||
check(
|
||
all(
|
||
by_id[slot]["array_fields"][
|
||
f"0x628a7/{by_id[slot]['unit_id']}"
|
||
] == slot
|
||
for slot in range(1, 13)
|
||
)
|
||
and meta["round_trip_mapping_count"] == 12,
|
||
"CVINIT's slot-to-unit and unit-to-setting maps round-trip exactly",
|
||
)
|
||
check(
|
||
by_id[0]["slot_kind"] == "system"
|
||
and "unit_id" not in by_id[0]
|
||
and by_id[1]["speaker_seen_flag_address"] == "0x56225"
|
||
and by_id[12]["speaker_seen_flag_address"] == "0x5622e",
|
||
"CVINIT preserves the non-unit system slot and speaker-seen joins",
|
||
)
|
||
|
||
semantics = extract_init.field_semantics(
|
||
records, meta["array_layouts"]
|
||
)
|
||
check(
|
||
len(semantics) == 14
|
||
and semantics["0x62cad"] == "character_voice_preview_asset_ids"
|
||
and semantics["0x62c8f"] == "character_voice_setting_unit_ids"
|
||
and semantics["0x628a7/11"]
|
||
== "unit_voice_suppression_flag_ids.index_11",
|
||
"CVINIT raw arrays join to their canonical voice-setting semantics",
|
||
)
|
||
extract_init.attach_semantic_fields(records, semantics)
|
||
check(
|
||
by_id[12]["semantic_fields"][
|
||
"character_voice_preview_asset_ids"
|
||
] == 11412
|
||
and by_id[12]["semantic_fields"][
|
||
"character_voice_setting_unit_ids"
|
||
] == 11
|
||
and by_id[12]["semantic_fields"][
|
||
"unit_voice_suppression_flag_ids.index_11"
|
||
] == 12,
|
||
"CVINIT retains raw coordinates beside its joined semantic view",
|
||
)
|
||
|
||
|
||
def test_map_terrain_atlas() -> None:
|
||
scripts = paths.scripts()
|
||
script = sys4load.load(scripts["MPINIT.BIN"])
|
||
check(
|
||
extract_init.detect_mode(script) == "footer",
|
||
"MPINIT remains compatible with footer-mode auto-detection",
|
||
)
|
||
records, meta = extract_init.extract_map_terrain_atlas(script)
|
||
by_y = {record["grid_y"]: record for record in records}
|
||
check(
|
||
len(records) == 1472
|
||
and meta["footer_array_count"] == 1472
|
||
and meta["classified_instruction_count"] == 1473,
|
||
"MPINIT classifies every terrain-row footer copy and exit instruction",
|
||
)
|
||
check(
|
||
meta["atlas_base"] == "0xccc93"
|
||
and meta["row_stride"] == 53
|
||
and meta["first_authored_column"] == 1
|
||
and meta["authored_column_count"] == 50
|
||
and meta["tile_to_grid_scale"] == 2,
|
||
"MPINIT exposes its 53-cell row pitch and doubled tile coordinate system",
|
||
)
|
||
check(
|
||
meta["authored_grid_y_min"] == 2
|
||
and meta["authored_grid_y_max"] == 1600
|
||
and meta["implicit_zero_row_count"] == 127
|
||
and all(record["length"] == 50 for record in records),
|
||
"MPINIT preserves all authored rows and the omitted all-zero row gaps",
|
||
)
|
||
check(
|
||
by_y[2]["global_addr"] == "0xcccfe"
|
||
and by_y[1600]["global_addr"] == "0xe17d4"
|
||
and by_y[1199]["terrain_ids_used"] == [6]
|
||
and by_y[1199]["nonzero_cell_count"] == 25,
|
||
"MPINIT row coordinates recover directly from destination addresses",
|
||
)
|
||
|
||
definitions = {row["id"]: row for row in meta["terrain_definitions"]}
|
||
check(
|
||
definitions[1]["name"] == "通路"
|
||
and definitions[1]["layout_class_name"] == "passage"
|
||
and definitions[2]["name"] == "部屋"
|
||
and definitions[2]["area_fill_flag"] == 1
|
||
and definitions[3]["name"] == "隠し通路"
|
||
and definitions[3]["layout_class_name"] == "hidden"
|
||
and definitions[15]["name"] == "溶岩流"
|
||
and definitions[15]["texture_slot_index"] == 9,
|
||
"MPINIT terrain ids join to LAINIT names and layout/render classes",
|
||
)
|
||
|
||
stage_maps = {stage["id"]: stage for stage in meta["stage_maps"]}
|
||
stage1 = stage_maps[1]
|
||
check(
|
||
meta["stage_map_count"] == 66
|
||
and meta["unique_atlas_rectangle_count"] == 53
|
||
and stage1["name"] == "『庭園の地下空洞』"
|
||
and stage1["tile_bounds"]
|
||
== {"min_x": 9, "max_x": 17, "min_y": 1, "max_y": 8}
|
||
and stage1["grid_bounds"]
|
||
== {"min_x": 18, "max_x": 34, "min_y": 2, "max_y": 16},
|
||
"STINIT2 bounds join 66 stage definitions to their doubled atlas rectangles",
|
||
)
|
||
check(
|
||
stage1["grid_width"] == 17
|
||
and stage1["grid_height"] == 15
|
||
and stage1["terrain_ids_used"] == [1, 2, 4, 12]
|
||
and stage1["terrain_id_counts"]
|
||
== {"0": 212, "1": 12, "2": 13, "4": 2, "12": 16},
|
||
"stage joins expose complete terrain grids and value populations",
|
||
)
|
||
check(
|
||
any(
|
||
shared["stage_ids"] == [32, 33, 34]
|
||
for shared in meta["shared_atlas_rectangles"]
|
||
)
|
||
and any(
|
||
shared["stage_ids"] == [101, 104, 106, 107, 108]
|
||
for shared in meta["shared_atlas_rectangles"]
|
||
),
|
||
"MPINIT preserves intentional atlas sharing across stage variants",
|
||
)
|
||
check(
|
||
meta["nonzero_cell_count"] == 17126
|
||
and meta["stage_rectangle_nonzero_cell_count"] == 17079
|
||
and meta["outside_stage_rectangle_nonzero_cell_count"] == 47,
|
||
"MPINIT accounts for stage terrain and the raw border-context cells",
|
||
)
|
||
|
||
|
||
def test_terrain_definitions() -> None:
|
||
scripts = paths.scripts()
|
||
script = sys4load.load(scripts["LAINIT.BIN"])
|
||
check(
|
||
extract_init.detect_mode(script) == "name",
|
||
"LAINIT remains compatible with name-mode auto-detection",
|
||
)
|
||
records, meta = extract_init.extract_terrain_definitions(script)
|
||
by_id = {record["id"]: record for record in records}
|
||
check(
|
||
len(records) == 20
|
||
and meta["reserved_record_span"] == 30
|
||
and meta["authored_terrain_ids"]
|
||
== [1, 2, 3, 4, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
|
||
and meta["implicit_default_terrain_ids"] == [0, 5, 6],
|
||
"LAINIT exposes the shipped terrain ids and reserved definition span",
|
||
)
|
||
check(
|
||
meta["string_write_count"] == 22
|
||
and meta["static_write_count"] == 75
|
||
and meta["classified_static_write_count"] == 75
|
||
and meta["classified_instruction_count"] == 98,
|
||
"LAINIT classifies every string, numeric, and exit instruction",
|
||
)
|
||
check(
|
||
by_id[1]["name"] == "通路"
|
||
and by_id[1]["texture_slot_index"] == 1
|
||
and by_id[1]["layout_class_name"] == "passage"
|
||
and by_id[2]["name"] == "部屋"
|
||
and by_id[2]["area_fill_flag"] == 1
|
||
and by_id[3]["name"] == "隠し通路"
|
||
and by_id[3]["layout_class_name"] == "hidden",
|
||
"LAINIT preserves terrain names, texture slots, fill flags, and classes",
|
||
)
|
||
check(
|
||
by_id[4]["effect_description"] == "▲命中・回避・防御"
|
||
and by_id[4]["combat_stat_deltas"]
|
||
== {
|
||
"accuracy": 5,
|
||
"evasion": 5,
|
||
"physical_defense": 1,
|
||
"magic_defense": 1,
|
||
}
|
||
and by_id[7]["combat_stat_deltas"]
|
||
== {"accuracy": -5, "evasion": -5, "speed": -3}
|
||
and by_id[10]["combat_stat_deltas"]
|
||
== {"accuracy": 10, "evasion": 10}
|
||
and meta["combat_stat_cell_count"] == 13,
|
||
"LAINIT effect text agrees with every populated combat-stat delta",
|
||
)
|
||
check(
|
||
by_id[3]["required_skill_id"] == 3
|
||
and by_id[3]["required_skill_name"] == "探索"
|
||
and by_id[7]["required_skill_name"] == "潜水"
|
||
and by_id[9]["required_skill_name"] == "飛行"
|
||
and by_id[15]["required_skill_name"] == "耐熱"
|
||
and meta["required_skill_count"] == 5,
|
||
"LAINIT traversal gates resolve through SKINIT skill definitions",
|
||
)
|
||
authored_slots = [
|
||
slot for slot in meta["texture_slots"] if slot["authored"]
|
||
]
|
||
check(
|
||
len(authored_slots) == 10
|
||
and authored_slots[0]["id"] == 1
|
||
and authored_slots[0]["default_asset_name"] == "MP000A.AGF"
|
||
and authored_slots[-1]["id"] == 13
|
||
and authored_slots[-1]["default_asset_name"] == "MP000N.AGF"
|
||
and by_id[15]["default_texture_asset_name"] == "MP000I.AGF"
|
||
and meta["default_texture_asset_join_count"] == 10,
|
||
"LAINIT texture slots join every fallback asset to SYS4INI",
|
||
)
|
||
|
||
|
||
def test_h_scene_gallery() -> None:
|
||
scripts = paths.scripts()
|
||
script = sys4load.load(scripts["SPINIT.BIN"])
|
||
check(
|
||
extract_init.detect_mode(script) == "numeric",
|
||
"SPINIT remains compatible with numeric-mode auto-detection",
|
||
)
|
||
records, meta = extract_init.extract_h_scene_gallery(script)
|
||
check(
|
||
len(records) == 8
|
||
and meta["page_count"] == 8
|
||
and meta["slots_per_page"] == 15
|
||
and meta["registry_capacity"] == 120,
|
||
"SPINIT exposes HMODE's eight-by-fifteen page geometry",
|
||
)
|
||
check(
|
||
meta["static_write_count"] == 118
|
||
and meta["classified_static_write_count"] == 118
|
||
and meta["classified_instruction_count"] == 119
|
||
and meta["empty_cells"]
|
||
== [{"page": 7, "slot": 13}, {"page": 7, "slot": 14}],
|
||
"SPINIT classifies every scene cell and the two implicit empty slots",
|
||
)
|
||
check(
|
||
records[0]["thumbnail_sheet_asset_name"] == "SO027A.AGF"
|
||
and records[7]["thumbnail_sheet_asset_name"] == "SO027H.AGF"
|
||
and meta["resolved_thumbnail_sheet_count"] == 8,
|
||
"SPINIT pages join to all eight INIT2 HMODE thumbnail sheets",
|
||
)
|
||
check(
|
||
records[0]["scenes"][0]
|
||
== {
|
||
"slot": 0,
|
||
"script_resource_id": 0x151E,
|
||
"script_name": "SP0800.BIN",
|
||
}
|
||
and records[2]["scenes"][3]["script_name"] == "SP1200.BIN"
|
||
and records[7]["scenes"][-1]["script_name"] == "SP0179.BIN"
|
||
and meta["resolved_scene_script_count"] == 118,
|
||
"SPINIT resolves every populated cell to its call-script resource",
|
||
)
|
||
check(
|
||
len(records[7]["script_resource_ids"]) == 15
|
||
and records[7]["script_resource_ids"][-2:] == [0, 0]
|
||
and len(records[7]["record_fields"]) == 13,
|
||
"SPINIT preserves complete rows beside authored raw-cell provenance",
|
||
)
|
||
|
||
|
||
def test_training_actions() -> None:
|
||
scripts = paths.scripts()
|
||
script = sys4load.load(scripts["TRINIT.BIN"])
|
||
records, meta = extract_init.extract_training_actions(script)
|
||
by_id = {record["id"]: record for record in records}
|
||
check(
|
||
len(records) == 21
|
||
and meta["reserved_record_count"] == 21
|
||
and meta["string_stride"] == 6
|
||
and meta["numeric_block_start"] == "0x155bbc"
|
||
and meta["numeric_block_end_exclusive"] == "0x1560e7",
|
||
"TRINIT exposes its 21-row text and contiguous numeric geometry",
|
||
)
|
||
check(
|
||
meta["string_write_count"] == 75
|
||
and meta["static_write_count"] == 289
|
||
and meta["classified_static_write_count"] == 289
|
||
and meta["classified_instruction_count"] == 365,
|
||
"TRINIT classifies every text, numeric, and exit instruction",
|
||
)
|
||
check(
|
||
by_id[0]["description_lines"]
|
||
== [
|
||
"使い魔と性魔術を行い、能力を高める。",
|
||
"精気20必要。『捕獲攻撃』獲得。",
|
||
]
|
||
and by_id[1]["locked_hint_lines"]
|
||
== [
|
||
"使い魔の成長や特別なアイテムが必要の",
|
||
"ようだ……。",
|
||
"作る為の方法と材料は……。",
|
||
]
|
||
and by_id[15]["description_lines"][2]
|
||
== "さらに最大精気+2。",
|
||
"TRINIT preserves the available and locked three-line text families",
|
||
)
|
||
check(
|
||
by_id[0]["eligibility"]["minimum_unit_level"] == 3
|
||
and by_id[4]["eligibility"]["minimum_alignment"] == 15
|
||
and by_id[13]["eligibility"]["maximum_alignment"] == -75
|
||
and by_id[13]["eligibility"]["minimum_training_progress"] == 45
|
||
and by_id[1]["eligibility"]["required_item_name"]
|
||
== "マタタビの媚薬",
|
||
"TRINIT decodes level, alignment, training, and ITINIT gates",
|
||
)
|
||
check(
|
||
by_id[0]["effects"]["spirit_cost"] == 20
|
||
and by_id[0]["effects"]["unit_stat_deltas"]
|
||
== {
|
||
"physical_attack": 7,
|
||
"physical_defense": 4,
|
||
"speed": 8,
|
||
"luck": 2,
|
||
"max_hp": 15,
|
||
"max_sp": 12,
|
||
"max_fs": 6,
|
||
}
|
||
and by_id[0]["effects"]["awarded_skill_name"] == "捕獲攻撃"
|
||
and by_id[13]["effects"]["alignment_delta_hundredths"] == -2000
|
||
and by_id[13]["effects"]["awarded_item_name"] == "死王の喚石",
|
||
"TRINIT joins spirit, stat, alignment, skill, and item effects",
|
||
)
|
||
check(
|
||
meta["event_cell_count"] == 75
|
||
and len(meta["distinct_event_story_flag_ids"]) == 38
|
||
and meta["resolved_event_dispatch_count"] == 75
|
||
and by_id[0]["execution_limit"] == 6
|
||
and by_id[0]["event_story_flag_ids"]
|
||
== [800, 830, 830, 830, 830, 830, 0, 0, 0, 0]
|
||
and by_id[0]["events"][0]["script_name"] == "SC0800.BIN"
|
||
and by_id[0]["events"][1]["script_name"] == "SC0830.BIN",
|
||
"TRINIT event slots join to SCINIT and retain repeat-scene limits",
|
||
)
|
||
check(
|
||
meta["required_item_join_count"] == 8
|
||
and meta["awarded_item_join_count"] == 3
|
||
and meta["awarded_skill_join_count"] == 8
|
||
and meta["authored_numeric_cell_counts"]["unit_stat_deltas"] == 95
|
||
and meta["authored_numeric_cell_counts"]["event_story_flag_ids"]
|
||
== 75,
|
||
"TRINIT accounts for every definition join and populated field family",
|
||
)
|
||
|
||
|
||
def test_card_generation_lists() -> None:
|
||
scripts = paths.scripts()
|
||
script = sys4load.load(scripts["CDINIT.BIN"])
|
||
check(
|
||
extract_init.detect_mode(script) == "numeric",
|
||
"CDINIT remains compatible with numeric-mode auto-detection",
|
||
)
|
||
records, meta = extract_init.extract_card_generation_lists(script)
|
||
by_id = {record["id"]: record for record in records}
|
||
check(
|
||
list(by_id) == [1, 11, 31, 41, 55, 61, 71, 94, 160]
|
||
and [record["entry_count"] for record in records]
|
||
== [11, 36, 38, 51, 26, 52, 64, 30, 75],
|
||
"CDINIT exposes all nine selector branches and their candidate counts",
|
||
)
|
||
check(
|
||
meta["entry_count"] == 383
|
||
and len(meta["distinct_card_ids"]) == 81
|
||
and meta["resolved_card_reference_count"] == 383
|
||
and meta["classified_instruction_count"] == 1565,
|
||
"CDINIT classifies every instruction and resolves every card reference",
|
||
)
|
||
first = by_id[1]["entries"][0]
|
||
check(
|
||
first["slot"] == 1
|
||
and first["card_id"] == 1
|
||
and first["card_name"] == "癒しのカード・小"
|
||
and first["base_weight"] == 25
|
||
and first["growth_interval_turns"] == 5
|
||
and first["growth_weight"] == 1
|
||
and first["source_addresses"]
|
||
== {
|
||
"card_id": "0x1525b3",
|
||
"base_weight": "0x152489",
|
||
"growth_interval_turns": "0x15248a",
|
||
"growth_weight": "0x15248b",
|
||
},
|
||
"CDINIT retains the parallel card-id and weight-table provenance",
|
||
)
|
||
gated = next(
|
||
entry
|
||
for entry in by_id[11]["entries"]
|
||
if entry["card_id"] == 14
|
||
)
|
||
check(
|
||
gated["card_name"] == "使い魔のカード"
|
||
and gated["required_story_flag_ids"] == [901]
|
||
and gated["forbidden_story_flag_ids"] == [861],
|
||
"CDINIT entries join CDINIT2 names and FIELD story-flag gates",
|
||
)
|
||
ignored_gate = next(
|
||
entry
|
||
for entry in by_id[41]["entries"]
|
||
if entry["card_id"] == 18
|
||
)
|
||
check(
|
||
meta["ignored_required_story_flag_definition_count"] == 18
|
||
and meta["ignored_required_story_flag_entry_count"] == 54
|
||
and ignored_gate["required_story_flag_ids"] == [901, 863]
|
||
and ignored_gate["ignored_required_story_flag_ids"] == [51],
|
||
"CDINIT distinguishes FIELD's two live required flags from column three",
|
||
)
|
||
check(
|
||
meta["used_selector_ids"] == [1, 11, 31, 41, 61, 71, 160]
|
||
and meta["unreferenced_selector_ids"] == [55, 94]
|
||
and meta["stage_definition_reference_count"] == 50
|
||
and meta["stage_object_reference_count"] == 246
|
||
and by_id[1]["stage_object_references"]
|
||
== [{"stage_id": 1, "object_slots": [8]}],
|
||
"CDINIT joins every used list back to STINIT type-28 stage objects",
|
||
)
|
||
check(
|
||
meta["runtime_scan_capacity"] == 100
|
||
and meta["cleared_entry_prefix"] == 50
|
||
and by_id[160]["entry_count"] == 75
|
||
and meta["fallback_comment"]
|
||
== "カード発生リストの設定が不足しています",
|
||
"CDINIT preserves its 100-slot scan, 50-slot clear, and fallback warning",
|
||
)
|
||
|
||
|
||
def test_card_definitions() -> None:
|
||
scripts = paths.scripts()
|
||
script = sys4load.load(scripts["CDINIT2.BIN"])
|
||
records, meta = extract_init.extract_card_definitions(script)
|
||
by_id = {record["id"]: record for record in records}
|
||
check(
|
||
len(records) == 81
|
||
and meta["reserved_record_count"] == 100
|
||
and meta["numeric_block_start"] == "0x1519f9"
|
||
and meta["numeric_block_end_exclusive"] == "0x152485",
|
||
"CDINIT2 exposes 81 cards in one contiguous reserved 100-row block",
|
||
)
|
||
check(
|
||
meta["string_write_count"] == 162
|
||
and meta["static_write_count"] == 395
|
||
and meta["classified_instruction_count"] == 558,
|
||
"CDINIT2 classifies every name, result, numeric, and exit instruction",
|
||
)
|
||
check(
|
||
meta["type_counts"]
|
||
== {
|
||
"item_award": 24,
|
||
"random_warp": 1,
|
||
"resource_recovery": 6,
|
||
"stage_clear_point_bonus": 3,
|
||
"story_event": 40,
|
||
"trap": 7,
|
||
},
|
||
"CDINIT2 partitions every card into its FIELD behavior type",
|
||
)
|
||
check(
|
||
by_id[1]["effects"]["resource_recovery"]["hp"]
|
||
== {"minimum": 2, "maximum_exclusive": 5}
|
||
and by_id[4]["effects"]["spirit_recovery"]
|
||
== {"minimum": 2, "maximum_exclusive": 5}
|
||
and by_id[7]["effects"]["stage_clear_spendable_point_bonus"]
|
||
== 5,
|
||
"CDINIT2 exposes recovery ranges and stage-clear point bonuses",
|
||
)
|
||
check(
|
||
by_id[10]["effects"]["event_story_flag_id"] == 490
|
||
and by_id[10]["effects"]["event_script_name"] == "SC0490.BIN"
|
||
and by_id[58]["effects"]["awarded_item_name"]
|
||
== "ブロンズコイン",
|
||
"CDINIT2 resolves event and item rewards through SCINIT and ITINIT",
|
||
)
|
||
check(
|
||
by_id[52]["effects"]["condition"] == "paralysis"
|
||
and by_id[52]["effects"]["condition_level"] == 1
|
||
and by_id[55]["effects"]["resource_damage"]["sp"]
|
||
== {"minimum": 10, "maximum_exclusive": 10}
|
||
and by_id[57]["effects"]["random_warp"],
|
||
"CDINIT2 decodes trap conditions, fixed damage, and random warp",
|
||
)
|
||
check(
|
||
by_id[18]["eligibility"]["required_story_flag_ids"]
|
||
== [901, 863]
|
||
and by_id[18]["eligibility"][
|
||
"ignored_required_story_flag_ids"
|
||
] == [51]
|
||
and meta["ignored_required_story_flag_count"] == 18,
|
||
"CDINIT2 separates FIELD's live gates from the engine-dead third flag",
|
||
)
|
||
check(
|
||
meta["awarded_item_join_count"] == 24
|
||
and meta["event_dispatch_join_count"] == 40
|
||
and meta["condition_join_count"] == 3
|
||
and meta["visual_asset_join_count"] == 81
|
||
and by_id[81]["visual_asset_name"] == "MVS107.AGF",
|
||
"CDINIT2 resolves every item, event, condition, and visual join",
|
||
)
|
||
|
||
|
||
def test_battle_effect_definitions() -> None:
|
||
scripts = paths.scripts()
|
||
script = sys4load.load(scripts["BTANINIT.BIN"])
|
||
check(
|
||
extract_init.detect_mode(script) == "numeric",
|
||
"BTANINIT remains compatible with numeric-mode auto-detection",
|
||
)
|
||
records, meta = extract_init.extract_battle_effect_definitions(
|
||
script
|
||
)
|
||
by_id = {record["id"]: record for record in records}
|
||
check(
|
||
len(records) == 202
|
||
and meta["runtime_work_slot_count"] == 6
|
||
and meta["hit_pulse_capacity_per_slot"] == 3
|
||
and meta["zero_effect_id_is_empty"],
|
||
"BTANINIT exposes 202 sparse effect ids and the six-slot work ABI",
|
||
)
|
||
check(
|
||
meta["definition_write_count"] == 1917
|
||
and meta["classified_instruction_count"] == 4472,
|
||
"BTANINIT classifies every clear, branch, work write, and exit",
|
||
)
|
||
check(
|
||
meta["visual_mode_counts"]
|
||
== {
|
||
"green_colorkey_sprite_sheet": 1,
|
||
"movie": 186,
|
||
"opaque_sprite_sheet": 15,
|
||
}
|
||
and meta["sprite_sheet_effect_count"] == 16,
|
||
"BTANINIT separates movie and sprite-sheet effect paths",
|
||
)
|
||
check(
|
||
by_id[1]["visual_asset_name"] == "MVB001.AGF"
|
||
and by_id[1]["sound_asset_name"] == "A0124.WAV"
|
||
and by_id[1]["additive_blend"]
|
||
and by_id[1]["anchor"] == "slot_combatant"
|
||
and by_id[1]["width"] == 280
|
||
and by_id[1]["height"] == 352,
|
||
"BTANINIT effect 1 joins its movie, sound, blend, and geometry",
|
||
)
|
||
check(
|
||
by_id[24]["visual_asset_name"] == "AM994.AGF"
|
||
and by_id[24]["visual_mode"]
|
||
== "green_colorkey_sprite_sheet"
|
||
and by_id[24]["atlas"]
|
||
== {
|
||
"columns": 4,
|
||
"authored_rows": 4,
|
||
"frame_count": 16,
|
||
"duration_ms": 800,
|
||
}
|
||
and by_id[131]["visual_mode"] == "opaque_sprite_sheet"
|
||
and by_id[131]["atlas"]["frame_count"] == 28,
|
||
"BTANINIT preserves sprite-atlas geometry and duration",
|
||
)
|
||
check(
|
||
by_id[915]["hit_pulse_offsets_ms"] == [100]
|
||
and by_id[915]["record_fields"]["0x155baa/3/0"] == 100
|
||
and meta["hit_pulse_effect_count"] == 26,
|
||
"BTANINIT exposes BTL's hit-pulse timing rows",
|
||
)
|
||
check(
|
||
meta["resolved_visual_asset_count"] == 202
|
||
and meta["sound_effect_count"] == 199
|
||
and meta["resolved_sound_asset_count"] == 199,
|
||
"BTANINIT resolves every populated AGF and WAV resource",
|
||
)
|
||
check(
|
||
meta["referenced_effect_definition_count"] == 186
|
||
and meta["unreferenced_effect_definition_ids"]
|
||
== [
|
||
24, 806, 807, 901, 902, 903, 904, 905,
|
||
906, 907, 910, 995, 996, 997, 998, 999,
|
||
],
|
||
"BTANINIT retains all sixteen unreferenced authored effects",
|
||
)
|
||
|
||
|
||
def test_battle_animations() -> None:
|
||
scripts = paths.scripts()
|
||
script = sys4load.load(scripts["BTANINIT2.BIN"])
|
||
check(
|
||
extract_init.detect_mode(script) == "numeric",
|
||
"BTANINIT2 remains compatible with numeric-mode auto-detection",
|
||
)
|
||
records, meta = extract_init.extract_battle_animations(script)
|
||
by_id = {record["id"]: record for record in records}
|
||
check(
|
||
len(records) == 122
|
||
and meta["reserved_record_count"] == 1000
|
||
and meta["effect_slots_per_record"] == 6,
|
||
"BTANINIT2 exposes 122 sparse rows in its reserved timeline registry",
|
||
)
|
||
check(
|
||
meta["static_write_count"] == 1017
|
||
and meta["classified_instruction_count"] == 1018
|
||
and meta["effect_reference_count"] == 573
|
||
and meta["distinct_effect_id_count"] == 186
|
||
and meta["resolved_effect_reference_count"] == 573,
|
||
"BTANINIT2 classifies every write and resolves every effect id",
|
||
)
|
||
check(
|
||
meta["effect_slot_populations"]
|
||
== {
|
||
"0": 114, "1": 114, "2": 113,
|
||
"3": 113, "4": 0, "5": 119,
|
||
}
|
||
and meta["delay_slot_populations"]
|
||
== {
|
||
"0": 0, "1": 0, "2": 111,
|
||
"3": 111, "4": 0, "5": 111,
|
||
},
|
||
"BTANINIT2 preserves its actor/target slot and delay geometry",
|
||
)
|
||
check(
|
||
by_id[1]["duration_ms"] == 670
|
||
and [
|
||
(slot["slot"], slot["effect_id"], slot["start_delay_ms"])
|
||
for slot in by_id[1]["effect_slots"]
|
||
]
|
||
== [
|
||
(0, 958, 0),
|
||
(1, 958, 0),
|
||
(2, 1, 200),
|
||
(3, 1, 200),
|
||
(5, 921, 470),
|
||
]
|
||
and by_id[1]["weapon_class_items"]
|
||
== [{"item_id": 901, "item_name": "素手"}],
|
||
"BTANINIT2 row 1 joins the unarmed attack timeline and item class",
|
||
)
|
||
check(
|
||
by_id[101]["skill_uses"]
|
||
== [{"skill_id": 101, "skill_name": "急所射撃"}]
|
||
and by_id[101]["effect_slots"][2]["effect"][
|
||
"visual_asset_name"
|
||
] == "MVB101.AGF"
|
||
and meta["skill_reference_count"] == 101
|
||
and meta["skill_animation_count"] == 98,
|
||
"BTANINIT2 joins all SKINIT animation references",
|
||
)
|
||
check(
|
||
by_id[801]["skill_uses"]
|
||
== [{"skill_id": 33, "skill_name": "見切り"}]
|
||
and by_id[801]["duration_ms"] == 0
|
||
and by_id[809]["uses"] == ["defeat"]
|
||
and by_id[809]["effect_slots"][0]["effect"][
|
||
"visual_asset_name"
|
||
] == "AM050.AGF"
|
||
and meta["complete_timeline_count"] == 111
|
||
and meta["auxiliary_timeline_count"] == 11,
|
||
"BTANINIT2 distinguishes full timelines and passive/defeat auxiliaries",
|
||
)
|
||
check(
|
||
meta["unjoined_authored_animation_ids"]
|
||
== [205, 221, 222, 224]
|
||
and by_id[205]["uses"] == ["unjoined_authored"],
|
||
"BTANINIT2 preserves four authored rows without shipped consumers",
|
||
)
|
||
|
||
|
||
def test_stage_definitions() -> None:
|
||
scripts = paths.scripts()
|
||
script = sys4load.load(scripts["STINIT2.BIN"])
|
||
check(
|
||
extract_init.detect_mode(script) == "name",
|
||
"STINIT2 remains compatible with name-mode auto-detection",
|
||
)
|
||
records, meta = extract_init.extract_stage_definitions(script)
|
||
by_id = {record["id"]: record for record in records}
|
||
check(
|
||
len(records) == 74
|
||
and meta["reserved_record_count"] == 1000
|
||
and meta["mapped_stage_count"] == 66
|
||
and meta["event_only_stage_count"] == 8,
|
||
"STINIT2 separates 74 stage rows into mapped and event-only records",
|
||
)
|
||
check(
|
||
meta["string_write_count"] == 370
|
||
and meta["description_line_count"] == 296
|
||
and meta["static_write_count"] == 1263
|
||
and meta["classified_instruction_count"] == 1634,
|
||
"STINIT2 classifies every name, description, numeric, and exit instruction",
|
||
)
|
||
check(
|
||
by_id[1]["descriptions"]["uncleared"]
|
||
== [
|
||
"庭園につながっている地下空洞。",
|
||
"浅い階層なので魔物も少ないようだ。",
|
||
"",
|
||
]
|
||
and by_id[1]["descriptions"]["cleared"]
|
||
== [
|
||
"庭園につながっている地下空洞。",
|
||
"浅い階層なので魔物も少ないようだ。",
|
||
"",
|
||
],
|
||
"STINIT2 preserves the six pre/post-clear description slots",
|
||
)
|
||
check(
|
||
by_id[1]["display_number"]
|
||
== {"kind": "numbered", "major": 0, "minor": 1}
|
||
and by_id[3]["display_number"]["kind"] == "event"
|
||
and by_id[160]["display_number"]["kind"] == "extra",
|
||
"STINIT2 distinguishes numbered, event-only, and EX display labels",
|
||
)
|
||
check(
|
||
by_id[3]["flow"]["entry_scjump_decision_id"] == 150
|
||
and by_id[3]["flow"]["entry_script_name"] == "SC0150.BIN"
|
||
and by_id[1]["flow"]["clear_script_name"] == "SC0010.BIN"
|
||
and by_id[1]["flow"]["failure_script_name"] == "SC0000.BIN"
|
||
and meta["resolved_scjump_reference_count"] == 174,
|
||
"STINIT2 resolves every entry, clear, and failure SCJUMP reference",
|
||
)
|
||
check(
|
||
by_id[1]["flow"]["stage_loader_script_name"] == "STINIT.BIN"
|
||
and meta["resolved_loader_script_count"] == 74,
|
||
"STINIT2 resolves every shared stage-loader reference",
|
||
)
|
||
check(
|
||
by_id[32]["availability"]["unlock_group_id"] == 32
|
||
and by_id[33]["availability"]["unlock_group_id"] == 32
|
||
and by_id[34]["availability"]["unlock_group_id"] == 32
|
||
and meta["main_progression_stage_count"] == 49
|
||
and meta["extra_dungeon_stage_count"] == 8,
|
||
"STINIT2 exposes shared unlock groups and progression/EX flags",
|
||
)
|
||
check(
|
||
by_id[3]["availability"]["required_story_flag_ids"]
|
||
== [151, 23, 1743, 1886]
|
||
and by_id[3]["availability"]["forbidden_story_flag_ids"]
|
||
== [21]
|
||
and meta["story_flag_gated_stage_count"] == 74,
|
||
"STINIT2 preserves all seven-column story eligibility rows",
|
||
)
|
||
check(
|
||
by_id[32]["map"]["tile_bounds"]
|
||
== {"min_x": 1, "max_x": 25, "min_y": 116, "max_y": 136}
|
||
and by_id[32]["map"]["grid_bounds"]
|
||
== {"min_x": 2, "max_x": 50, "min_y": 232, "max_y": 272}
|
||
and by_id[32]["map"]["minimap_atlas_origin_y"] == 66,
|
||
"STINIT2 joins tile, doubled-grid, and minimap atlas geometry",
|
||
)
|
||
check(
|
||
by_id[167]["clear_rewards"]["base_spendable_points"] == 60
|
||
and by_id[167]["clear_rewards"]["coins"]
|
||
== [
|
||
{
|
||
"item_id": 91,
|
||
"item_name": "ブロンズコイン",
|
||
"quantity": 3,
|
||
},
|
||
{
|
||
"item_id": 92,
|
||
"item_name": "シルバーコイン",
|
||
"quantity": 2,
|
||
},
|
||
{
|
||
"item_id": 93,
|
||
"item_name": "ゴールドコイン",
|
||
"quantity": 1,
|
||
},
|
||
]
|
||
and meta["clear_coin_reward_cell_count"] == 64,
|
||
"STINIT2 resolves the spendable-point and three coin reward columns",
|
||
)
|
||
check(
|
||
meta["authoring_difficulty_tier_population"] == 66
|
||
and meta["authoring_difficulty_tier_counts"]
|
||
== {
|
||
"1": 7,
|
||
"2": 11,
|
||
"3": 17,
|
||
"4": 5,
|
||
"5": 12,
|
||
"6": 9,
|
||
"7": 4,
|
||
"8": 1,
|
||
}
|
||
and by_id[167]["authoring_difficulty_tier"] == 8,
|
||
"STINIT2 preserves the inferred authoring-only difficulty tiers",
|
||
)
|
||
|
||
|
||
def test_condition_definitions() -> None:
|
||
scripts = paths.scripts()
|
||
script = sys4load.load(scripts["ILINIT.BIN"])
|
||
check(
|
||
extract_init.detect_mode(script) == "name",
|
||
"ILINIT remains compatible with name-mode auto-detection",
|
||
)
|
||
records, meta = extract_init.extract_condition_definitions(script)
|
||
by_id = {record["id"]: record for record in records}
|
||
check(
|
||
list(by_id) == [*range(1, 12), 13, 14],
|
||
"ILINIT extracts all thirteen authored condition ids",
|
||
)
|
||
check(
|
||
meta["record_span"] == 30
|
||
and meta["level_count"] == 5
|
||
and meta["reserved_condition_ids"] == [12, *range(15, 30)],
|
||
"ILINIT exposes its complete 30-by-5 reserved layout",
|
||
)
|
||
check(
|
||
meta["static_write_count"] == 228
|
||
and meta["classified_static_write_count"] == 228,
|
||
"ILINIT classifies every static integer write",
|
||
)
|
||
check(
|
||
by_id[1]["name"] == "即死"
|
||
and by_id[2]["name"] == "HP吸"
|
||
and by_id[14]["condition"] == "exaltation",
|
||
"condition records preserve authored names and canonical ids",
|
||
)
|
||
check(
|
||
by_id[2]["string_fields"]["0x25fa/5/0"] == "HP吸1"
|
||
and by_id[2]["string_fields"]["0x25fa/5/4"] == "HP吸5",
|
||
"condition level names retain raw base/stride/column provenance",
|
||
)
|
||
check(
|
||
[level["duration_turns"] for level in by_id[6]["levels"]]
|
||
== [2, 3, 4, 5, 6]
|
||
and by_id[6]["fields"]["0xaacb4"] == 1
|
||
and by_id[6]["fields"]["0xaacd2"] == 10,
|
||
"charm exposes level durations, RECOVER policy, and icon id",
|
||
)
|
||
check(
|
||
by_id[5]["levels"][4]["stat_deltas"]["accuracy"] == -25
|
||
and by_id[14]["levels"][4]["stat_deltas"]["physical_attack"] == 10
|
||
and by_id[14]["levels"][4]["stat_deltas"]["physical_defense"] == -10,
|
||
"curse and exaltation expose their five-level stat matrices",
|
||
)
|
||
check(
|
||
by_id[9]["levels"][4]["resource_deltas"]["hp"] == -5
|
||
and by_id[13]["levels"][4]["resource_deltas"]["hp"] == 5,
|
||
"poison and regeneration expose opposing periodic HP deltas",
|
||
)
|
||
|
||
recovery = meta["recovery_protocol"]
|
||
check(
|
||
recovery["resource_restore"]["source_columns"]
|
||
== ["max_hp", "max_sp", "max_fs"]
|
||
and recovery["resource_restore"]["destination_columns"]
|
||
== ["current_hp", "current_sp", "current_fs"],
|
||
"RECOVER joins max-stat columns to current HP/SP/FS",
|
||
)
|
||
check(
|
||
recovery["condition_reset"]["current_level_table"] == "0x52383"
|
||
and recovery["condition_reset"]["baseline_level_table"] == "0x52f3b"
|
||
and recovery["condition_reset"]["remaining_turns_table"] == "0x5295f"
|
||
and recovery["condition_reset"]["recovery_policy_table"] == "0xaacb4",
|
||
"RECOVER publishes the four-table condition reset protocol",
|
||
)
|
||
|
||
semantics = extract_init.field_semantics(records)
|
||
check(
|
||
semantics["0xaacf0/5/4"]
|
||
== "condition_duration_turns_by_level.level_5_turns"
|
||
and semantics["0x25fa/5/4"] == "condition_level_names.level_5"
|
||
and semantics["0xaad86/55/46"]
|
||
== "condition_stat_deltas.level_5_physical_attack"
|
||
and semantics["0xab3f8/15/12"]
|
||
== "condition_resource_deltas.level_5_hp",
|
||
"ILINIT raw cells join to level-aware condition semantics",
|
||
)
|
||
extract_init.attach_semantic_fields(records, semantics)
|
||
check(
|
||
by_id[6]["semantic_fields"]["condition_cleared_by_recover"] == 1
|
||
and by_id[14]["semantic_fields"][
|
||
"condition_stat_deltas.level_5_physical_attack"
|
||
] == 10,
|
||
"condition definitions retain raw addresses beside the semantic view",
|
||
)
|
||
|
||
|
||
def test_field_semantics() -> None:
|
||
scripts = paths.scripts()
|
||
items, _ = extract_init.extract_name(sys4load.load(scripts["ITINIT.BIN"]))
|
||
semantics = extract_init.field_semantics(items)
|
||
check(semantics["0x8c879"] == "item_sort_key",
|
||
"parallel INIT fields expose canonical semantic names")
|
||
check(semantics["0x9f541/14/8"] == "item_stat_modifiers.critical_chance",
|
||
"row-table columns expose canonical semantic names")
|
||
extract_init.attach_semantic_fields(items, semantics)
|
||
check(items[0]["semantic_fields"]["item_sort_key"] == 10,
|
||
"records expose a joined semantic field view")
|
||
|
||
units, _ = extract_init.extract_name(sys4load.load(scripts["EBINIT.BIN"]))
|
||
unit_semantics = extract_init.field_semantics(units)
|
||
check(
|
||
unit_semantics["0x73236/4/0"]
|
||
== "unit_sally_action_unlock_requirements.contract",
|
||
"SALLY unlock columns expose their action semantics",
|
||
)
|
||
check(
|
||
unit_semantics["0x741d6/8/6"] == "unit_sally_event_ids.sacrifice",
|
||
"SALLY event columns expose their dispatched action semantics",
|
||
)
|
||
check(
|
||
unit_semantics["0x741d6/8/2"] == "unit_sally_event_ids.reserved_action",
|
||
"the unreachable SALLY action remains explicit rather than speculative",
|
||
)
|
||
check(
|
||
unit_semantics["0x66716/30/19"] == "unit_voice_asset_ids.damage_reaction_1",
|
||
"BTL target-owned damage voice columns expose their semantics",
|
||
)
|
||
check(
|
||
unit_semantics["0x66716/30/23"] == "unit_voice_asset_ids.finishing_blow",
|
||
"BTL actor-owned finishing-blow voice exposes its semantics",
|
||
)
|
||
check(
|
||
unit_semantics["0x66716/30/17"] == "unit_voice_asset_ids.unused_slot_17",
|
||
"populated but unreachable voice slots remain explicit",
|
||
)
|
||
check(
|
||
unit_semantics["0x7843e"] == "unit_power_tier",
|
||
"EBINIT's authoring-only power tier joins by semantic name",
|
||
)
|
||
check(
|
||
unit_semantics["0x72296"] == "unit_boss_class",
|
||
"EBINIT's signed boss class joins by semantic name",
|
||
)
|
||
extract_init.attach_semantic_fields(units, unit_semantics)
|
||
unit_by_id = {record["id"]: record for record in units}
|
||
check(
|
||
unit_by_id[5]["semantic_fields"]["unit_sally_event_ids.release"] == 1360,
|
||
"EBINIT records join SALLY release events by semantic field name",
|
||
)
|
||
check(
|
||
unit_by_id[5]["semantic_fields"]["unit_voice_asset_ids.defeated"] == 11523,
|
||
"EBINIT records join voice assets by semantic field name",
|
||
)
|
||
check(
|
||
[
|
||
unit_by_id[unit_id]["semantic_fields"]["unit_power_tier"]
|
||
for unit_id in (2, 3, 4)
|
||
] == [2, 4, 6],
|
||
"Lily's three forms preserve the correlated 2/4/6 power tiers",
|
||
)
|
||
check(
|
||
[
|
||
unit_by_id[unit_id]["semantic_fields"]["unit_boss_class"]
|
||
for unit_id in (110, 115, 755, 756)
|
||
] == [1, -1, 4, -4],
|
||
"paired story and final-boss records preserve victory-target sign",
|
||
)
|
||
check(
|
||
unit_by_id[456]["semantic_fields"]["unit_battle_sprite_asset_id"] == 12585
|
||
and unit_by_id[600]["semantic_fields"]["unit_starting_level"] == 80,
|
||
"overlapping EBINIT writes retain their established parallel semantics",
|
||
)
|
||
|
||
stages, meta = extract_init.extract_mixed(
|
||
sys4load.load(extract_init.resolve("STINIT"))
|
||
)
|
||
stage_semantics = extract_init.field_semantics(
|
||
stages, meta["array_layouts"]
|
||
)
|
||
check(stage_semantics["0xe7325/1"] == "stage_object_tile_x.index_1",
|
||
"mixed buffer cells expose canonical semantic names")
|
||
check(
|
||
stage_semantics["0xe74b5/21"]
|
||
== "stage_object_required_story_flags.row_3.required_flag_1",
|
||
"mixed row buffers expose row and column semantics",
|
||
)
|
||
check(
|
||
stage_semantics["0xe7889/3"]
|
||
== "stage_enemy_movement_routine_set_ids.row_1",
|
||
"enemy footer copies expose whole-row semantics",
|
||
)
|
||
extract_init.attach_semantic_fields(stages, stage_semantics)
|
||
check(
|
||
stages[0]["semantic_fields"][
|
||
"stage_enemy_movement_routine_set_ids.row_1"
|
||
] == [1, 1, 1],
|
||
"semantic footer fields expose row values without provenance wrappers",
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
test_real_name_tables()
|
||
test_append_ebinit_fragment()
|
||
test_character_profiles()
|
||
test_magic_actions()
|
||
test_static_negative_write()
|
||
test_output_name_validation()
|
||
test_real_mixed_table()
|
||
test_real_class_change_rules()
|
||
test_real_scene_dispatch()
|
||
test_real_routine_banks()
|
||
test_real_message_tables()
|
||
test_message_infrastructure()
|
||
test_message_join()
|
||
test_character_names()
|
||
test_gallery_definitions()
|
||
test_alchemy_recipes()
|
||
test_affinity_definitions()
|
||
test_name_entry_palette()
|
||
test_voice_configuration()
|
||
test_terrain_definitions()
|
||
test_h_scene_gallery()
|
||
test_training_actions()
|
||
test_card_generation_lists()
|
||
test_card_definitions()
|
||
test_battle_effect_definitions()
|
||
test_battle_animations()
|
||
test_stage_definitions()
|
||
test_map_terrain_atlas()
|
||
test_condition_definitions()
|
||
test_field_semantics()
|
||
if FAILS:
|
||
raise SystemExit(f"{len(FAILS)} failed checks")
|
||
print("all extract_init checks passed")
|