182 lines
7.7 KiB
Python
182 lines
7.7 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),
|
||
}
|
||
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")
|
||
|
||
|
||
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_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")
|
||
|
||
|
||
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")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
test_real_name_tables()
|
||
test_static_negative_write()
|
||
test_real_mixed_table()
|
||
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")
|