OXYGENHUB

feat: contract 0.6 — GET/POST /perf, the performance metrics (budget, current frame, windows, zones, raw frames since one, a platform section) and their settings, and /status.capabilities; backend callbacks get_perf and set_perf return allocated JSON (#6) 8fd33bc520f9

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-10 20:21 UTC
Signature
✔ signed by Andy McCall
Hash
8fd33bc520f976b333697dc16f9a615f07227a1534df9906e0932cc843987274
Depends on
346ca3ee83f4 398756c0e9b2 4c0b050e9a25 674b8447022b 689a9ccb0138 8ac19619e24b 961b3c610c66 bed0f1e524fe d44319b1e712
Depended on by
nothing yet
In bookmarks
main perf-6
Edits
36 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified CHANGELOG.md +26 −0
…
6 unchanged lines
7 7
advertises the highest level whose backend callbacks are all present via
8 8
`/status.contract`.
9 9
10 +
## 0.6.0
11 +
- `GET /perf[?windows=][&zones=][&frames=][&since=]` — **performance metrics**:
12 +
  how much of each frame the program uses, in guest CPU cycles. `budget` (the
13 +
  frame and its budget), `current` (the last completed frame: total, work,
14 +
  idle, IRQ and main cycles, instructions, host time, utilisation), `windows`
15 +
  (min, mean, p50, p95, p99 and max per window, and overruns), `zones` (the
16 +
  program's own breakdown: address ranges, or regions the guest marks), raw
17 +
  per-frame records (`frames`, or every frame `since` one, so a harness never
18 +
  misses one), and `platform` for the rest. Backend callback: `get_perf`.
19 +
- `POST /perf` — settings as a JSON body: enabled, target FPS, idle mode,
20 +
  history, reset, and the zones (an address range, or a **name for a guest
21 +
  marker region**). Backend callback: `set_perf`.
22 +
- `/status.capabilities` — the endpoint groups a server serves, whatever level
23 +
  it reaches, so a client can ask for `perf` (or `pointer`) directly instead of
24 +
  inferring it from the cumulative ladder. Absent before 0.6.
25 +
- Both callbacks return allocated JSON the core frees, so the core stays
26 +
  platform-neutral: the backend owns the measurements and their shape.
27 +
- First backend: the enhanced X16 emulator, whose Performance panel and DAP
28 +
  `x16/perfStats` it answers from the same counters. Its `platform` section
29 +
  adds VERA's bus bandwidth per scanline and the sprite renderer's time; its
30 +
  guest markers are writes to `$9FBC`.
31 +
- Motivation: a released game that players found slow. Its port measured
32 +
  itself with scanline probes compiled into the game, at a cost in memory and
33 +
  with torn reads; the emulator already knew the answer and had no way to say
34 +
  it to a test.
35 +
10 36
## 0.5.0
11 37
- `POST /pad?index=[&buttons=][&connected=]` — present a **virtual game
12 38
  controller** and set what is held. `buttons` is the canonical mask (bit0 LEFT,
…
53 unchanged lines
modified README.md +9 −2
…
20 unchanged lines
21 21
| `GET /pointer` | read back pointer position + buttons | 0.4 |
22 22
| `POST /pad?index=&buttons=&connected=` | present a virtual controller, set what is held | 0.5 |
23 23
| `GET /pad?index=` | read back a pad's presence + held buttons | 0.5 |
24 +
| `GET /perf?windows=&frames=&since=` | performance metrics: the frame budget, work/idle/IRQ cycles, statistics windows, zones, raw frames | 0.6 |
25 +
| `POST /perf` | performance settings (JSON body): target FPS, idle mode, history, zones and marker names | 0.6 |
24 26
25 -
Current contract: **0.5.0**. Minors are purely additive (a missing capability
27 +
`/status` also lists `capabilities` from 0.6: the groups a server serves,
28 +
whatever level it reaches.
29 +
30 +
Current contract: **0.6.0**. Minors are purely additive (a missing capability
26 31
returns 501 and lowers the advertised contract), so a client reads
27 32
`/status.contract` and fails fast only on a MAJOR mismatch. See [SPEC.md](SPEC.md).
28 33
…
47 unchanged lines
76 81
77 82
/* Four callbacks satisfy 0.1; add inject_key/reset for 0.2,
78 83
   write_mem/capture_audio for 0.3, set_pointer/get_pointer for 0.4,
79 -
   set_pad/get_pad for 0.5.
84 +
   set_pad/get_pad for 0.5, get_perf/set_perf for 0.6.
80 85
   A NULL callback 501s its endpoint. */
81 86
static const retro_control_backend_t backend = {
82 87
    .platform        = "x16",
…
9 unchanged lines
92 97
    .set_pointer     = x16_set_pointer,      /* 0.4 */
93 98
    .set_pad         = x16_set_pad,          /* 0.5 */
94 99
    .get_pointer     = x16_get_pointer,      /* 0.4 */
100 +
    .get_perf        = x16_get_perf,         /* 0.6: malloc'd JSON */
101 +
    .set_perf        = x16_set_perf,         /* 0.6 */
95 102
};
96 103
97 104
if (control_port)                 /* set by -controlport / --control-port / env */
…
33 unchanged lines
modified SPEC.md +177 −5
1 1
# Retro Remote Debug Controller — HTTP Control Contract
2 2
3 -
**Contract version: 0.5.0** (semver; clients assert on the MAJOR). 0.5 adds a
4 -
virtual game controller (`POST /pad`, `GET /pad`); 0.4 added pointer injection
3 +
**Contract version: 0.6.0** (semver; clients assert on the MAJOR). 0.6 adds
4 +
performance metrics (`GET /perf`, `POST /perf`) and `/status.capabilities`; 0.5
5 +
added a virtual game controller (`POST /pad`, `GET /pad`); 0.4 added pointer injection
5 6
(`POST /pointer`, `GET /pointer`); 0.3 added memory write (`POST /mem`) and an
6 7
audio drain (`GET /audio`); 0.2 added input injection (`POST /key`,
7 8
`POST /reset`). Every minor is purely additive, so older clients keep working and
…
38 unchanged lines
46 47
Liveness + contract negotiation. Always available.
47 48
```json
48 49
{
49 -
  "contract": "0.5.0",
50 +
  "contract": "0.6.0",
50 51
  "emulator": "x16emu",
51 52
  "platform": "x16",
52 53
  "frame": 12345,
53 54
  "paused": true,
54 -
  "running": true
55 +
  "running": true,
56 +
  "capabilities": ["mem", "regs", "screenshot", "step", "key", "reset",
57 +
                   "mem-write", "audio", "pointer", "pad", "perf"]
55 58
}
56 59
```
57 60
- `contract` — this document's version. Clients MUST reject a mismatched MAJOR.
61 +
- `capabilities` *(0.6)* — the endpoint groups this server serves, whatever
62 +
  level `contract` reaches: `mem`, `regs`, `screenshot`, `step` (always), then
63 +
  `key`, `reset`, `mem-write` (`POST /mem`), `audio`, `pointer`, `pad`, `perf`.
64 +
  `contract` stays cumulative (0.6.0 means everything up to and including
65 +
  `/perf`); `capabilities` lets a client ask for one group directly, so a
66 +
  backend with no pointer can still offer `/perf` without a refusing stub.
67 +
  Absent on servers older than 0.6: read `contract` there.
58 68
- `platform` — one of `x16`, `neo6502`, `agon`, `pcfx` (extensible). Tells the
59 69
  client the memory/register model (see *Per-platform* appendix).
60 70
- `frame` — monotonic completed-frame counter since boot. The determinism
…
169 unchanged lines
230 240
- `X-Rrdc-Audio-Dropped: <n>` — samples the ring dropped (oldest-first) to
231 241
  overflow since the last drain. Non-zero means drain more often. `0` when clean.
232 242
243 +
### `GET /perf  [?windows=<list>] [&zones=0|1] [&frames=<n>] [&since=<frame>]`  *(0.6)*
244 +
How much of each frame the program uses: the measure a profiler gives, read by
245 +
a test the way `/mem` is. Answered on the emulator thread (see *Consistency*),
246 +
so it describes whole frames.
247 +
248 +
Query params, all OPTIONAL:
249 +
- `windows` — comma-separated statistics windows, each a number of seconds or
250 +
  `session` (the whole history). Default `1,10,30,60,session`.
251 +
- `zones` — `0` leaves `zones` out. Default `1`.
252 +
- `frames` — return the last `n` completed frames' raw records in `frames`
253 +
  (oldest first). Default `0`: none.
254 +
- `since` — return the raw records of every frame after `<frame>` still in the
255 +
  history (oldest first; `frames` then caps how many, the newest kept). Lets a
256 +
  harness poll and never miss a frame: pass the last `frame` it saw.
257 +
- A platform MAY accept more (the X16: `lines=1`, below); unknown params are
258 +
  ignored.
259 +
260 +
Every count is in **guest CPU cycles** unless its name says otherwise (`…Us`:
261 +
host microseconds; `…Pct`: percent). The shape:
262 +
263 +
```json
264 +
{
265 +
  "enabled": true,
266 +
  "budget": {
267 +
    "targetFps": 60, "vsyncHz": 59.52, "machineKhz": 8000,
268 +
    "cyclesPerFrame": 134400, "scanlinesPerFrame": 525, "cyclesPerScanline": 256,
269 +
    "budgetCycles": 134400, "framesPerBudget": 1,
270 +
    "historyFrames": 3600, "totalFrames": 7200, "idleMode": "auto"
271 +
  },
272 +
  "current": {
273 +
    "frame": 7199, "totalCycles": 134400, "workCycles": 31012,
274 +
    "idleCycles": 103388, "irqCycles": 3297, "mainCycles": 27715,
275 +
    "budgetWorkCycles": 31012, "instructions": 9120, "hostUs": 412,
276 +
    "utilizationPct": 23.1, "overBudget": false
277 +
  },
278 +
  "windows": [
279 +
    { "name": "1s", "seconds": 1, "frames": 60, "budgetCycles": 134400,
280 +
      "work":       {"min": 0, "mean": 0, "p50": 0, "p95": 0, "p99": 0, "max": 0},
281 +
      "budgetWork": {"min": 0, "mean": 0, "p50": 0, "p95": 0, "p99": 0, "max": 0},
282 +
      "total": {"…": 0}, "irq": {"…": 0}, "hostUs": {"…": 0},
283 +
      "overruns": 0, "overrunPct": 0.0, "worstOverrunCycles": 0, "worstFrame": 0,
284 +
      "meanUtilizationPct": 23.1, "p95UtilizationPct": 31.0, "maxUtilizationPct": 40.2 }
285 +
  ],
286 +
  "zones": [
287 +
    { "id": 0, "name": "sound", "kind": "marker", "markerId": 7, "idle": false,
288 +
      "current": 11520,
289 +
      "windows": { "1s": {"min": 0, "mean": 0, "p50": 0, "p95": 0, "p99": 0, "max": 0} } },
290 +
    { "id": 1, "name": "vblank wait", "kind": "address",
291 +
      "start": 49152, "end": 49408, "bank": -1, "idle": true, "current": 0, "windows": {} }
292 +
  ],
293 +
  "frames": [
294 +
    { "frame": 7198, "totalCycles": 134400, "workCycles": 30544,
295 +
      "idleCycles": 103856, "irqCycles": 3297, "instructions": 9001,
296 +
      "hostUs": 398, "budgetWorkCycles": 30544, "zones": [11520, 0] }
297 +
  ],
298 +
  "platform": { }
299 +
}
300 +
```
301 +
302 +
- `enabled` — whether the backend is measuring. Off, every count is 0 and the
303 +
  history is empty; `POST /perf {"enabled":true}` turns it on. A backend MAY
304 +
  start measuring on the first `/perf` request.
305 +
- `budget` — the frame and its budget. `budgetCycles` is the cycles the program
306 +
  may use per budget period of `framesPerBudget` frames (a 30 fps target on a
307 +
  60 Hz machine: 2 frames). `cyclesPerScanline` converts a count to scanlines.
308 +
- `current` — the last **completed** frame. `workCycles` = total − idle;
309 +
  `mainCycles` = work − irq. `idleCycles` are cycles the backend classifies as
310 +
  waiting (see *Idle*). `budgetWorkCycles` is the work over the trailing budget
311 +
  period, which `utilizationPct` and `overBudget` measure against `budgetCycles`.
312 +
- `windows` — per requested window: the frames it covers and `{min, mean, p50,
313 +
  p95, p99, max}` of each series (`budgetWork` per budget period, the rest per
314 +
  frame); `overruns` (periods over budget), their share, and the worst.
315 +
- `zones` — the program's own breakdown (see *Zones*). `current` is the zone's
316 +
  cycles in the last completed frame; `windows` its `{min…max}` per window, where
317 +
  the backend keeps a zone's history (`{}` where it does not). A cycle belongs
318 +
  to one zone at most: the innermost open marker region, else the address zone
319 +
  the PC is in.
320 +
- `frames` — raw per-frame records, `zones` indexed by zone `id`.
321 +
- `platform` — anything platform-specific, described in the *Per-platform
322 +
  appendix* (the X16's VERA bus bandwidth and sprite render time). A client
323 +
  that does not know the platform ignores it.
324 +
325 +
**Idle.** A backend counts as idle what the CPU spends waiting: a halt
326 +
instruction (`WAI`, `HALT`), a recognised spin loop, an address zone marked
327 +
idle, or what the guest marks so. `idleMode` says which: `auto` (all of them),
328 +
`markers` (only what the guest marks), `none`.
329 +
330 +
**Zones.** Two kinds, both optional:
331 +
- *address* zones — a PC range `[start, end)` (and `bank`, `-1` any), set by
332 +
  `POST /perf`.
333 +
- *marker* regions — the guest program writes markers to a platform-defined
334 +
  register as it enters and leaves a region of its own (the X16: `$9FBC`, see
335 +
  the appendix). A marker region exists once the guest first enters it, named
336 +
  `region <n>` unless `POST /perf` has named it.
337 +
338 +
### `POST /perf`  *(0.6)*
339 +
Settings. The body is a JSON object; every field is OPTIONAL, and a field left
340 +
out is left as it is:
341 +
```json
342 +
{
343 +
  "enabled": true,
344 +
  "targetFps": 60,
345 +
  "idleMode": "auto",
346 +
  "historyFrames": 3600,
347 +
  "reset": false,
348 +
  "zones": [
349 +
    { "name": "game", "marker": 1 },
350 +
    { "name": "vblank wait", "start": 49152, "end": 49408, "bank": -1, "idle": true }
351 +
  ]
352 +
}
353 +
```
354 +
- `enabled` — start or stop measuring. Stopping MAY discard the history.
355 +
- `targetFps` — the rate the program aims for, which sets `budget`.
356 +
- `idleMode` — `auto`, `markers` or `none` (see *Idle*).
357 +
- `historyFrames` — how many frames of history to keep.
358 +
- `reset` — `true` empties the history and the statistics.
359 +
- `zones` — **replaces** the zone list. An entry with `marker` names the guest's
360 +
  marker region `<n>`; one with `start`/`end` is an address zone.
361 +
- A platform MAY accept more (the X16: `sprites`, below); unknown fields are
362 +
  ignored.
363 +
- Response: `200` and the `GET /perf` body without `windows`, `zones` or
364 +
  `frames`, i.e. the settings as they now stand. A body that is not a JSON
365 +
  object, or a field of the wrong type, is `400`.
366 +
233 367
## Consistency (normative)
234 368
235 369
- `/mem`, `/regs`, and `/step` MUST be serviced on the emulator thread at an
236 370
  instruction boundary — e.g. the HTTP handler enqueues a command that the main
237 371
  loop drains between instructions/frames and signals back. The HTTP thread MUST
238 372
  NOT read CPU/RAM state directly while the CPU is executing.
373 +
- `/perf` MUST be answered on the emulator thread, and describe completed
374 +
  frames only (`current` is the last whole frame, never the one in progress).
239 375
- `/screenshot` MUST serve a complete-frame snapshot (double-buffer: render
240 376
  thread writes the back buffer, swaps under a lock at frame end; HTTP thread
241 377
  reads the front buffer).
…
29 unchanged lines
271 407
- Pad: `/pad` presents a virtual controller through the emulator's own joystick
272 408
  read path. The fork implements the full 0.5 callback set and advertises
273 409
  **0.5.0**.
410 +
- Perf *(0.6)*: the **enhanced X16 emulator** (`enhanced-x16-emulator`, the one
411 +
  with the ImGui Performance panel) implements `/perf` from the same
412 +
  measurements the panel shows, and advertises **0.6.0**; the plain fork does
413 +
  not measure, and stays at 0.5.0. The budget is the X16's: 8 MHz, 525
414 +
  scanlines a frame at 59.52 Hz, 134,400 cycles, 256 a scanline. Idle: `WAI`,
415 +
  a short store-free backward branch taken twice, or markers.
416 +
  - Markers: the guest writes one byte to **`$9FBC`** (the emulator's register
417 +
    12; reading it returns `1`, the marker protocol, so a program can tell it
418 +
    is there): `$01`–`$3F` enter region n, `$80|n` leave region n, `$00` leave
419 +
    the innermost, `$F0` the frame's work begins, `$F1` the frame's work ends
420 +
    (the rest of the frame is idle), `$FE` reset the statistics. Up to 8
421 +
    regions nest; cycles go to the innermost. A write to a stock X16 or the
422 +
    plain fork does nothing.
423 +
  - Zones: up to 16; the first 8 keep a history (`windows`), the rest report
424 +
    `current` only.
425 +
  - `platform`:
426 +
    ```json
427 +
    { "bandwidth": { "frame": 7198, "lineClocks": 800, "linesActive": 480,
428 +
        "peakLine": 12, "peakLineClocks": 480, "p95LineClocks": 480,
429 +
        "meanLineClocks": 320, "linesOverBudget": 0,
430 +
        "layers": [ {"fetches": 57600, "bytes": 230400, "peakLine": 0, "peakFetches": 120},
431 +
                    {"fetches": 57600, "bytes": 230400, "peakLine": 0, "peakFetches": 120} ],
432 +
        "port": {"readBytes": 0, "writeBytes": 64, "accesses": 64, "vramBytes": 64},
433 +
        "lines": [320, 320, "…480 entries"] },
434 +
      "sprites": { "enabled": true, "frame": 7198, "lineBudget": 798,
435 +
        "peakLine": 40, "peakBudget": 612, "peakDemand": 612,
436 +
        "linesExhausted": 0, "spritesDropped": 0, "budgetUsedTotal": 51200,
437 +
        "slotsUsed": 24, "lines": [0, 0, "…480 entries"] } }
438 +
    ```
439 +
    `bandwidth` is VERA's VRAM bus in **bus clocks** (800 a scanline; a layer
440 +
    fetch or a CPU data-port access takes 2), for the last completed frame;
441 +
    `lines` (only with `?lines=1`) is each active line's clocks.
442 +
    `sprites` is the sprite renderer's time per line against its 798-clock
443 +
    budget, for the last frame; it is off until `POST /perf {"sprites":true}`
444 +
    (it costs the emulator time), `{"enabled":false}` until then.
274 445
275 446
### Neo6502 — neo6502 emulator
276 447
- Flag: `--control-port <N>`.
…
78 unchanged lines
355 526
- **Vendor the shared core** (`core/retro_control.c` + `.h`) — for C/C++
356 527
  emulators. Implement the backend callbacks (four are enough for 0.1; add
357 528
  `inject_key`/`reset` for 0.2, `write_mem`/`capture_audio` for 0.3,
358 -
  `set_pointer`/`get_pointer` for 0.4, and `set_pad`/`get_pad` for 0.5 — a NULL
529 +
  `set_pointer`/`get_pointer` for 0.4, `set_pad`/`get_pad` for 0.5, and
530 +
  `get_perf`/`set_perf` (which return allocated JSON) for 0.6 — a NULL
359 531
  callback makes its endpoint return 501 and lowers the advertised contract, so a
360 532
  platform lacking a device should supply a stub that returns 400 instead), wire
361 533
  three loop hooks, call
…
15 unchanged lines
modified client/emu_control.py +35 −1
1 1
"""emu_control.py — shared client for the Retro Remote Debug Controller.
2 2
3 3
One client for every port's pytest suite. Speaks the HTTP contract in
4 -
../SPEC.md (0.4.x). Depends only on the stdlib; Pillow is optional and only
4 +
../SPEC.md (0.6.x). Depends only on the stdlib; Pillow is optional and only
5 5
needed for .screenshot().
6 6
7 7
    from emu_control import EmuControl
…
210 unchanged lines
218 218
        body, _ = self._get(f"/pad?index={index}")
219 219
        return json.loads(body)
220 220
221 +
    def capabilities(self) -> set[str]:
222 +
        """/status.capabilities as a set (contract 0.6); empty on older
223 +
        servers, which have no such field."""
224 +
        return set(self.status().get("capabilities") or ())
225 +
226 +
    def perf(self, windows=None, zones: bool = True, frames: int = 0,
227 +
             since: int | None = None, **extra) -> dict:
228 +
        """GET /perf — the performance metrics (contract 0.6).
229 +
230 +
        `windows` is a list of seconds or "session" (default: the server's,
231 +
        1, 10, 30, 60 and session). `frames=n` adds the last n frames' raw
232 +
        records; `since=f` every frame after f still in the history (pass the
233 +
        last frame you saw to never miss one). `extra` goes on the query
234 +
        string as it is (a platform's own params, e.g. lines=1 on the X16)."""
235 +
        parts = []
236 +
        if windows is not None:
237 +
            parts.append("windows=" + ",".join(str(w) for w in windows))
238 +
        if not zones:
239 +
            parts.append("zones=0")
240 +
        if frames:
241 +
            parts.append(f"frames={frames}")
242 +
        if since is not None:
243 +
            parts.append(f"since={since}")
244 +
        parts += [f"{k}={v}" for k, v in extra.items()]
245 +
        body, _ = self._get("/perf" + ("?" + "&".join(parts) if parts else ""))
246 +
        return json.loads(body)
247 +
248 +
    def perf_config(self, **settings) -> dict:
249 +
        """POST /perf — settings (contract 0.6): enabled, targetFps,
250 +
        idleMode, historyFrames, reset, zones (a list of {"name", "marker"} or
251 +
        {"name", "start", "end", "bank", "idle"}; replaces the list), and a
252 +
        platform's own (the X16: sprites). Returns the settings as they stand."""
253 +
        return json.loads(self._post("/perf", json.dumps(settings).encode()))
254 +
221 255
    def screenshot_ppm(self) -> bytes:
222 256
        """Return the live screen as raw PPM (P6) bytes. Stdlib-only."""
223 257
        body, _ = self._get("/screenshot")
…
29 unchanged lines
modified conformance/test_conformance.py +92 −2
…
9 unchanged lines
10 10
from emu_control import parse_ppm_header
11 11
12 12
13 -
def _raw(emu, method, path):
13 +
def _raw(emu, method, path, body=None):
14 14
    """Raw request returning (status_code, body) without the client's wrapping,
15 15
    so we can assert on 4xx status codes."""
16 -
    req = urllib.request.Request(emu.base + path, method=method)
16 +
    req = urllib.request.Request(emu.base + path, method=method, data=body)
17 17
    try:
18 18
        with urllib.request.urlopen(req, timeout=emu.timeout) as r:
19 19
            return r.status, r.read()
…
256 unchanged lines
276 276
    finally:
277 277
        emu.pad(connected=False)
278 278
        emu.resume()
279 +
280 +
281 +
# -- contract 0.6: performance metrics (skipped on < 0.6 servers) ------------
282 +
283 +
def test_capabilities_list_the_groups(emu):
284 +
    """/status.capabilities names each group the server serves. Always the
285 +
    four 0.1 groups; at 0.6 also perf, and the groups below it the ladder
286 +
    implies."""
287 +
    if _contract_minor(emu) < 6:
288 +
        pytest.skip("contract < 0.6 (no capabilities)")
289 +
    caps = emu.capabilities()
290 +
    for c in ("mem", "regs", "screenshot", "step", "key", "mem-write", "audio", "pointer", "pad", "perf"):
291 +
        assert c in caps, (c, caps)
292 +
293 +
294 +
def _perf_on(emu):
295 +
    emu.perf_config(enabled=True)
296 +
297 +
298 +
def test_perf_shape(emu):
299 +
    """GET /perf's sections and their arithmetic: work = total - idle, main =
300 +
    work - irq, the windows asked for, raw frames oldest first. The numbers
301 +
    themselves are the platform's and the program's; only their relations are
302 +
    the contract's."""
303 +
    if _contract_minor(emu) < 6:
304 +
        pytest.skip("contract < 0.6")
305 +
    _perf_on(emu)
306 +
    emu.pause()
307 +
    try:
308 +
        emu.step(10)
309 +
        p = emu.perf(windows=[1, "session"], frames=5)
310 +
    finally:
311 +
        emu.resume()
312 +
    assert p["enabled"] is True
313 +
    b = p["budget"]
314 +
    for k in ("targetFps", "vsyncHz", "cyclesPerFrame", "scanlinesPerFrame", "cyclesPerScanline",
315 +
              "budgetCycles", "framesPerBudget", "historyFrames", "totalFrames", "idleMode"):
316 +
        assert k in b, k
317 +
    assert b["cyclesPerFrame"] > 0 and b["budgetCycles"] > 0
318 +
    c = p["current"]
319 +
    assert c["workCycles"] == c["totalCycles"] - c["idleCycles"], c
320 +
    assert c["mainCycles"] == c["workCycles"] - c["irqCycles"], c
321 +
    assert isinstance(c["overBudget"], bool)
322 +
    assert [w["name"] for w in p["windows"]] == ["1s", "session"], p["windows"]
323 +
    for w in p["windows"]:
324 +
        for series in ("work", "budgetWork", "total", "irq", "hostUs"):
325 +
            s = w[series]
326 +
            assert s["min"] <= s["p50"] <= s["p95"] <= s["p99"] <= s["max"], (series, s)
327 +
    assert isinstance(p["zones"], list) and isinstance(p["platform"], dict)
328 +
    frames = [f["frame"] for f in p["frames"]]
329 +
    assert 1 <= len(frames) <= 5 and frames == sorted(frames), frames
330 +
    assert frames[-1] == c["frame"], (frames, c["frame"])
331 +
332 +
333 +
def test_perf_since_misses_no_frame(emu):
334 +
    """since=<f> returns every completed frame after f, so a harness that
335 +
    polls with the last frame it saw gets each frame exactly once."""
336 +
    if _contract_minor(emu) < 6:
337 +
        pytest.skip("contract < 0.6")
338 +
    _perf_on(emu)
339 +
    emu.pause()
340 +
    try:
341 +
        emu.step(2)
342 +
        last = emu.perf(zones=False)["current"]["frame"]
343 +
        emu.step(5)
344 +
        got = [f["frame"] for f in emu.perf(zones=False, since=last)["frames"]]
345 +
    finally:
346 +
        emu.resume()
347 +
    assert got == list(range(last + 1, last + 6)), (last, got)
348 +
349 +
350 +
def test_perf_zones_name_a_marker_region(emu):
351 +
    """POST /perf's zones replace the list, and a {name, marker} entry names
352 +
    the guest's marker region; the zone is reported under that name whether
353 +
    or not the program has entered it yet."""
354 +
    if _contract_minor(emu) < 6:
355 +
        pytest.skip("contract < 0.6")
356 +
    try:
357 +
        emu.perf_config(enabled=True, zones=[{"name": "conformance", "marker": 63}])
358 +
        names = [z["name"] for z in emu.perf()["zones"]]
359 +
        assert "conformance" in names, names
360 +
    finally:
361 +
        emu.perf_config(zones=[])
362 +
363 +
364 +
def test_perf_rejects_a_bad_body(emu):
365 +
    if _contract_minor(emu) < 6:
366 +
        pytest.skip("contract < 0.6")
367 +
    code, _ = _raw(emu, "POST", "/perf", b"not json")
368 +
    assert code == 400
modified core/retro_control.c +66 −4
…
48 unchanged lines
49 49
/* single-slot marshalled request. */
50 50
typedef enum { REQ_NONE = 0, REQ_MEM, REQ_REGS, REQ_SHOT, REQ_KEY, REQ_RESET,
51 51
               REQ_WRITE, REQ_AUDIO, REQ_PTR_SET, REQ_PTR_GET,
52 -
               REQ_PAD_SET, REQ_PAD_GET } req_t;
52 +
               REQ_PAD_SET, REQ_PAD_GET, REQ_PERF_GET, REQ_PERF_SET } req_t;
53 53
static volatile req_t g_req = REQ_NONE;
54 54
static uint32_t g_req_addr, g_req_len;
55 55
static int32_t  g_req_bank;
…
8 unchanged lines
64 64
static int      g_pad_buttons;   /* held mask, or -1 = leave unchanged */
65 65
static int      g_pad_connected; /* 1 plug, 0 unplug, -1 = unchanged */
66 66
static char     g_resp_extra[64];     /* extra response header line, or "" */
67 +
static char     g_perf_query[1024];   /* GET /perf's query string (0.6) */
68 +
static const char *g_perf_body;       /* POST /perf's body, NUL-terminated (HTTP-thread owned) */
67 69
68 70
static uint8_t    *g_resp_body  = NULL;
69 71
static size_t      g_resp_len   = 0;
…
241 unchanged lines
311 313
        g_resp_ctype = "application/json";
312 314
        break;
313 315
    }
316 +
    case REQ_PERF_GET:
317 +
    case REQ_PERF_SET: {
318 +
        char *b = NULL;
319 +
        int st = 200;
320 +
        int have = (g_req == REQ_PERF_GET) ? g_be->get_perf != NULL : g_be->set_perf != NULL;
321 +
        if (!have) {
322 +
            st = 501;
323 +
        } else if (g_req == REQ_PERF_GET) {
324 +
            b = g_be->get_perf(g_perf_query);
325 +
        } else {
326 +
            b = g_be->set_perf(g_perf_body ? g_perf_body : "", &st);
327 +
            if (st != 200 && st != 400) st = 400;
328 +
        }
329 +
        if (!b) {                             /* not implemented, or the backend failed */
330 +
            const char *json = (st == 501) ? "{\"error\":\"not implemented\"}"
331 +
                                           : "{\"error\":\"perf failed\"}";
332 +
            if (st != 501) st = 500;
333 +
            size_t l = strlen(json);
334 +
            b = (char *)malloc(l + 1);
335 +
            if (b) memcpy(b, json, l + 1);
336 +
        }
337 +
        g_resp_body = (uint8_t *)b; g_resp_len = b ? strlen(b) : 0;
338 +
        g_resp_status = st;
339 +
        g_resp_ctype = "application/json";
340 +
        break;
341 +
    }
314 342
    default: break;
315 343
    }
316 344
    g_req = REQ_NONE;
…
47 unchanged lines
364 392
        status == 400 ? "Bad Request" :
365 393
        status == 404 ? "Not Found" :
366 394
        status == 405 ? "Method Not Allowed" :
395 +
        status == 500 ? "Internal Server Error" :
367 396
        status == 501 ? "Not Implemented" : "Error";
368 397
    char hdr[320];
369 398
    int hl = snprintf(hdr, sizeof hdr,
…
70 unchanged lines
440 469
    pthread_mutex_unlock(&g_lock);
441 470
    unsigned long long fc = g_be->get_frame_count ? g_be->get_frame_count() : 0;
442 471
    /* Advertise the highest level whose defining callbacks are all present. */
472 +
    const int perf = g_be->get_perf && g_be->set_perf;
443 473
    const char *contract =
474 +
        (g_be->inject_key && g_be->write_mem && g_be->capture_audio &&
475 +
         g_be->set_pointer && g_be->get_pointer &&
476 +
         g_be->set_pad && g_be->get_pad && perf) ? "0.6.0" :
444 477
        (g_be->inject_key && g_be->write_mem && g_be->capture_audio &&
445 478
         g_be->set_pointer && g_be->get_pointer &&
446 479
         g_be->set_pad && g_be->get_pad) ? "0.5.0" :
…
1 unchanged lines
448 481
         g_be->set_pointer && g_be->get_pointer) ? "0.4.0" :
449 482
        (g_be->inject_key && g_be->write_mem && g_be->capture_audio) ? "0.3.0" :
450 483
        g_be->inject_key ? "0.2.0" : "0.1.0";
451 -
    char buf[320];
484 +
    /* 0.6: each endpoint group the backend serves, whatever level it reaches. */
485 +
    char caps[160];
486 +
    snprintf(caps, sizeof caps, "\"mem\",\"regs\",\"screenshot\",\"step\"%s%s%s%s%s%s%s",
487 +
        g_be->inject_key ? ",\"key\"" : "",
488 +
        g_be->reset ? ",\"reset\"" : "",
489 +
        g_be->write_mem ? ",\"mem-write\"" : "",
490 +
        g_be->capture_audio ? ",\"audio\"" : "",
491 +
        (g_be->set_pointer && g_be->get_pointer) ? ",\"pointer\"" : "",
492 +
        (g_be->set_pad && g_be->get_pad) ? ",\"pad\"" : "",
493 +
        perf ? ",\"perf\"" : "");
494 +
    char buf[512];
452 495
    snprintf(buf, sizeof buf,
453 496
        "{\"contract\":\"%s\",\"emulator\":\"%s\",\"platform\":\"%s\","
454 -
        "\"frame\":%llu,\"paused\":%s,\"running\":true}",
497 +
        "\"frame\":%llu,\"paused\":%s,\"running\":true,\"capabilities\":[%s]}",
455 498
        contract,
456 499
        g_be->emulator ? g_be->emulator : "",
457 500
        g_be->platform ? g_be->platform : "",
458 -
        fc, (f == 0) ? "true" : "false");
501 +
        fc, (f == 0) ? "true" : "false", caps);
459 502
    send_json(fd, 200, buf);
460 503
}
461 504
…
165 unchanged lines
627 670
            do_marshalled(fd, REQ_PAD_SET);
