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", 0x0013: "IRIS/M.BLACK LINK", 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())