External Debug Client¶
Host-side client library for DebugProtocol.md. Wire format lives in the protocol doc; this document covers language choice, library shape, API, and client-side concerns so agents and scripts do not re-implement framing from scratch.
Python package lives under clients/python/ (gs2debug). Wire format: DebugProtocol.md.
Agent / script cookbook (preferred entry point): gs2debug.md.
Language: Python 3¶
Python 3.10+ is the first (and primary) client language.
Why:
- Stdlib
socketcovers AF_UNIX (macOS / Linux) and TCP with no dependencies; Windows AF_UNIX works on recent Python + Windows 10+. - Agents and CI scripts already default to Python; extending framing with
struct/socketis straightforward. - No build step — drop a script next to the package and call
Client. - Matches the protocol direction: host-driven tools, opaque binary on the wire, no JSON in the emulator.
A later TypeScript (or other) client can share the same wire rules from the protocol doc (e.g. for MCP). This sketch does not define those APIs.
You do not need deep Python experience to use the library; agents and the package own most of the protocol detail.
Responsibilities¶
| Layer | Owns |
|---|---|
| DebugProtocol.md | Frame bytes, type IDs, payload layouts |
Client library (gs2debug) |
Connect, framing read/write, seq allocation, HELLO, request/reply matching, ERROR → exception, EVENT demux, thin helpers (read_mem, reset, bp_set, wait_stopped, type_text, paste_text, quit, …) |
| Agent / script | Workflows (reset → type → peek → …), timeouts, retries, policy |
The emulator owns machine state. The client peeks/pokes memory via READMEM / WRITEMEM, resets via RESET, run-controls via PAUSE / CONTINUE, sets breakpoints via BP_*, injects keys via KEYEVENT, fills the paste buffer via PASTE_TEXT, and force-exits via QUIT.
Package layout¶
clients/python/
README.md
pyproject.toml # package name gs2debug, requires-python >= 3.10, no runtime deps
src/gs2debug/
__init__.py
types.py # HELLO / PING / QUIT / GET_STATUS / RESET / PAUSE / CONTINUE /
# READMEM / WRITEMEM / BP_* / KEYEVENT / PASTE_TEXT / EVT_* / STOP_* / MEM_* constants
keys.py # SDL scancodes / keymods + ASCII→key map for type_text
frame.py # pack / unpack 12-byte header + payload
client.py # Client, HelloInfo, StatusInfo, BpInfo, StoppedEvent, TraceWindow
errors.py # ProtocolError from ERROR frames
examples/ # hello_ping, read/write text40, type_to_emu, test_breakpoints,
# watch_data_iigs, watch_c010_iie, …
tests/
test_frame.py # encode/decode roundtrips (no socket server needed)
Stdlib only: socket, struct. Optional later: threading / selectors for a background event reader.
Client API¶
from collections.abc import Callable
from dataclasses import dataclass
@dataclass(frozen=True)
class HelloInfo:
version: int
flags: int
max_payload: int
@dataclass(frozen=True)
class StatusInfo:
execution_mode: int # 0=NORMAL, 1=STEP_INTO, 2=PAUSED
platform_id: int # PlatformId_t / -p N (0=II … 5=IIgs)
@dataclass(frozen=True)
class BpInfo:
id: int
hit_count: int
kind: int # BP_KIND_EXEC / DATA / IO
flags: int
access: int # BP_ACCESS_*
domain: int
address: int
length: int
addr_mask: int
data_value: int
data_mask: int
ignore_count: int
@dataclass(frozen=True)
class StoppedEvent:
reason: int # STOP_BP_EXEC / DATA / IO / STOP_STEP / STOP_PAUSE
bp_id: int
pc: int
eaddr: int
value: int
access: int
kind: int
execution_mode: int
trace: bytes # 40-byte system_trace_entry_t snapshot when present
@dataclass(frozen=True)
class TraceWindow:
available: int # entries currently in the ring
entries: list[bytes] # oldest → newest; each len == 40
class Client:
def connect(self, path: str) -> None:
"""Connect to a Unix-domain socket at path. TCP later."""
def close(self) -> None:
"""Close the connection."""
def hello(self, version: int = 1) -> HelloInfo:
"""Send HELLO; return server caps. Required before other commands."""
def ping(self) -> None:
"""Send PING; succeed if empty PING reply arrives."""
def quit(self) -> None:
"""Force-quit emulator (skips QuitModal / dirty-disk prompts).
Prefer over kill/SIGTERM. If a harness must SIGTERM, start GSSquared
with --no-quit-confirm."""
def get_status(self) -> StatusInfo:
"""Send GET_STATUS; return execution_mode and platform_id.
Handled on the emulator main thread. Reply is 8 bytes on the wire."""
def reset(self, cold_start: bool = False) -> None:
"""Send RESET; call computer_t::reset(cold_start) on the main thread.
cold_start=True clears $3F2–$3F4 before reset. Does not clear breakpoints."""
def pause(self) -> None:
"""PAUSE: stop guest execution (STOP_PAUSE / EXEC_PAUSED)."""
def continue_(self) -> None:
"""CONTINUE: resume from PAUSE or STEP_INTO (EXEC_NORMAL)."""
def step_into(self, count: int = 1) -> None:
"""STEP_INTO: run `count` instructions (instructions_left); empty reply.
When the batch finishes: EVT_STOPPED STOP_STEP + CPU trace. count >= 1."""
def get_trace(self, ago: int = 0, count: int = 100) -> TraceWindow:
"""GET_TRACE: ring window. ago=0 is newest; count extends into the past.
Returns TraceWindow(available, entries) — each entry is 40 raw bytes."""
def state_get(self, device_id: int) -> bytes:
"""STATE_GET: opaque device snapshot for DEVICE_ID_* (e.g. DEVICE_ID_ENSONIQ)."""
def bp_set(
self,
*,
kind: int,
address: int,
length: int = 1,
flags: int = BP_FLAG_ENABLED,
access: int = BP_ACCESS_NONE,
domain: int = 0,
addr_mask: int = 0xFFFFFFFF,
data_value: int = 0,
data_mask: int = 0xFF,
ignore_count: int = 0,
) -> int:
"""BP_SET; return new breakpoint id. See DebugProtocol.md for kind/access rules.
EXEC: access ignored. DATA/IO: access R/W/RW required. IO: $C000–$C0FF offset."""
def bp_clear(self, bp_id: int) -> None:
"""BP_CLEAR one id."""
def bp_clear_all(self) -> None:
"""BP_CLEAR_ALL (shared table with the built-in debugger)."""
def bp_enable(self, bp_id: int, enabled: bool = True) -> None:
"""BP_ENABLE / disable by id."""
def bp_list(self) -> list[BpInfo]:
"""BP_LIST; return current table entries."""
def wait_event(self, *, timeout: float | None = 5.0) -> tuple[int, int, bytes]:
"""Block until an EVENT frame. Returns (event_id, seq, data).
Raises TimeoutError on deadline. Cannot run while a request is outstanding."""
def wait_stopped(self, *, timeout: float | None = 5.0) -> StoppedEvent:
"""Block until EVT_STOPPED; parse and return StoppedEvent.
Skips other event ids (e.g. EVT_RUN_STATE) until a stop arrives."""
def read_mem(self, domain: int, address: int, length: int) -> bytes:
"""Send READMEM; return `length` bytes from the given domain/address.
Domains: MAIN, MAIN_RAW; MEGAII / MEGAII_RAW on Apple IIgs.
ENSONIQ/ADBMICRO reserved. Handled on the emulator main thread."""
def write_mem(self, domain: int, address: int, data: bytes) -> None:
"""Send WRITEMEM; poke `data` at domain/address.
Domains: MAIN, MAIN_RAW; MEGAII / MEGAII_RAW on Apple IIgs.
ENSONIQ/ADBMICRO reserved. Handled on the emulator main thread. Success reply is empty."""
def key_event(self, down: bool, scancode: int, mod: int = 0) -> None:
"""Send KEYEVENT (one SDL key down or up)."""
def key_down(self, scancode: int, mod: int = 0) -> None:
"""KEYEVENT down. Use for modifiers and low-level control."""
def key_up(self, scancode: int, mod: int = 0) -> None:
"""KEYEVENT up."""
def tap_key(self, scancode: int, mod: int = 0, *, hold_s: float = 0.02) -> None:
"""keydown, optional hold, then keyup with the same scancode/mod."""
def type_text(self, text: str, *, delay_s: float = 0.05, hold_s: float = 0.02) -> None:
"""Type printable ASCII (US layout); newline → Return.
hold_s between down/up; delay_s after each char (3× after Return)."""
def paste_text(self, text: str) -> None:
"""Fill the emulator paste buffer (PASTE_TEXT). One round-trip; guest paces drain.
Replaces any in-progress paste. Empty string cancels. Prefer over type_text for long strings."""
def request(
self,
type: int,
payload: bytes = b"",
*,
timeout: float | None = None,
) -> bytes:
"""Send one request; return reply payload. Raises ProtocolError on ERROR."""
def on_event(
self,
handler: Callable[[int, int, bytes], None] | None,
) -> None:
"""Register handler(event_id, seq, data) for EVENT frames. None clears.
Also invoked for EVENTs interleaved while a request awaits its reply."""
Generic request keeps payloads as bytes. Thin helpers pack/unpack documented fields with struct — no JSON.
Constants (BP_KIND_*, BP_ACCESS_*, BP_FLAG_*, EVT_*, STOP_*, EXEC_*, MEM_*, PLATFORM_*) are re-exported from gs2debug / gs2debug.types. Breakpoint semantics: DebugProtocol.md (EXEC / DATA / IO, masks, ignore_count, STOP reasons).
Behavioral rules¶
- Connect + HELLO first. Any other command before a successful
hello()is a client bug; server may replyERRORwithE_NOT_HANDSHAKED. SurfaceE_BAD_VERSION/E_NOT_HANDSHAKEDasProtocolError. - Single-flight v1. At most one outstanding
requestat a time. Library allocates monotone non-zeroseq(start at 1).wait_event/wait_stoppedalso require no outstanding request. - Reply matching. After send, read frames until a non-
EVENTframe with matchingseq: type == ERROR→ raiseProtocolError(code, message)typeequals the request type → return payload- otherwise → protocol violation (raise)
- EVENT while waiting. If an
EVENTframe arrives before the reply, invokeon_event(if set), then keep waiting for the reply. Events must not break request/reply pairing. Preferwait_stopped()when idle for stop notifications. - Timeouts.
request(..., timeout=seconds)/wait_event/wait_stoppedraiseTimeoutErroron deadline. Connection failures raise clearly (OSError). - Threading (v1). Sync API on the caller thread. A background reader for events while idle is a later enhancement.
- Breakpoint hit → STEP_INTO. A DATA/IO/EXEC hit (and PAUSE) leaves the emu in
EXEC_STEP_INTOorEXEC_PAUSEDuntilcontinue_()orstep_into(n). Scripts that want a visible pause must delay before resume.step_into(n)arms N instructions and deliversSTOP_STEPwhen done. - Quit cleanly. Prefer
quit()over killing the process (SDL maps SIGTERM to QuitModal unless--no-quit-confirm).
Errors¶
class ProtocolError(Exception):
code: int # E_* from the protocol doc
message: str # UTF-8 text from ERROR payload (may be empty)
Map known codes (E_UNKNOWN_TYPE, E_BAD_LENGTH, …) in docs or constants; agents can branch on code.
Client-side concerns¶
Implementers and agents must handle these; do not reinvent per script:
| Concern | Rule |
|---|---|
| Partial I/O | Loop recv/send until the full 12-byte header and N payload bytes are transferred |
| Endianness | Little-endian: struct.pack("<III", type, seq, length) / unpack |
| Max payload | 1 MiB (0x00100000). Refuse to send or accept larger frames |
| Connection model | One client connection; reconnect = new Client |
| Socket path | AF_UNIX path for now (e.g. /tmp/gs2.sock). Windows named pipes / TCP later |
| Unknown events | Ignore unknown event_id values (handler may no-op); wait_stopped skips non-EVT_STOPPED |
| Encoding | No JSON. Payloads are bytes; helpers use struct for fixed fields |
Smoke-test story¶
Until the emulator socket exists: unit-test frame.py encode/decode only.
Once the debug socket exists:
- Start GSSquared with
--debug PATH(or-D PATH), e.g.-p 3for IIe Enhanced /-p 5for IIgs. Client().connect(path)→hello()→get_status()(checkplatform_id) →reset()/read_mem/paste_text/type_text/bp_*/wait_stopped→quit().- Assert caps and that
platform_idmatches the launched-p N. - Prefer
c.quit()overkill(see AGENTS.md debug-protocol smoke notes).
Self-launching examples (spawn GSSquared, exercise protocol, quit):
| Script | Role |
|---|---|
examples/test_breakpoints.py |
PAUSE/CONTINUE + EXEC/IO + cap (expects running emu + socket args) |
examples/watch_data_iigs.py |
Launch -p 5, DATA watch, print stop packet, QUIT |
examples/watch_c010_iie.py |
Launch -p 3, IO write $C010, hold in STEP, CONTINUE, QUIT |
Non-goals (v1 client)¶
- MCP wrapper, async API, pipelining, TCP connect
- Expression-language breakpoints (host owns policy; emu is typed stops only)
Flow¶
sequenceDiagram
participant Script
participant Gs2debug
participant Emu
Script->>Gs2debug: connect(path)
Script->>Gs2debug: hello()
Gs2debug->>Emu: HELLO seq=1
Emu-->>Gs2debug: HELLO seq=1 caps
Note over Gs2debug: may deliver EVENT frames to handler first
Gs2debug-->>Script: HelloInfo
Script->>Gs2debug: get_status()
Gs2debug->>Emu: GET_STATUS seq=2
Emu-->>Gs2debug: mode + platform_id
Gs2debug-->>Script: StatusInfo
Script->>Gs2debug: bp_set(kind=IO, address=$C010, access=W)
Gs2debug->>Emu: BP_SET
Emu-->>Gs2debug: id
Note over Emu: guest hits watch → STEP_INTO
Emu-->>Gs2debug: EVENT EVT_STOPPED
Script->>Gs2debug: wait_stopped()
Gs2debug-->>Script: StoppedEvent
Script->>Gs2debug: continue_()
Script->>Gs2debug: quit()
Example Commands¶
./build/GSSquared --debug /tmp/gs2.sock -p 3
PYTHONPATH=clients/python/src python3 clients/python/examples/hello_ping.py /tmp/gs2.sock
PYTHONPATH=clients/python/src python3 clients/python/examples/watch_c010_iie.py
PYTHONPATH=clients/python/src python3 clients/python/examples/watch_data_iigs.py 02/0000