diff --git a/docs/PROJECT-STRUCTURE.md b/docs/PROJECT-STRUCTURE.md
index 5a4adb6..96591e3 100644
--- a/docs/PROJECT-STRUCTURE.md
+++ b/docs/PROJECT-STRUCTURE.md
@@ -43,7 +43,9 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings)
│ ├── test_globals.py, test_opcodes.py unit tests for the globals / opcode tooling
│ ├── vm0.py headless Python VM (Phase A0); `--test` = RECOVER unit test
│ ├── extract_phase2.py batch: disasm + text + data extraction
- │ ├── extract_init.py, global_map.py … *INIT parsers, global-var map builder
+ │ ├── extract_init.py, extract_message_table.py, global_map.py …
+ │ │ *INIT / ID-dispatched message parsers,
+ │ │ global-var map builder
│ ├── validate_opcode_table*.py decode-coverage validators
│ ├── movie-corpus-gate/ C# full-corpus FFmpeg decode/lifecycle acceptance tool
│ └── probe_*.py format reverse-engineering probes (historical)
diff --git a/docs/global-reference.md b/docs/global-reference.md
index 05791da..8c2add3 100644
--- a/docs/global-reference.md
+++ b/docs/global-reference.md
@@ -1,7 +1,7 @@
# Global Variable Reference (generated)
-5023 globals (144 curated, 4879 auto shape-inferred). Source of truth: `vm-map/globals.toml`.
+5023 globals (145 curated, 4878 auto shape-inferred). Source of truth: `vm-map/globals.toml`.
## choice-output
@@ -353,6 +353,8 @@
| address | name | conf | source | usage |
|---|---|---|---|---|
| `0x6718` | selected_party_slot | high | investigation | Current/selected slot in the 100-entry party-unit arrays. UNITECH chooses a free slot here before populating it; CHMENU replaces it with the selected sorted roster slot, then uses it to index party_slot_flags, party_slot_character_id, and companion per-slot tables. A natural New Game enters SC0000 with slot 2 selected. |
+| `0x8c877` | current_item_id | high | investigation | Shared item-id argument/selection slot. Item menus and gameplay scripts write a chosen item id, use it to index ITINIT arrays, and dispatch through item_handler_script_id; ITMES compares it against all 287 item ids to select the matching player-facing title and description. |
+| `0xa6e59` | current_skill_id | high | investigation | Shared skill-id argument/selection slot. Skill menus and combat scripts write the chosen skill id and use it to index SKINIT arrays; SKMES compares it against all 131 skill ids to select the matching player-facing title and description. |
| `0xeff75` | current_entity_index_hi | med | inference | High-purity current-entity row index (purity 0.95 in the auto shape map); dominant 2D-table row selector. |
| `0x152616` | current_entity_index | med | investigation | Primary current-entity row index (RECOVER-confirmed; purity 0.51, 363 row-index uses). |
@@ -394,7 +396,6 @@
| `0x665d6` | — | med | auto-shape | TODO: confirm. Branch-read in 0 scenes / 3 scripts; compared against []; writers=['MES.BIN', 'SBUNKI.BIN']. |
| `0x665d7` | — | med | auto-shape | TODO: confirm. Branch-read in 0 scenes / 5 scripts; compared against [1, 3]; writers=['ADDSKILL.BIN', 'SBUNKI.BIN']. |
| `0x66714` | — | med | auto-shape | TODO: confirm. Branch-read in 0 scenes / 3 scripts; compared against []; writers=['DEBUGBTL.BIN', 'FIELD.BIN', 'RTN_M051.BIN', 'RTN_M052.BIN']. |
-| `0xa6e59` | — | med | auto-shape | TODO: confirm. Branch-read in 0 scenes / 5 scripts; compared against [1, 2, 3, 4, 11, 21, 22, 23]; writers=['CALCDMG.BIN', 'CHMENU.BIN', 'FORT.BIN', 'INFOIT.BIN']. |
| `0xab8e7` | — | med | auto-shape | TODO: confirm. Branch-read in 0 scenes / 3 scripts; compared against [0, 10, 15, 20, 25, 30, 35, 40]; writers=['CALCCC.BIN', 'CCINIT.BIN']. |
| `0xaba5c` | — | med | auto-shape | TODO: confirm. Branch-read in 136 scenes / 149 scripts; compared against [0, 1]; writers=['DEBUGADV.BIN', 'DEBUGADV2.BIN', 'SC0000.BIN', 'SC0010.BIN']. |
| `0xaba5e` | — | med | auto-shape | TODO: confirm. Branch-read in 0 scenes / 5 scripts; compared against [2]; writers=['DEBUGMAP.BIN', 'DEBUGMAP2.BIN', 'DEBUGMAP3.BIN']. |
@@ -4986,7 +4987,6 @@
| `0x7843e` | — | med | auto-shape | unit-field |
| `0x81c96` | — | med | auto-shape | record-table[stride 3] |
| `0x8284e` | — | med | auto-shape | record-table[stride 3] |
-| `0x8c877` | — | low | auto-shape | index/counter? |
| `0xa70b2` | — | low | auto-shape | skill-field? |
| `0xaac76` | — | low | auto-shape | index/counter? |
| `0xaacf0` | — | med | auto-shape | record-table[stride 5] |
diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md
index 36ed888..71893d5 100644
--- a/docs/phase-b-framework.md
+++ b/docs/phase-b-framework.md
@@ -727,6 +727,15 @@ voice assets, XP/drops, capture and compendium flags, summoning economy, essence
Its linked-table shapes are 18 SKINIT and 84 EBINIT populated columns. The next EB tranche identifies roster
state flags, per-action unlock requirements, SCJUMP event ids, normal/brainwashed unit variants, and SALLY's
per-unit bonus item. Combat tracing also names the shared column-8 item/skill modifier as critical chance.
+
+The message-table tranche adds a reusable extractor for global-id dispatch chains and joins player-facing
+ITMES/SKMES text back to INIT records. All 287 item ids and all 131 skill ids match exactly in both
+directions. Each joined message retains its rendered title, richer description, furigana annotations, and
+dispatch offset independently of INIT's shorter effect label; generated profiles report complete coverage.
+The dispatch keys also establish `current_item_id` and `current_skill_id` as high-confidence shared index
+slots. This makes message/field correlation the next evidence source for the remaining sparse item and skill
+columns.
+
The remaining EBINIT unknowns are now the unread `0x7843e` enum and the signed meaning within boss classes;
enemy AI appears to live outside the static EBINIT schema. STINIT's bespoke parser remains a separate
extraction task.
diff --git a/docs/tools-reference.md b/docs/tools-reference.md
index 208bb18..d66c781 100644
--- a/docs/tools-reference.md
+++ b/docs/tools-reference.md
@@ -54,9 +54,10 @@ All opcode knowledge (ABI, semantics, provenance, `depends_on`) is hand-edited *
| Tool | Purpose | Run | Reads → Writes |
|---|---|---|---|
| `extract_phase2.py` | Batch: disassembly + text corpora for every script. | `extract_phase2.py` | corpus → `build/disasm/*.asm`, `build/text/{dialogue.jsonl,strings.jsonl,*.strings.txt}`, `build/manifest.json` |
-| `extract_init.py` | Parse a `*INIT` data table (auto-detects name / numeric / footer shape). Name tables infer their reserved record span, preserve sparse one-based runtime ids, distinguish lookup bases from first written cells, statically evaluate both direct `mov` writes and the INIT convention `sub destination, 0, magnitude` for negative values, and use corpus-observed 2D consumers to separate parallel `fields` from linked `record_fields` keyed `base/stride/column`. Also refreshes the generated data index. | `extract_init.py
[OUTNAME] [--mode …]` | `.BIN` → `build/data/.json`, `build/data/README.md` |
-| `init_table_profile.py` | Build the static investigation surface for an extracted name/numeric table: per-array and per-record-column population/value distributions, representative records, and direct opcode/script consumers. Findings are evidence only; confirmed meanings go in `vm-map/globals.toml`. | `init_table_profile.py [--build] [--limit N]` | `build/data/.json` + corpus → stdout; with `--build`, `build/data/-field-profile.{json,md}` |
-| `test_extract_init.py`, `test_init_table_profile.py` | Regression checks for sparse one-based INIT extraction and field profiling. | run each directly | — |
+| `extract_message_table.py` | Discover a repeated global-id dispatch chain such as ITMES/SKMES, reconstruct player-facing title/description lines (including furigana surface text and readings), and emit an ID-keyed message table with bytecode provenance. | `extract_message_table.py [OUTNAME]` | `.BIN` → `build/data/.json` |
+| `extract_init.py` | Parse a `*INIT` data table (auto-detects name / numeric / footer shape). Name tables infer their reserved record span, preserve sparse one-based runtime ids, distinguish lookup bases from first written cells, statically evaluate direct and negative-value writes, and separate parallel `fields` from linked row-major `record_fields`. ITINIT and SKINIT also join their matching ITMES/SKMES player-facing messages by runtime id. Refreshes the generated data index. | `extract_init.py [OUTNAME] [--mode …]` | `.BIN` plus matching `.BIN` when supported → `build/data/.json`, `build/data/README.md` |
+| `init_table_profile.py` | Build the static investigation surface for an extracted name/numeric table: message coverage, per-array and per-record-column population/value distributions, representative records, and direct opcode/script consumers. Findings are evidence only; confirmed meanings go in `vm-map/globals.toml`. | `init_table_profile.py [--build] [--limit N]` | `build/data/.json` + corpus → stdout; with `--build`, `build/data/-field-profile.{json,md}` |
+| `test_extract_init.py`, `test_init_table_profile.py` | Regression checks for sparse one-based INIT extraction, MES dispatch reconstruction/joins, and field/message profiling. | run each directly | — |
| `global_map.py` | Build the partial global-variable name map from static evidence. | `global_map.py` | corpus + `build/data/` → `build/global-var-map.{json,md}` |
## VM
diff --git a/tools/extract_init.py b/tools/extract_init.py
index 7a918b2..778b9a6 100644
--- a/tools/extract_init.py
+++ b/tools/extract_init.py
@@ -26,6 +26,7 @@ from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
import paths
+import extract_message_table
import sys4load
SET_STRING = 0x192
@@ -37,6 +38,11 @@ T_GLOBAL_INT = 3
T_GLOBAL_STRING = 5
T_IMM = 0
+MESSAGE_TABLES = {
+ "ITINIT": "ITMES",
+ "SKINIT": "SKMES",
+}
+
def resolve(name: str) -> Path:
for cand in (paths.GAME_DIR / f"{name}.BIN", paths.DATA1 / f"{name}.BIN"):
@@ -252,6 +258,28 @@ def extract_footer(scr):
return records, {}
+def join_messages(records: list[dict], message_scr) -> dict:
+ """Join a message-dispatch script to INIT records by runtime id."""
+ messages, message_meta = extract_message_table.extract_messages(message_scr)
+ by_id = {message["id"]: message for message in messages}
+ joined = 0
+ for record in records:
+ if message := by_id.get(record["id"]):
+ record["message"] = {
+ key: value for key, value in message.items() if key != "id"
+ }
+ joined += 1
+ init_ids = {record["id"] for record in records}
+ message_ids = set(by_id)
+ return {
+ "source": message_scr.path.name,
+ **message_meta,
+ "joined_count": joined,
+ "init_ids_without_message": sorted(init_ids - message_ids),
+ "message_ids_without_init": sorted(message_ids - init_ids),
+ }
+
+
def write_data_index(data_dir: Path) -> None:
"""Regenerate the disposable build/data index from current table JSONs."""
tables = []
@@ -272,15 +300,18 @@ def write_data_index(data_dir: Path) -> None:
"bases remain available in every record; confirmed field meanings live in",
"`vm-map/globals.toml` and the generated `docs/global-reference.md`.",
"",
- "| file | mode | records | array fields | record columns |",
- "|---|---|---:|---:|---:|",
+ "| file | mode | records | messages | array fields | record columns |",
+ "|---|---|---:|---:|---:|---:|",
]
for filename, data in tables:
columns = len(data.get("field_columns") or [])
record_columns = len(data.get("record_field_columns") or [])
+ message_count = data.get("message_table", {}).get(
+ "joined_count", data.get("message_count", 0)
+ )
lines.append(
f"| `{filename}` | {data['mode']} | {data['record_count']} | "
- f"{columns} | {record_columns} |"
+ f"{message_count} | {columns} | {record_columns} |"
)
lines += [
"",
@@ -289,6 +320,9 @@ def write_data_index(data_dir: Path) -> None:
"by the runtime lookup base used by `lookup-array`, not merely the first written cell.",
"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",
+ "short description stored by the INIT script.",
"",
"Use `tools/init_table_profile.py --build` to generate value/population and",
"direct-consumer evidence. `STINIT` still requires a bespoke mixed numeric/string parser.",
@@ -309,6 +343,11 @@ def main() -> int:
mode = mode_arg or detect_mode(scr)
extractor = {"name": extract_name, "numeric": extract_numeric, "footer": extract_footer}[mode]
recs, meta = extractor(scr)
+ if mode == "name" and name in MESSAGE_TABLES:
+ message_name = MESSAGE_TABLES[name]
+ meta["message_table"] = join_messages(
+ recs, sys4load.load(extract_message_table.resolve(message_name))
+ )
cols = sorted({c for r in recs for c in r.get("fields", {})}, key=lambda h: int(h, 16))
out = {"table": name, "source": scr.path.name, "magic": scr.magic, "mode": mode,
diff --git a/tools/extract_message_table.py b/tools/extract_message_table.py
new file mode 100644
index 0000000..c2cf0c2
--- /dev/null
+++ b/tools/extract_message_table.py
@@ -0,0 +1,175 @@
+#!/usr/bin/env python3
+"""Extract an ID-dispatched SYS4 message table.
+
+ITMES and SKMES are long chains of:
+
+ eq , ,
+ jcc ...
+ show-text ...
+ ...
+ jmp
+
+This tool discovers the selector global from that repeated shape, reconstructs
+the displayed lines (including the surface text of furigana spans), and emits a
+reusable ID-to-message JSON table.
+
+Usage:
+ py -3.11 -X utf8 tools/extract_message_table.py ITMES
+ py -3.11 -X utf8 tools/extract_message_table.py SKMES [OUTNAME]
+"""
+from __future__ import annotations
+
+import collections
+import json
+import sys
+from pathlib import Path
+
+HERE = Path(__file__).resolve().parent
+sys.path.insert(0, str(HERE))
+import paths
+import sys4load
+
+
+T_IMM = 0
+T_INLINE_STRING = 2
+T_GLOBAL_INT = 3
+
+
+def resolve(name: str) -> Path:
+ normalized = name.upper().removesuffix(".BIN")
+ scripts = paths.scripts()
+ try:
+ return scripts[f"{normalized}.BIN"]
+ except KeyError:
+ raise SystemExit(f"not found: {normalized}.BIN") from None
+
+
+def _dispatch_comparison(ins):
+ """Return (selector global, record id) for an eq global/int-immediate guard."""
+ if sys4load.display_label(ins.opcode) != "eq" or len(ins.args) != 3:
+ return None
+ left, right = ins.args[1:]
+ if left[0] == T_GLOBAL_INT and right[0] == T_IMM:
+ return left[1], right[1]
+ if right[0] == T_GLOBAL_INT and left[0] == T_IMM:
+ return right[1], left[1]
+ return None
+
+
+def discover_selector(scr) -> tuple[int, int]:
+ """Return the most frequently ID-compared global and its guard count."""
+ counts = collections.Counter(
+ selector
+ for ins in scr.instructions
+ if (pair := _dispatch_comparison(ins)) is not None
+ for selector in [pair[0]]
+ )
+ if not counts:
+ raise ValueError(f"{scr.path.name}: no global/immediate eq dispatch guards")
+ return counts.most_common(1)[0]
+
+
+def _strings(ins, scr) -> list[str]:
+ return [
+ scr.strings[value][0]
+ for arg_type, value in ins.args
+ if arg_type == T_INLINE_STRING and value in scr.strings
+ ]
+
+
+def _message_body(scr, start: int, stop: int) -> dict | None:
+ fragments: list[str] = []
+ lines: list[str] = []
+ furigana: list[dict] = []
+ for ins in scr.instructions[start:stop]:
+ operation = sys4load.display_label(ins.opcode)
+ if operation == "jmp":
+ break
+ values = _strings(ins, scr)
+ if operation == "show-text":
+ fragments.extend(values)
+ elif operation == "display-furigana" and values:
+ surface = values[0]
+ fragments.append(surface)
+ annotation = {"line": len(lines), "text": surface}
+ if len(values) > 1:
+ annotation["reading"] = values[1]
+ furigana.append(annotation)
+ elif operation == "end-text-line":
+ lines.append("".join(fragments))
+ fragments = []
+ if fragments:
+ lines.append("".join(fragments))
+ if not lines:
+ return None
+ message = {
+ "title": lines[0],
+ "description": "\n".join(lines[1:]),
+ }
+ if furigana:
+ message["furigana"] = furigana
+ return message
+
+
+def extract_messages(scr, selector: int | None = None) -> tuple[list[dict], dict]:
+ """Extract ordered message records and dispatch metadata from a script."""
+ discovered_selector, comparison_count = discover_selector(scr)
+ selector = discovered_selector if selector is None else selector
+ guards = [
+ (index, pair[1])
+ for index, ins in enumerate(scr.instructions)
+ if (pair := _dispatch_comparison(ins)) is not None and pair[0] == selector
+ ]
+ records: list[dict] = []
+ seen: set[int] = set()
+ for guard_index, (instruction_index, record_id) in enumerate(guards):
+ stop = guards[guard_index + 1][0] if guard_index + 1 < len(guards) else len(scr.instructions)
+ message = _message_body(scr, instruction_index + 1, stop)
+ if message is None:
+ continue
+ if record_id in seen:
+ raise ValueError(f"{scr.path.name}: duplicate message id {record_id}")
+ seen.add(record_id)
+ records.append({
+ "id": record_id,
+ "dispatch_offset": f"0x{scr.instructions[instruction_index].offset:x}",
+ **message,
+ })
+ return records, {
+ "selector_global": f"0x{selector:x}",
+ "dispatch_guard_count": len(guards),
+ "message_count": len(records),
+ "selector_discovery_count": comparison_count,
+ }
+
+
+def main() -> int:
+ if len(sys.argv) < 2:
+ raise SystemExit(__doc__)
+ name = sys.argv[1].upper().removesuffix(".BIN")
+ outname = sys.argv[2] if len(sys.argv) > 2 else name
+ scr = sys4load.load(resolve(name))
+ records, meta = extract_messages(scr)
+ output = {
+ "table": name,
+ "source": scr.path.name,
+ "magic": scr.magic,
+ "mode": "message-dispatch",
+ "record_count": len(records),
+ **meta,
+ "records": records,
+ }
+ outpath = paths.BUILD / "data" / f"{outname}.json"
+ outpath.parent.mkdir(parents=True, exist_ok=True)
+ outpath.write_text(
+ json.dumps(output, ensure_ascii=False, indent=2), encoding="utf8"
+ )
+ print(
+ f"{name}: selector={meta['selector_global']}, {len(records)} messages "
+ f"-> build/data/{outname}.json"
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/init_table_profile.py b/tools/init_table_profile.py
index 8a898fd..e852636 100644
--- a/tools/init_table_profile.py
+++ b/tools/init_table_profile.py
@@ -108,6 +108,29 @@ def profile_columns(data: dict) -> list[dict]:
return rows
+def profile_messages(data: dict) -> dict:
+ """Summarize the joined player-facing message evidence."""
+ records = data["records"]
+ with_message = [record for record in records if "message" in record]
+ with_furigana = [
+ record for record in with_message if record["message"].get("furigana")
+ ]
+ return {
+ "population": len(with_message),
+ "coverage": len(with_message) / len(records) if records else 0.0,
+ "furigana_records": len(with_furigana),
+ "examples": [
+ {
+ "id": record["id"],
+ "name": record.get("name", ""),
+ "title": record["message"]["title"],
+ "description": record["message"]["description"],
+ }
+ for record in with_message[:5]
+ ],
+ }
+
+
def add_direct_references(rows: list[dict], source_name: str) -> None:
by_base: dict[int, list[dict]] = collections.defaultdict(list)
for row in rows:
@@ -148,6 +171,7 @@ def add_direct_references(rows: list[dict], source_name: str) -> None:
def render_markdown(data: dict, rows: list[dict], limit: int) -> str:
ranked = sorted(rows, key=lambda row: (-row["population"], -row["references"], row["key"]))
shown = ranked[:limit]
+ message_profile = profile_messages(data)
lines = [
f"# {data['table']} field profile",
"",
@@ -156,6 +180,9 @@ def render_markdown(data: dict, rows: list[dict], limit: int) -> str:
"",
f"- records: {data['record_count']}",
f"- populated fields: {len(rows)}",
+ f"- player-facing messages: {message_profile['population']}/{data['record_count']} "
+ f"({message_profile['coverage']:.0%})",
+ f"- messages with furigana spans: {message_profile['furigana_records']}",
f"- rows shown: {len(shown)} (ranked by record coverage, then consumer references)",
"",
"| field | populated | distinct | range | direct refs | readers | common values | examples |",
@@ -189,6 +216,7 @@ def main() -> int:
name = args.table.upper().removesuffix(".JSON").removesuffix(".BIN")
data = load_table(name)
rows = profile_columns(data)
+ messages = profile_messages(data)
add_direct_references(rows, data["source"])
output = {
"table": data["table"],
@@ -197,6 +225,7 @@ def main() -> int:
"field_column_count": len(rows),
"parallel_array_count": sum(row["kind"] == "parallel-array" for row in rows),
"record_column_count": sum(row["kind"] == "record-column" for row in rows),
+ "message_profile": messages,
"columns": sorted(rows, key=lambda row: (
int(row["base"], 16), row["stride"] or 0, row["column"] or 0
)),
diff --git a/tools/test_extract_init.py b/tools/test_extract_init.py
index f6afbb3..1869aef 100644
--- a/tools/test_extract_init.py
+++ b/tools/test_extract_init.py
@@ -10,6 +10,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import extract_init
+import extract_message_table
import paths
import sys4load
@@ -65,9 +66,75 @@ def test_static_negative_write() -> None:
"INIT subtraction writes preserve negative values")
+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")
+
+
if __name__ == "__main__":
test_real_name_tables()
test_static_negative_write()
+ test_real_message_tables()
+ test_message_join()
if FAILS:
raise SystemExit(f"{len(FAILS)} failed checks")
print("all extract_init checks passed")
diff --git a/tools/test_init_table_profile.py b/tools/test_init_table_profile.py
index 4c841dc..27e45b4 100644
--- a/tools/test_init_table_profile.py
+++ b/tools/test_init_table_profile.py
@@ -13,7 +13,9 @@ def main() -> int:
fixture = {
"records": [
{"id": 1, "name": "one", "fields": {"0x10": 2, "0x20": 0},
- "record_fields": {"0x30/3/0": 9}},
+ "record_fields": {"0x30/3/0": 9},
+ "message": {"title": "One", "description": "First",
+ "furigana": [{"line": 0, "text": "One", "reading": "one"}]}},
{"id": 3, "name": "three", "fields": {"0x10": 2}},
{"id": 7, "name": "seven", "fields": {"0x10": 5},
"record_fields": {"0x30/3/2": 4}},
@@ -31,6 +33,11 @@ def main() -> int:
assert rows["0x30/3/0"]["base"] == "0x30"
assert rows["0x30/3/0"]["stride"] == 3
assert rows["0x30/3/2"]["column"] == 2
+ messages = profile.profile_messages(fixture)
+ assert messages["population"] == 1
+ assert messages["coverage"] == 1 / 3
+ assert messages["furigana_records"] == 1
+ assert messages["examples"][0]["description"] == "First"
print("all init_table_profile checks passed")
return 0
diff --git a/vm-map/globals.toml b/vm-map/globals.toml
index 220b8f6..3aac2a3 100644
--- a/vm-map/globals.toml
+++ b/vm-map/globals.toml
@@ -6,6 +6,16 @@
[meta]
note = "Curated global addresses override the auto shape-inference map (build/global-var-map.json)."
+[[global]]
+address = "0x8c877"
+name = "current_item_id"
+category = "index-pointer"
+type = "int"
+value_domain = "ITINIT item id; 0 means none"
+usage = "Shared item-id argument/selection slot. Item menus and gameplay scripts write a chosen item id, use it to index ITINIT arrays, and dispatch through item_handler_script_id; ITMES compares it against all 287 item ids to select the matching player-facing title and description."
+source = "investigation"
+confidence = "high"
+
[[global]]
address = "0x8c879"
name = "item_sort_key"
@@ -196,6 +206,16 @@ usage = "Sparse ITINIT consumable field. Item 106, Blood Price Healing Hand, des
source = "investigation"
confidence = "high"
+[[global]]
+address = "0xa6e59"
+name = "current_skill_id"
+category = "index-pointer"
+type = "int"
+value_domain = "SKINIT skill id; 0 means none"
+usage = "Shared skill-id argument/selection slot. Skill menus and combat scripts write the chosen skill id and use it to index SKINIT arrays; SKMES compares it against all 131 skill ids to select the matching player-facing title and description."
+source = "investigation"
+confidence = "high"
+
[[global]]
address = "0xa6e5a"
name = "skill_sort_key"
@@ -1367,17 +1387,6 @@ source = "auto-shape"
confidence = "med"
depends_on = []
-[[global]]
-address = "0xa6e59"
-name = ""
-category = "story-flag"
-type = "int"
-value_domain = "one of {1, 2, 3, 4, 11, 21, 22, 23}"
-usage = "TODO: confirm. Branch-read in 0 scenes / 5 scripts; compared against [1, 2, 3, 4, 11, 21, 22, 23]; writers=['CALCDMG.BIN', 'CHMENU.BIN', 'FORT.BIN', 'INFOIT.BIN']."
-source = "auto-shape"
-confidence = "med"
-depends_on = []
-
[[global]]
address = "0xab8e7"
name = ""