Join item and skill message semantics
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- DO NOT EDIT -- generated from vm-map/globals.toml by tools/globals_build.py --build -->
|
||||
# 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] |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <TABLE> [OUTNAME] [--mode …]` | `<TABLE>.BIN` → `build/data/<OUTNAME>.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 <TABLE> [--build] [--limit N]` | `build/data/<TABLE>.json` + corpus → stdout; with `--build`, `build/data/<TABLE>-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 <MES> [OUTNAME]` | `<MES>.BIN` → `build/data/<OUTNAME>.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 <TABLE> [OUTNAME] [--mode …]` | `<TABLE>.BIN` plus matching `<MES>.BIN` when supported → `build/data/<OUTNAME>.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 <TABLE> [--build] [--limit N]` | `build/data/<TABLE>.json` + corpus → stdout; with `--build`, `build/data/<TABLE>-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
|
||||
|
||||
@@ -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 <TABLE> --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,
|
||||
|
||||
175
tools/extract_message_table.py
Normal file
175
tools/extract_message_table.py
Normal file
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract an ID-dispatched SYS4 message table.
|
||||
|
||||
ITMES and SKMES are long chains of:
|
||||
|
||||
eq <temporary>, <selected id global>, <record id>
|
||||
jcc ...
|
||||
show-text ...
|
||||
...
|
||||
jmp <shared exit>
|
||||
|
||||
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())
|
||||
@@ -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
|
||||
)),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
Reference in New Issue
Block a user