Join CIINIT and CIMES character profiles
This commit is contained in:
@@ -493,12 +493,20 @@ UNIT_STAT_COLUMNS = (
|
||||
)
|
||||
|
||||
MESSAGE_TABLES = {
|
||||
"CIINIT": "CIMES",
|
||||
"EBINIT": "EIMES",
|
||||
"ITINIT": "ITMES",
|
||||
"SKINIT": "SKMES",
|
||||
"VIINIT": "VIMES",
|
||||
}
|
||||
|
||||
CHARACTER_PROFILE_NAME_ARRAY_BASE = 0x45D7
|
||||
CHARACTER_PROFILE_UNIT_ARRAY_BASE = 0x15A118
|
||||
CHARACTER_PROFILE_PORTRAIT_ARRAY_BASE = 0x15A17C
|
||||
CHARACTER_PROFILE_PORTRAIT_X_ARRAY_BASE = 0x15A1E0
|
||||
CHARACTER_PROFILE_PORTRAIT_Y_ARRAY_BASE = 0x15A244
|
||||
CHARACTER_PROFILE_RECORD_SPAN = 100
|
||||
|
||||
VOCABULARY_NAME_ARRAY_BASE = 0x463B
|
||||
VOCABULARY_RECORD_TABLE_BASE = 0x15A2A9
|
||||
VOCABULARY_RECORD_STRIDE = 3
|
||||
@@ -1174,6 +1182,84 @@ def extract_vocabulary(scr):
|
||||
}
|
||||
|
||||
|
||||
def extract_character_profiles(scr):
|
||||
"""Extract CIINIT's profile-id keyed character-information registry."""
|
||||
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] - CHARACTER_PROFILE_NAME_ARRAY_BASE
|
||||
if not (1 <= record_id < CHARACTER_PROFILE_RECORD_SPAN):
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: character 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
|
||||
|
||||
integer_arrays = (
|
||||
CHARACTER_PROFILE_UNIT_ARRAY_BASE,
|
||||
CHARACTER_PROFILE_PORTRAIT_ARRAY_BASE,
|
||||
CHARACTER_PROFILE_PORTRAIT_X_ARRAY_BASE,
|
||||
CHARACTER_PROFILE_PORTRAIT_Y_ARRAY_BASE,
|
||||
)
|
||||
for ins in scr.instructions:
|
||||
write = _static_global_write(ins)
|
||||
if write is None:
|
||||
continue
|
||||
destination, value = write
|
||||
matched = False
|
||||
for base in integer_arrays:
|
||||
relative = destination - base
|
||||
if 0 <= relative < CHARACTER_PROFILE_RECORD_SPAN:
|
||||
if relative not in by_id:
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: integer write for unnamed character "
|
||||
f"profile id {relative}"
|
||||
)
|
||||
_store_unique(
|
||||
by_id[relative]["fields"],
|
||||
f"0x{base:x}",
|
||||
value,
|
||||
relative,
|
||||
)
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: unexpected integer write 0x{destination:x}"
|
||||
)
|
||||
|
||||
return records, {
|
||||
"schema": "character-information-profiles",
|
||||
"name_array_base": f"0x{CHARACTER_PROFILE_NAME_ARRAY_BASE:x}",
|
||||
"name_write_base": f"0x{CHARACTER_PROFILE_NAME_ARRAY_BASE + 1:x}",
|
||||
"first_record_id": 1,
|
||||
"record_span": CHARACTER_PROFILE_RECORD_SPAN,
|
||||
"unit_id_array_base": f"0x{CHARACTER_PROFILE_UNIT_ARRAY_BASE:x}",
|
||||
"portrait_asset_array_base": (
|
||||
f"0x{CHARACTER_PROFILE_PORTRAIT_ARRAY_BASE:x}"
|
||||
),
|
||||
"portrait_x_offset_array_base": (
|
||||
f"0x{CHARACTER_PROFILE_PORTRAIT_X_ARRAY_BASE:x}"
|
||||
),
|
||||
"portrait_y_offset_array_base": (
|
||||
f"0x{CHARACTER_PROFILE_PORTRAIT_Y_ARRAY_BASE:x}"
|
||||
),
|
||||
"implicit_defaults": {
|
||||
f"0x{CHARACTER_PROFILE_PORTRAIT_X_ARRAY_BASE:x}": 0,
|
||||
f"0x{CHARACTER_PROFILE_PORTRAIT_Y_ARRAY_BASE:x}": 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@cache
|
||||
def object_type_definitions() -> dict[int, dict]:
|
||||
"""Load OBINIT's authoritative display and state-row metadata by object type id."""
|
||||
@@ -1913,7 +1999,8 @@ 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",
|
||||
"title, description, furigana, and bytecode dispatch offset separately from the",
|
||||
"layout-specific text (title/description, summary/strategy, or biography), 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",
|
||||
"names from `vm-map/globals.toml`; each record's generated `semantic_fields` is the joined",
|
||||
@@ -1987,6 +2074,8 @@ def main() -> int:
|
||||
}[mode]
|
||||
if mode == "name" and name == "VIINIT":
|
||||
extractor = extract_vocabulary
|
||||
elif mode == "name" and name == "CIINIT":
|
||||
extractor = extract_character_profiles
|
||||
recs, meta = extractor(scr)
|
||||
if mode == "name" and name in MESSAGE_TABLES:
|
||||
message_name = MESSAGE_TABLES[name]
|
||||
|
||||
@@ -18,6 +18,7 @@ Usage:
|
||||
py -3.11 -X utf8 tools/extract_message_table.py SKMES [OUTNAME]
|
||||
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
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -38,6 +39,7 @@ T_GLOBAL_INT = 3
|
||||
BRANCH_SENTINEL = 0xFFFFFFFF
|
||||
|
||||
MESSAGE_LAYOUTS = {
|
||||
"CIMES.BIN": "character-biography",
|
||||
"EIMES.BIN": "enemy-commentary",
|
||||
}
|
||||
|
||||
@@ -111,7 +113,11 @@ def _message_body(
|
||||
lines.append("".join(fragments))
|
||||
if not lines:
|
||||
return None
|
||||
if layout == "enemy-commentary":
|
||||
if layout == "character-biography":
|
||||
message = {
|
||||
"biography": "\n".join(lines),
|
||||
}
|
||||
elif layout == "enemy-commentary":
|
||||
message = {
|
||||
"summary": lines[0],
|
||||
"strategy": "\n".join(lines[1:]),
|
||||
|
||||
@@ -36,7 +36,9 @@ def message_heading_body(message: dict) -> tuple[str, str]:
|
||||
"""Return presentation-neutral heading/body text for supported MES layouts."""
|
||||
return (
|
||||
message.get("title", message.get("summary", "")),
|
||||
message.get("description", message.get("strategy", "")),
|
||||
message.get(
|
||||
"description", message.get("strategy", message.get("biography", ""))
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -283,7 +285,9 @@ def profile_messages(data: dict) -> dict:
|
||||
"description": body,
|
||||
"message_fields": {
|
||||
key: record["message"][key]
|
||||
for key in ("title", "description", "summary", "strategy")
|
||||
for key in (
|
||||
"title", "description", "summary", "strategy", "biography"
|
||||
)
|
||||
if key in record["message"]
|
||||
},
|
||||
})
|
||||
@@ -307,6 +311,7 @@ def find_message_matches(data: dict, pattern: str) -> list[dict]:
|
||||
record.get("message", {}).get("description", ""),
|
||||
record.get("message", {}).get("summary", ""),
|
||||
record.get("message", {}).get("strategy", ""),
|
||||
record.get("message", {}).get("biography", ""),
|
||||
]))
|
||||
]
|
||||
|
||||
|
||||
@@ -105,6 +105,37 @@ def test_real_name_tables() -> None:
|
||||
)
|
||||
|
||||
|
||||
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_static_negative_write() -> None:
|
||||
class Instruction:
|
||||
opcode = extract_init.SUB
|
||||
@@ -581,6 +612,7 @@ def test_real_message_tables() -> None:
|
||||
"SKMES.BIN": (0xA6E59, 131, "fallthrough"),
|
||||
"VIMES.BIN": (0x15A2A8, 65, "branch-target"),
|
||||
"EIMES.BIN": (0x15A759, 192, "branch-target"),
|
||||
"CIMES.BIN": (0x15A117, 24, "branch-target"),
|
||||
}
|
||||
for name, (selector, count, dispatch_layout) in expected.items():
|
||||
records, meta = extract_message_table.extract_messages(
|
||||
@@ -639,6 +671,20 @@ def test_real_message_tables() -> None:
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
def test_message_join() -> None:
|
||||
scripts = paths.scripts()
|
||||
@@ -691,6 +737,26 @@ def test_message_join() -> None:
|
||||
"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",
|
||||
)
|
||||
|
||||
|
||||
def test_field_semantics() -> None:
|
||||
scripts = paths.scripts()
|
||||
@@ -798,6 +864,7 @@ def test_field_semantics() -> None:
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_real_name_tables()
|
||||
test_character_profiles()
|
||||
test_static_negative_write()
|
||||
test_output_name_validation()
|
||||
test_real_mixed_table()
|
||||
|
||||
@@ -67,6 +67,12 @@ def test_load_and_lint():
|
||||
and entries[0x56b85]["name"]
|
||||
== "enemy_encyclopedia_revealed_flags",
|
||||
"EBINIT/EIMES enemy-encyclopedia state is curated")
|
||||
check(entries[0x15a117]["name"] == "current_character_profile_id"
|
||||
and entries[0x15a118]["name"] == "character_profile_unit_ids"
|
||||
and entries[0x15a17c]["name"]
|
||||
== "character_profile_portrait_asset_ids"
|
||||
and entries[0x45d7]["name"] == "character_profile_names",
|
||||
"CIINIT/CIMES character-profile 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",
|
||||
|
||||
@@ -200,6 +200,29 @@ def main() -> int:
|
||||
"strategy": "Avoid the first encounter",
|
||||
}
|
||||
assert profile.find_message_matches(enemy_fixture, "first encounter")
|
||||
|
||||
character_fixture = {
|
||||
"table": "CHARACTER",
|
||||
"records": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Emilio",
|
||||
"message": {
|
||||
"biography": "Former demon king\nNow a familiar",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
character_messages = profile.profile_messages(character_fixture)
|
||||
assert character_messages["examples"][0]["title"] == ""
|
||||
assert (
|
||||
character_messages["examples"][0]["description"]
|
||||
== "Former demon king\nNow a familiar"
|
||||
)
|
||||
assert character_messages["examples"][0]["message_fields"] == {
|
||||
"biography": "Former demon king\nNow a familiar",
|
||||
}
|
||||
assert profile.find_message_matches(character_fixture, "familiar")
|
||||
print("all init_table_profile checks passed")
|
||||
return 0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user