Decode MAINIT and message infrastructure
This commit is contained in:
@@ -496,6 +496,7 @@ MESSAGE_TABLES = {
|
||||
"CIINIT": "CIMES",
|
||||
"EBINIT": "EIMES",
|
||||
"ITINIT": "ITMES",
|
||||
"MAINIT": "MAMES",
|
||||
"SKINIT": "SKMES",
|
||||
"VIINIT": "VIMES",
|
||||
}
|
||||
@@ -507,6 +508,22 @@ CHARACTER_PROFILE_PORTRAIT_X_ARRAY_BASE = 0x15A1E0
|
||||
CHARACTER_PROFILE_PORTRAIT_Y_ARRAY_BASE = 0x15A244
|
||||
CHARACTER_PROFILE_RECORD_SPAN = 100
|
||||
|
||||
MAGIC_ACTION_NAME_ARRAY_BASE = 0x45B9
|
||||
MAGIC_ACTION_INTEGER_ARRAY_BASES = (
|
||||
0x1560E8,
|
||||
0x156106,
|
||||
0x156124,
|
||||
0x156142,
|
||||
0x156160,
|
||||
0x15617E,
|
||||
0x15619C,
|
||||
0x1561BA,
|
||||
0x1561D8,
|
||||
0x1561F6,
|
||||
)
|
||||
MAGIC_ACTION_HANDLER_ARRAY_BASE = 0x1561F6
|
||||
MAGIC_ACTION_RECORD_SPAN = 30
|
||||
|
||||
VOCABULARY_NAME_ARRAY_BASE = 0x463B
|
||||
VOCABULARY_RECORD_TABLE_BASE = 0x15A2A9
|
||||
VOCABULARY_RECORD_STRIDE = 3
|
||||
@@ -1260,6 +1277,76 @@ def extract_character_profiles(scr):
|
||||
}
|
||||
|
||||
|
||||
def extract_magic_actions(scr):
|
||||
"""Extract MAINIT's action-id keyed magic/research/growth registry.
|
||||
|
||||
MAINIT has only one consecutive string column, so the generic name-table
|
||||
span heuristic cannot see its reserved 30-cell stride. Its ten integer
|
||||
columns are equally spaced consumers of the same action id.
|
||||
"""
|
||||
records = []
|
||||
by_id = {}
|
||||
for ins in scr.instructions:
|
||||
if (
|
||||
ins.opcode != SET_STRING
|
||||
or len(ins.args) < 2
|
||||
or ins.args[0][0] != T_GLOBAL_STRING
|
||||
):
|
||||
continue
|
||||
record_id = ins.args[0][1] - MAGIC_ACTION_NAME_ARRAY_BASE
|
||||
if not (1 <= record_id < MAGIC_ACTION_RECORD_SPAN):
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: magic-action name outside reserved id span: "
|
||||
f"0x{ins.args[0][1]:x}"
|
||||
)
|
||||
text = scr.strings.get(ins.args[1][1], (None,))[0]
|
||||
record = {"id": record_id, "name": text, "fields": {}}
|
||||
records.append(record)
|
||||
by_id[record_id] = record
|
||||
|
||||
for ins in scr.instructions:
|
||||
write = _static_global_write(ins)
|
||||
if write is None:
|
||||
continue
|
||||
destination, value = write
|
||||
matched = False
|
||||
for base in MAGIC_ACTION_INTEGER_ARRAY_BASES:
|
||||
record_id = destination - base
|
||||
if 0 <= record_id < MAGIC_ACTION_RECORD_SPAN:
|
||||
if record_id not in by_id:
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: integer write for unnamed magic "
|
||||
f"action id {record_id}"
|
||||
)
|
||||
_store_unique(
|
||||
by_id[record_id]["fields"],
|
||||
f"0x{base:x}",
|
||||
value,
|
||||
record_id,
|
||||
)
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: unexpected integer write 0x{destination:x}"
|
||||
)
|
||||
|
||||
return records, {
|
||||
"schema": "magic-actions",
|
||||
"name_array_base": f"0x{MAGIC_ACTION_NAME_ARRAY_BASE:x}",
|
||||
"name_write_base": f"0x{MAGIC_ACTION_NAME_ARRAY_BASE + 1:x}",
|
||||
"first_record_id": 1,
|
||||
"record_span": MAGIC_ACTION_RECORD_SPAN,
|
||||
"integer_array_bases": [
|
||||
f"0x{base:x}" for base in MAGIC_ACTION_INTEGER_ARRAY_BASES
|
||||
],
|
||||
"handler_script_array_base": (
|
||||
f"0x{MAGIC_ACTION_HANDLER_ARRAY_BASE:x}"
|
||||
),
|
||||
"implicit_default": 0,
|
||||
}
|
||||
|
||||
|
||||
@cache
|
||||
def object_type_definitions() -> dict[int, dict]:
|
||||
"""Load OBINIT's authoritative display and state-row metadata by object type id."""
|
||||
@@ -1999,7 +2086,7 @@ def write_data_index(data_dir: Path) -> None:
|
||||
"Linked row-major fields are stored separately in `record_fields`, keyed as",
|
||||
"`base/stride/column` from corpus-observed `lookup-array-2d` consumers.",
|
||||
"Where a matching `*MES` dispatcher exists, `message` preserves its player-facing",
|
||||
"layout-specific text (title/description, summary/strategy, or biography), furigana,",
|
||||
"layout-specific text (title/description, summary/strategy, biography, or description-only), furigana,",
|
||||
"and bytecode dispatch offset separately from the",
|
||||
"short description stored by the INIT script.",
|
||||
"Top-level `field_semantics` maps raw array/row-column keys to canonical machine-readable",
|
||||
@@ -2076,6 +2163,8 @@ def main() -> int:
|
||||
extractor = extract_vocabulary
|
||||
elif mode == "name" and name == "CIINIT":
|
||||
extractor = extract_character_profiles
|
||||
elif mode == "name" and name == "MAINIT":
|
||||
extractor = extract_magic_actions
|
||||
recs, meta = extractor(scr)
|
||||
if mode == "name" and name in MESSAGE_TABLES:
|
||||
message_name = MESSAGE_TABLES[name]
|
||||
|
||||
@@ -19,6 +19,7 @@ Usage:
|
||||
py -3.11 -X utf8 tools/extract_message_table.py VIMES
|
||||
py -3.11 -X utf8 tools/extract_message_table.py EIMES
|
||||
py -3.11 -X utf8 tools/extract_message_table.py CIMES
|
||||
py -3.11 -X utf8 tools/extract_message_table.py MAMES
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -41,6 +42,7 @@ BRANCH_SENTINEL = 0xFFFFFFFF
|
||||
MESSAGE_LAYOUTS = {
|
||||
"CIMES.BIN": "character-biography",
|
||||
"EIMES.BIN": "enemy-commentary",
|
||||
"MAMES.BIN": "description",
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +119,10 @@ def _message_body(
|
||||
message = {
|
||||
"biography": "\n".join(lines),
|
||||
}
|
||||
elif layout == "description":
|
||||
message = {
|
||||
"description": "\n".join(lines),
|
||||
}
|
||||
elif layout == "enemy-commentary":
|
||||
message = {
|
||||
"summary": lines[0],
|
||||
|
||||
@@ -136,6 +136,39 @@ def test_character_profiles() -> None:
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
@@ -613,6 +646,7 @@ def test_real_message_tables() -> None:
|
||||
"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(
|
||||
@@ -685,6 +719,106 @@ def test_real_message_tables() -> None:
|
||||
"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()
|
||||
@@ -757,6 +891,25 @@ def test_message_join() -> None:
|
||||
"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_field_semantics() -> None:
|
||||
scripts = paths.scripts()
|
||||
@@ -865,6 +1018,7 @@ def test_field_semantics() -> None:
|
||||
if __name__ == "__main__":
|
||||
test_real_name_tables()
|
||||
test_character_profiles()
|
||||
test_magic_actions()
|
||||
test_static_negative_write()
|
||||
test_output_name_validation()
|
||||
test_real_mixed_table()
|
||||
@@ -872,6 +1026,7 @@ if __name__ == "__main__":
|
||||
test_real_scene_dispatch()
|
||||
test_real_routine_banks()
|
||||
test_real_message_tables()
|
||||
test_message_infrastructure()
|
||||
test_message_join()
|
||||
test_field_semantics()
|
||||
if FAILS:
|
||||
|
||||
@@ -73,6 +73,22 @@ def test_load_and_lint():
|
||||
== "character_profile_portrait_asset_ids"
|
||||
and entries[0x45d7]["name"] == "character_profile_names",
|
||||
"CIINIT/CIMES character-profile state is curated")
|
||||
check(entries[0x1560e7]["name"] == "current_magic_action_id"
|
||||
and entries[0x45b9]["name"] == "magic_action_names"
|
||||
and entries[0x1561f6]["name"]
|
||||
== "magic_action_information_handler_script_ids",
|
||||
"MAINIT/MAMES action state is curated")
|
||||
check(entries[0x15a095]["name"] == "information_tab_index"
|
||||
and entries[0x15a096]["name"] == "information_message_handled"
|
||||
and entries[0x15a097]["name"]
|
||||
== "information_message_handler_script_ids",
|
||||
"INFOMES handler registry state is curated")
|
||||
check(entries[0x7db]["name"] == "modal_message_lines"
|
||||
and entries[0x665d6]["name"] == "modal_message_line_count"
|
||||
and entries[0x7e5]["name"] == "modal_annotation_texts"
|
||||
and entries[0x665e3]["name"]
|
||||
== "modal_annotation_horizontal_cells",
|
||||
"MES modal-buffer state is curated")
|
||||
check(entries[0x5f0ed]["name"] == "scene_decision_seen_flags",
|
||||
"glossary prerequisite seen-state is curated")
|
||||
check(entries[0x20543]["name"] == "tile_faction_traversal_masks",
|
||||
|
||||
Reference in New Issue
Block a user