628 671
        } else {                              /* 0.5: read a pad back */
629 672
            do_marshalled(fd, REQ_PAD_GET);
673 +
        }
674 +
        return;
675 +
    }
676 +
    if (!strcmp(target, "/perf")) {           /* 0.6: performance metrics */
677 +
        if (is_post) {
678 +
            size_t blen = 0;
679 +
            uint8_t *body = read_body(fd, req, n, &blen);
680 +
            char *text = (char *)malloc(blen + 1);
681 +
            if (!text) { free(body); send_json(fd, 500, "{\"error\":\"out of memory\"}"); return; }
682 +
            if (body && blen) memcpy(text, body, blen);
683 +
            text[blen] = 0;
684 +
            free(body);
685 +
            g_perf_body = text;
686 +
            do_marshalled(fd, REQ_PERF_SET);
687 +
            g_perf_body = NULL;
688 +
            free(text);
689 +
        } else {
690 +
            snprintf(g_perf_query, sizeof g_perf_query, "%s", query);
691 +
            do_marshalled(fd, REQ_PERF_GET);
630 692
        }
631 693
        return;
632 694
    }
…
57 unchanged lines
modified core/retro_control.h +23 −5
…
26 unchanged lines
27 27
#include <stddef.h>
28 28
29 29
/* Highest contract the core implements. The server advertises the highest level
30 -
 * whose backend callbacks are all present: 0.5.0 needs set_pad + get_pad (on
31 -
 * top of 0.4); 0.4.0 needs set_pointer + get_pointer (on top of 0.3); 0.3.0
32 -
 * needs inject_key + write_mem + capture_audio; 0.2.0 needs inject_key; else
33 -
 * 0.1.0. Keeps a partial backend honest. */
