Join CIINIT and CIMES character profiles

This commit is contained in:
gamer147
2026-07-23 17:57:21 -04:00
parent e84f61d670
commit 29aa9a4da5
13 changed files with 318 additions and 39 deletions

View File

@@ -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]

View File

@@ -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:]),

View File

@@ -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", ""),
]))
]

View File

@@ -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()

View File

@@ -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",

View File

@@ -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