307 lines
10 KiB
Python
307 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from .decoder import H8536Decoder
|
|
from .formatting import h16
|
|
from .rom import Rom
|
|
|
|
|
|
DEFAULT_ROM = Path("ROM/M27C512@DIP28_1.BIN")
|
|
DEFAULT_TEXT_OUTPUT = Path("build/panel_button_trace.md")
|
|
DEFAULT_JSON_OUTPUT = Path("build/panel_button_trace.json")
|
|
|
|
BUTTON_TABLE_BASE = 0x2706
|
|
NOOP_HANDLER = 0x1C25
|
|
QUEUE_FUNCTION = 0x3E54
|
|
|
|
INPUT_BYTES = [
|
|
{
|
|
"shadow": "F6D7",
|
|
"source": "F102",
|
|
"dirty": "F6F2.7",
|
|
"dispatcher": "1B2D",
|
|
"initial_r5": 0x7E,
|
|
"bank": "IRQ4 A8 panel byte path",
|
|
},
|
|
{
|
|
"shadow": "F6D6",
|
|
"source": "F103",
|
|
"dirty": "F6F2.6",
|
|
"dispatcher": "1B44",
|
|
"initial_r5": 0x6E,
|
|
"bank": "IRQ4 A8 panel byte path",
|
|
},
|
|
{
|
|
"shadow": "F6D5",
|
|
"source": "F104",
|
|
"dirty": "F6F2.5",
|
|
"dispatcher": "1B5B",
|
|
"initial_r5": 0x5E,
|
|
"bank": "IRQ4 A8 panel byte path",
|
|
},
|
|
{
|
|
"shadow": "F6D4",
|
|
"source": "F105",
|
|
"dirty": "F6F2.4",
|
|
"dispatcher": "1BA0",
|
|
"initial_r5": 0x4E,
|
|
"bank": "IRQ4 A8 panel byte path",
|
|
},
|
|
{
|
|
"shadow": "F6D3",
|
|
"source": "F106",
|
|
"dirty": "F6F2.3",
|
|
"dispatcher": "1BB6",
|
|
"initial_r5": 0x3E,
|
|
"bank": "IRQ4 A8 panel byte path",
|
|
},
|
|
{
|
|
"shadow": "F6D2",
|
|
"source": "F107",
|
|
"dirty": "F6F2.2",
|
|
"dispatcher": "1BCC",
|
|
"initial_r5": 0x2E,
|
|
"bank": "IRQ4 A8 panel byte path",
|
|
},
|
|
{
|
|
"shadow": "F6D1",
|
|
"source": "F108",
|
|
"dirty": "F6F2.1",
|
|
"dispatcher": "1B72",
|
|
"initial_r5": 0x1E,
|
|
"bank": "IRQ4 A8 panel byte path",
|
|
},
|
|
{
|
|
"shadow": "F6D0",
|
|
"source": "F109",
|
|
"dirty": "F6F2.0",
|
|
"dispatcher": "1B89",
|
|
"initial_r5": 0x0E,
|
|
"bank": "IRQ4 A8 panel byte path",
|
|
},
|
|
{
|
|
"shadow": "F6DC",
|
|
"source": "F005",
|
|
"dirty": "F6F3.4",
|
|
"dispatcher": "1BE2",
|
|
"initial_r5": 0xCE,
|
|
"bank": "IRQ3 A8 panel byte path",
|
|
},
|
|
{
|
|
"shadow": "F6DB",
|
|
"source": "F006",
|
|
"dirty": "F6F3.3",
|
|
"dispatcher": "1BF8",
|
|
"initial_r5": 0xBE,
|
|
"bank": "IRQ3 A8 panel byte path",
|
|
},
|
|
]
|
|
|
|
KNOWN_REPORTS = {
|
|
0x0007: "CAM POWER",
|
|
0x0015: "CALL",
|
|
}
|
|
|
|
|
|
def analyze_panel_buttons(rom_bytes: bytes) -> dict[str, Any]:
|
|
rom = Rom(rom_bytes)
|
|
entries = _table_entries(rom)
|
|
handler_summaries = {
|
|
target: _summarize_handler(rom, target)
|
|
for target in sorted({entry["handler"] for entry in entries if entry["handler"] != NOOP_HANDLER})
|
|
}
|
|
for entry in entries:
|
|
entry["handler_summary"] = handler_summaries.get(entry["handler"], {})
|
|
selectors = entry["handler_summary"].get("report_selectors", [])
|
|
entry["known_report"] = ", ".join(
|
|
KNOWN_REPORTS.get(int(selector), f"0x{int(selector):04X}") for selector in selectors
|
|
)
|
|
return {
|
|
"kind": "panel_button_trace",
|
|
"button_table_base": BUTTON_TABLE_BASE,
|
|
"button_table_base_hex": h16(BUTTON_TABLE_BASE),
|
|
"noop_handler": NOOP_HANDLER,
|
|
"noop_handler_hex": h16(NOOP_HANDLER),
|
|
"entries": entries,
|
|
"known_paths": _known_paths(entries),
|
|
"handler_summaries": [
|
|
{"handler": handler, "handler_hex": h16(handler), **summary}
|
|
for handler, summary in sorted(handler_summaries.items())
|
|
],
|
|
}
|
|
|
|
|
|
def format_markdown(analysis: dict[str, Any]) -> str:
|
|
lines = [
|
|
"# PT2 Known Button ROM Trace",
|
|
"",
|
|
"This report follows the panel button edge path from the serial-visible reports back into the ROM input scanner.",
|
|
"The key table is the indirect handler table at `H'2706`, used by `loc_1C0E` after byte-level panel input changes are detected.",
|
|
"",
|
|
"## Known Anchors",
|
|
"",
|
|
]
|
|
for path in analysis["known_paths"]:
|
|
lines.extend(
|
|
[
|
|
f"### {path['name']}",
|
|
"",
|
|
f"- Emitted selector: `0x{path['selector']:04X}`",
|
|
f"- Handler: `H'{path['handler']:04X}`",
|
|
f"- Edge source: `{path['source']} -> {path['shadow']}` via `{path['dirty']}`",
|
|
f"- Trigger bit: `{path['shadow']}.{path['bit']}`",
|
|
f"- Table slot: `H'{path['table_address']:04X}` -> `H'{path['handler']:04X}`",
|
|
f"- Current-level tests: {', '.join(path['handler_summary'].get('level_tests', [])) or 'none found'}",
|
|
f"- State writes: {', '.join(path['handler_summary'].get('state_writes', [])) or 'none found'}",
|
|
"",
|
|
]
|
|
)
|
|
lines.extend(
|
|
[
|
|
"## Button Matrix Entries With Serial Reports",
|
|
"",
|
|
"| Source | Shadow bit | Dirty | Handler | Selector(s) | State writes |",
|
|
"| --- | --- | --- | --- | --- | --- |",
|
|
]
|
|
)
|
|
for entry in analysis["entries"]:
|
|
selectors = entry["handler_summary"].get("report_selectors", [])
|
|
if not selectors:
|
|
continue
|
|
selector_text = ", ".join(f"`0x{int(selector):04X}`" for selector in selectors)
|
|
writes = ", ".join(f"`{write}`" for write in entry["handler_summary"].get("state_writes", [])[:4])
|
|
lines.append(
|
|
f"| `{entry['source']}` | `{entry['shadow']}.{entry['bit']}` | `{entry['dirty']}` | "
|
|
f"`H'{entry['handler']:04X}` | {selector_text} | {writes} |"
|
|
)
|
|
lines.extend(
|
|
[
|
|
"",
|
|
"## Practical Read",
|
|
"",
|
|
"- CALL and CAM POWER do share the general panel edge path with many other buttons.",
|
|
"- The shared path is: panel byte snapshot -> shadow byte -> dirty bit -> `loc_1C0E` jump table -> handler -> `loc_3E54` report.",
|
|
"- Other buttons diverge in their handlers: many require `F731/F730/F791` session/menu gates, mutate page state, or emit different selectors.",
|
|
"- Some table entries are `H'1C25`, an immediate `RTS`, so those physical matrix positions are intentionally ignored in this firmware context.",
|
|
"",
|
|
]
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description="Trace panel button matrix handlers and known CALL/CAM report sources.")
|
|
parser.add_argument("rom", nargs="?", type=Path, default=DEFAULT_ROM)
|
|
parser.add_argument("--out", type=Path, default=DEFAULT_TEXT_OUTPUT)
|
|
parser.add_argument("--json-out", type=Path, default=DEFAULT_JSON_OUTPUT)
|
|
args = parser.parse_args(argv)
|
|
|
|
analysis = analyze_panel_buttons(args.rom.read_bytes())
|
|
args.out.parent.mkdir(parents=True, exist_ok=True)
|
|
args.out.write_text(format_markdown(analysis), encoding="utf-8")
|
|
args.json_out.parent.mkdir(parents=True, exist_ok=True)
|
|
args.json_out.write_text(json.dumps(analysis, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
print(f"wrote {args.out}")
|
|
print(f"wrote {args.json_out}")
|
|
return 0
|
|
|
|
|
|
def _table_entries(rom: Rom) -> list[dict[str, Any]]:
|
|
rows: list[dict[str, Any]] = []
|
|
for lane in INPUT_BYTES:
|
|
initial_r5 = int(lane["initial_r5"])
|
|
for bit in range(7, -1, -1):
|
|
table_offset = initial_r5 - (2 * (7 - bit))
|
|
table_address = BUTTON_TABLE_BASE + table_offset
|
|
handler = rom.u16(table_address)
|
|
rows.append(
|
|
{
|
|
"source": lane["source"],
|
|
"shadow": lane["shadow"],
|
|
"dirty": lane["dirty"],
|
|
"dispatcher": lane["dispatcher"],
|
|
"bank": lane["bank"],
|
|
"bit": bit,
|
|
"table_offset": table_offset,
|
|
"table_address": table_address,
|
|
"table_address_hex": h16(table_address),
|
|
"handler": handler,
|
|
"handler_hex": h16(handler),
|
|
"is_noop": handler == NOOP_HANDLER,
|
|
}
|
|
)
|
|
return rows
|
|
|
|
|
|
def _known_paths(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
paths: list[dict[str, Any]] = []
|
|
for entry in entries:
|
|
selectors = entry["handler_summary"].get("report_selectors", [])
|
|
for selector in selectors:
|
|
name = KNOWN_REPORTS.get(int(selector))
|
|
if not name:
|
|
continue
|
|
paths.append({"name": name, "selector": int(selector), **entry})
|
|
return paths
|
|
|
|
|
|
def _summarize_handler(rom: Rom, address: int) -> dict[str, Any]:
|
|
instructions = _decode_linear_handler(rom, address)
|
|
report_selectors: list[int] = []
|
|
last_r3: int | None = None
|
|
level_tests: list[str] = []
|
|
state_writes: list[str] = []
|
|
for ins in instructions:
|
|
text = ins.text
|
|
if match := re.search(r"MOV:[^#]+#H'([0-9A-F]{4}), R3", text):
|
|
last_r3 = int(match.group(1), 16)
|
|
if QUEUE_FUNCTION in ins.targets and last_r3 is not None:
|
|
if last_r3 not in report_selectors:
|
|
report_selectors.append(last_r3)
|
|
if match := re.search(r"BTST\.B #([0-7]), @H'(F6[0-9A-F]{2})", text):
|
|
test = f"{match.group(2)}.{match.group(1)}"
|
|
if test not in level_tests:
|
|
level_tests.append(test)
|
|
if match := re.search(r"@H'(E[89][0-9A-F]{2})", text):
|
|
write = match.group(1)
|
|
if _looks_like_write(text) and write not in state_writes:
|
|
state_writes.append(write)
|
|
return {
|
|
"report_selectors": report_selectors,
|
|
"report_selectors_hex": [f"0x{selector:04X}" for selector in report_selectors],
|
|
"level_tests": level_tests,
|
|
"state_writes": state_writes,
|
|
"instruction_count_scanned": len(instructions),
|
|
}
|
|
|
|
|
|
def _decode_linear_handler(rom: Rom, address: int, *, max_bytes: int = 0x90) -> list[Any]:
|
|
decoder = H8536Decoder(rom, labels={QUEUE_FUNCTION: "loc_3E54"})
|
|
instructions: list[Any] = []
|
|
pc = address
|
|
end = min(len(rom.data), address + max_bytes)
|
|
while pc < end:
|
|
ins = decoder.decode(pc)
|
|
instructions.append(ins)
|
|
pc += max(1, ins.size)
|
|
if ins.mnemonic == "RTS":
|
|
break
|
|
return instructions
|
|
|
|
|
|
def _looks_like_write(text: str) -> bool:
|
|
return text.startswith("MOV") or text.startswith("BSET") or text.startswith("BCLR") or text.startswith("CLR")
|
|
|
|
|
|
__all__ = ["analyze_panel_buttons", "format_markdown", "main"]
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|