OXYGENHUB

spec: document Agon /audio format in the platform appendix 67065eaa1a13

The Agon (FAB Agon Emulator) appendix now documents its /audio: the VDP
mixes 8-bit unsigned mono at 16384 Hz, returned as a 16-bit signed PCM
WAV, and headless capture needs SDL_AUDIODRIVER=dummy so the SDL audio
callback (the sole getAudioSamples consumer) runs and tees the ring.

Git-Commit: 8b796dc1c69c85b1cac10b55ed85af0067153bac
Git-Author: Andy McCall <[email protected]>
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-07-21 22:50 UTC
Signature
◈ sealed by Andy McCall
Hash
67065eaa1a138cefe18e364919754202087d7336486d2f423fda5d2fc8f1aaeb
Depends on
42c217d9aaf7 961b3c610c66 d44319b1e712
Depended on by
346ca3ee83f4
In bookmarks
main perf-6
Edits
16 atoms, 0 tree operations, 2 files

Browse the tree at this change

modified README.md +69 −35
…
5 unchanged lines
6 6
7 7
An emulator launched with a control-port flag exposes a localhost HTTP server:
8 8
9 -
| Endpoint | Returns |
10 -
|---|---|
11 -
| `GET /status` | contract version, platform, frame counter, paused state |
12 -
| `GET /screenshot` | PPM image of the live screen |
13 -
| `GET /mem?addr=&len=&bank=` | raw memory bytes |
14 -
| `GET /regs` | CPU registers (JSON) |
15 -
| `POST /step?frames=` | advance N frames deterministically |
16 -
| `POST /key`, `/reset` | *(0.2)* inject input |
9 +
| Endpoint | Returns | Since |
10 +
|---|---|---|
11 +
| `GET /status` | contract version, platform, frame counter, paused/running | 0.1 |
12 +
| `GET /screenshot` | PPM image of the live screen | 0.1 |
13 +
| `GET /mem?addr=&len=&bank=` | raw memory bytes | 0.1 |
14 +
| `GET /regs` | CPU registers (JSON) | 0.1 |
15 +
| `POST /step?frames=` | advance N frames deterministically | 0.1 |
16 +
| `POST /pause` / `POST /resume` | halt / resume the machine | 0.1 |
17 +
| `POST /key`, `POST /reset` | inject input / reset | 0.2 |
18 +
| `POST /mem?addr=&bank=` | write (poke) memory | 0.3 |
19 +
| `GET /audio` | drain synthesised PCM as a WAV | 0.3 |
17 20
21 +
Current contract: **0.3.0**. Minors are purely additive (a missing capability
22 +
returns 501 and lowers the advertised contract), so a client reads
23 +
`/status.contract` and fails fast only on a MAJOR mismatch. See [SPEC.md](SPEC.md).
24 +
18 25
## Why
19 26
20 -
Every one of these ports (Pac-Man on X16, Neo6502, Agon; and whatever's next) is
21 -
chasing **frame-exact** arcade fidelity, and every one had its own fragile,
22 -
platform-specific way to check state headless (write to the SD card, dump 64 KB
23 -
of RAM on a magic jump, grep stdout — which lies). This replaces all of that
24 -
with one contract:
27 +
Every one of these ports (Pac-Man on X16, Neo6502, Agon, and NEC PC-FX; and
28 +
whatever's next) is chasing **frame-exact** arcade fidelity, and every one had
29 +
its own fragile, platform-specific way to check state headless (write to the SD
30 +
card, dump 64 KB of RAM on a magic jump, grep stdout — which lies). This
31 +
replaces all of that with one contract:
25 32
26 -
- **`GET /mem`** → assert game state (sprite table, actor positions).
33 +
- **`GET /mem`** → assert game state (sprite table, actor positions);
34 +
  **`POST /mem`** pokes it back for setup.
27 35
- **`GET /screenshot`** → Pillow → assert pixels, or just catch "nothing
28 36
  rendered" (the exact class of bug that costs an afternoon otherwise).
29 37
- **`POST /step`** → deterministic, frame-addressable capture, so CI diffs don't
30 38
  flake against a free-running 60 Hz loop.
39 +
- **`POST /key`** → drive input; **`GET /audio`** → assert the sound a stepped
40 +
  window produced.
31 41
32 42
The payoff is uniformity: the same `emu_control.py` and the same pytest patterns
33 -
drive the X16, Neo6502, and Agon emulators.
43 +
drive the X16, Neo6502, Agon, and PC-FX emulators.
34 44
35 45
## Layout
36 46
37 47
```
38 48
SPEC.md                 the normative HTTP contract (start here)
39 49
core/retro_control.h    portable backend vtable each emulator implements
40 -
core/retro_control.c    shared server: sockets, HTTP, PPM/JSON, marshalling  [next]
41 -
client/emu_control.py   shared pytest client
42 -
conformance/            tests an emulator runs to prove it conforms          [next]
50 +
core/retro_control.c    shared server: sockets, HTTP, PPM/JSON, marshalling
51 +
client/emu_control.py   shared pytest client (stdlib; Pillow optional)
52 +
conformance/            a platform-agnostic pytest that proves an emulator conforms
53 +
CHANGELOG.md            the additive contract history
43 54
```
55 +
56 +
## Adopting it in an emulator
57 +
58 +
Two first-class paths (both normative in [SPEC.md](SPEC.md#adoption-paths)):
44 59
45 -
## Adopting it in an emulator (the shape)
60 +
- **Vendor the shared core** — for C/C++ emulators. Add this repo as a git
61 +
  submodule, compile `core/retro_control.c`, implement the backend callbacks,
62 +
  and wire three loop hooks. The socket loop, HTTP parsing, PPM/JSON encoding
63 +
  and pause/step control come for free.
64 +
- **Conform only** — for emulators in other languages (the FAB Agon emulator is
65 +
  Rust). Keep your own HTTP server; just match the contract and pass the
66 +
  conformance suite. Sharing the C core is an optimization, not a requirement.
46 67
47 -
Write the server once, adapt per fork. Each fork vendors `core/` and implements
48 -
~5 callbacks, then calls the server:
68 +
The vendor path is one backend struct plus three hooks:
49 69
50 70
```c
51 71
#include "retro_control.h"
52 72
53 -
static retro_control_backend_t backend = {
54 -
    .platform       = "x16",
55 -
    .read_mem       = x16_read_mem,      /* debug-read, no side effects */
56 -
    .get_regs_json  = x16_get_regs_json,
57 -
    .get_framebuffer= x16_get_framebuffer,
58 -
    .step_frames    = x16_step_frames,
59 -
    .get_frame_count= x16_get_frame_count,
73 +
/* Four callbacks satisfy 0.1; add inject_key/reset for 0.2 and
74 +
   write_mem/capture_audio for 0.3. A NULL callback 501s its endpoint. */
75 +
static const retro_control_backend_t backend = {
76 +
    .platform        = "x16",
77 +
    .emulator        = "x16emu",
78 +
    .read_mem        = x16_read_mem,         /* debug-read, no side effects */
79 +
    .get_regs_json   = x16_get_regs_json,
80 +
    .get_framebuffer = x16_get_framebuffer,
81 +
    .get_frame_count = x16_get_frame_count,
82 +
    .inject_key      = x16_inject_key,       /* 0.2 */
83 +
    .reset           = x16_reset,            /* 0.2 */
84 +
    .write_mem       = x16_write_mem,        /* 0.3 */
85 +
    .capture_audio   = x16_capture_audio,    /* 0.3 */
60 86
};
61 87
62 -
if (control_port)              /* set by -controlport / --control-port */
88 +
if (control_port)                 /* set by -controlport / --control-port / env */
63 89
    retro_control_start(control_port, &backend);
64 90
```
65 91
66 -
The socket loop, HTTP parsing, PPM encoding, JSON framing, and the
67 -
enqueue-to-emulator-thread marshalling live in the shared core — not in three
68 -
different codebases.
92 +
Stepping and pausing are driven cooperatively from the emulator's main loop, not
93 +
by a callback — three hooks at a frame boundary:
94 +
95 +
```c
96 +
retro_control_service();                 /* run a pending request on the emu thread */
97 +
if (!retro_control_running()) { idle; }  /* gate advancing the machine (pause/step) */
98 +
... run one frame ...
99 +
if (frame_completed) retro_control_on_frame();   /* tick the /step budget */
100 +
```
69 101
70 102
## Status
71 103
72 -
Early. `SPEC.md` (0.1.0) and the interfaces below are the stable target the
73 -
forks build against; the server `.c` and conformance suite are landing next.
74 -
Reference implementation: the Commander X16 fork (`x16-emulator`).
104 +
Contract **0.3.0**, implemented across **four** emulators. The shared server and
105 +
the conformance suite (15 tests, green against the reference) are complete.
106 +
Reference implementation: the Commander X16 fork (`x16-emulator`), which vendors
107 +
`core/` as a git submodule; the NEC PC-FX fork (Mednafen) does the same, and the
108 +
FAB Agon emulator conforms directly in Rust.
modified SPEC.md +4 −0
…
168 unchanged lines
169 169
  ```
170 170
- Framebuffer: from `vgabuf` (the VDP output); the offscreen path already keeps
171 171
  video alive (the FAB equivalent of x16's `SDL_VIDEODRIVER=dummy`).
172 +
- Audio: the VDP mixes 8-bit unsigned mono at 16384 Hz; `/audio` returns it as a
173 +
  16-bit signed PCM WAV. The SDL audio callback (the sole `getAudioSamples`
174 +
  consumer) tees each block into the ring; headless capture needs
175 +
  `SDL_AUDIODRIVER=dummy` so that callback runs.
172 176
- Input: `/key?text=c` maps the char to a fabgl virtual key (`ascii2vk`) and
173 177
  delivers it via `sendVKeyEventToFabgl`; `?code=<vk>` passes a raw fabgl vkey.
174 178
  The control thread queues events; the render thread delivers them (same
…
25 unchanged lines