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 = ""