667 lines
31 KiB
Python
667 lines
31 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")
|
||
|
||
|
||
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"] == 8
|
||
and meta["decoded_movement_step_count"] == 508
|
||
and meta["decoded_movement_parameter_count"] == 971
|
||
and meta["decoded_movement_defaulted_parameter_count"] == 10,
|
||
"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")
|
||
check("provider_behavior" not in movement
|
||
and "destination_tile_x" not in movement,
|
||
"RTINIT does not leak provider-specific meanings onto undecoded selectors")
|
||
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),
|
||
"SKMES.BIN": (0xA6E59, 131),
|
||
}
|
||
for name, (selector, count) 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")
|
||
|
||
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")
|
||
|
||
|
||
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")
|
||
|
||
|
||
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_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_join()
|
||
test_field_semantics()
|
||
if FAILS:
|
||
raise SystemExit(f"{len(FAILS)} failed checks")
|
||
print("all extract_init checks passed")
|