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 |