34 -
#define RETRO_CONTROL_CONTRACT "0.5.0"
30 +
 * whose backend callbacks are all present: 0.6.0 needs get_perf + set_perf (on
31 +
 * top of 0.5); 0.5.0 needs set_pad + get_pad (on top of 0.4); 0.4.0 needs
32 +
 * set_pointer + get_pointer (on top of 0.3); 0.3.0 needs inject_key +
33 +
 * write_mem + capture_audio; 0.2.0 needs inject_key; else 0.1.0. Keeps a
34 +
 * partial backend honest. /status.capabilities lists each group a backend
35 +
 * serves whatever level it reaches, so a client can ask for one directly. */
36 +
#define RETRO_CONTROL_CONTRACT "0.6.0"
35 37
36 38
/* Native framebuffer pixel layout; the server swizzles to PPM RGB. */
37 39
typedef enum {
…
102 unchanged lines
140 142
     * Return 1 on success (values written), 0 if unsupported. NULL => GET /pad
141 143
     * returns 501. */
142 144
    int (*get_pad)(int index, int *buttons, int *connected);
145 +
146 +
    /* 0.6: the performance metrics, as the JSON body of GET /perf (SPEC
147 +
     * "GET /perf"). `query` is the request's raw query string ("" for none:
148 +
     * windows=, zones=, frames=, since=), for the backend to read. Return a
149 +
     * malloc'd NUL-terminated string the server frees, or NULL on failure
150 +
     * (server -> 500). Called on the emulator thread, so the metrics are read
151 +
     * at a frame/instruction boundary like /mem. NULL => GET /perf returns 501
152 +
     * and the server does not advertise 0.6.0. */
153 +
    char *(*get_perf)(const char *query);
154 +
155 +
    /* 0.6: apply a POST /perf settings body (a JSON object, NUL-terminated;
156 +
     * SPEC "POST /perf"). Set *status to 200, or 400 for a body the backend
157 +
     * cannot apply, and return a malloc'd JSON response the server frees (for
158 +
     * 200, the settings as they now stand; for 400, {"error":...}). NULL
159 +
     * return => 500. NULL callback => POST /perf returns 501. */
160 +
    char *(*set_perf)(const char *body, int *status);
143 161
} retro_control_backend_t;
144 162
145 163
/* Start the server thread bound to 127.0.0.1:port. Non-blocking; 0 on success,
…
20 unchanged lines