Skip to content

Commit 0a4b5c0

Browse files
sp00ktoberclaude
andauthored
feat(ppsspp-debug): typed PPSSPP debugger client, blocking and asyncio (#3)
Refs sp00ktober/MHFU-ModKit#59 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 90db6a3 commit 0a4b5c0

22 files changed

Lines changed: 3495 additions & 4 deletions

‎.github/workflows/ci.yml‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,24 @@ jobs:
3636
enable-cache: true
3737
- run: uv sync --locked
3838
- run: uv run pytest
39+
40+
# advisory until the first release: breaking changes show up as annotations on the PR
41+
api:
42+
if: github.event_name == 'pull_request'
43+
runs-on: ubuntu-latest
44+
steps:
45+
- uses: actions/checkout@v7
46+
with:
47+
fetch-depth: 0
48+
- uses: astral-sh/setup-uv@v10.2.0
49+
- name: griffe check against the base branch
50+
continue-on-error: true
51+
run: |
52+
base=origin/${{ github.base_ref }}
53+
status=0
54+
for init in packages/*/src/*/__init__.py; do
55+
git cat-file -e "$base:$init" 2>/dev/null || continue # new package
56+
module=${init%/__init__.py}
57+
uvx griffe@2.3.0 check "${module##*/}" -s "${module%/*}" -a "$base" -f github || status=1
58+
done
59+
exit $status

‎.pre-commit-config.yaml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ repos:
2424

2525
- repo: local
2626
hooks:
27+
# the workspace's own mypy, so it sees every package's dependencies
28+
- id: mypy
29+
name: mypy --strict
30+
language: system
31+
entry: uv run --frozen mypy
32+
types_or: [python, pyi]
33+
pass_filenames: false
2734
# the pattern is matched against UTF-8 bytes: the first three branches are emoji ranges
2835
- id: comment-style
2936
name: comments say what and why (no emoji, dates, retractions, Phase N, #NN)

‎README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@ uv run pytest
3232
```
3333

3434
Tests that read game files take them from `MHFU_DATA` and `MHP3RD_DATA`, each a directory of
35-
extracted `DATA.BIN` files, and skip when the variable is unset. Nothing from the games is
36-
committed here: no ISO, no extracted files, no memory dumps.
35+
extracted `DATA.BIN` files, and skip when the variable is unset; tests against a running PPSSPP
36+
take `PPSSPP_BINARY` and `PPSSPP_GAME` the same way. Nothing from the games is committed here:
37+
no ISO, no extracted files, no memory dumps.
3738

3839
## Licence
3940

‎packages/modkit-testing/tests/test_game_data.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ def run(fixture, value):
99
if value is not None:
1010
monkeypatch.setenv(fixture.upper(), str(value))
1111
pytester.makepyfile(f"def test_it({fixture}):\n assert {fixture}.is_dir()\n")
12-
return pytester.runpytest()
12+
return pytester.runpytest("-p", "no:asyncio")
1313

1414
return run
1515

‎packages/ppsspp-debug/README.md‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# ppsspp-debug
2+
3+
A typed client for [PPSSPP](https://www.ppsspp.org/)'s WebSocket debugger: read and write memory,
4+
set breakpoints and watchpoints, pause and step the CPU, press buttons. `Client` blocks,
5+
`AsyncClient` is for asyncio; both have the same methods. Nothing in it is specific to one
6+
game.
7+
8+
```python
9+
from ppsspp_debug import Client
10+
11+
with Client.connect() as ppsspp: # finds the PPSSPP running on this machine
12+
ppsspp.read_u32(address)
13+
with ppsspp.paused(): # one stop for many reads, all from the same frame
14+
data = ppsspp.read(address, 0x100)
15+
regs = ppsspp.registers()
16+
with ppsspp.watchpoint(address, 2) as hits: # removed when the block ends
17+
hit = hits.next(timeout=30) # the CPU is now stopped at the writer
18+
print(hex(hit.pc), ppsspp.registers()["a0"])
19+
ppsspp.resume()
20+
```
21+
22+
```python
23+
from ppsspp_debug import AsyncClient
24+
25+
async with AsyncClient.connect(port=12345) as ppsspp:
26+
async with ppsspp.breakpoint(address, stop=False, log_format="a0={a0}") as calls:
27+
async for call in calls: # the game keeps running
28+
print(call.message)
29+
```
30+
31+
PPSSPP opens the debugger at startup only with `RemoteDebuggerOnStartup = True` in its
32+
`ppsspp.ini`, or from Settings > Tools > Developer tools.
33+
34+
## What it handles for you
35+
36+
| PPSSPP (1.20.4) | The client |
37+
|---|---|
38+
| Sends its log only to the newest debugger connection, and stops it for all when any connection closes | Log streams get a connection of their own, opened when the stream starts |
39+
| Does not say why the CPU stopped | Matches a breakpoint stop by its pc; counts as a watchpoint hit any stop this client did not ask for |
40+
| Answers `cpu.stepping` and `cpu.resume` with a broadcast, not a reply; pausing a paused CPU answers nothing | `pause()` and `resume()` wait for the broadcast, and are fine to call twice |
41+
| Stops and restarts the CPU for every memory request while the game runs (about 10 ms each) | `paused()` makes each request about 0.3 ms; one `read()` of a whole struct costs one stop |
42+
| Keeps breakpoints armed after the script that set them has died | `breakpoint()` and `watchpoint()` remove theirs when the block ends, however it ends, and let the game run if a hit arrived that nobody took |
43+
| Reports errors as events, and an unknown event as an error | Raises `DebuggerError`, or `Unsupported` for an event this PPSSPP does not have |
44+
| Moves to a random port when its own is busy, and saves that port to `ppsspp.ini` | Reads the port from the PPSSPP process: `find_debuggers()`, `LocalEmulator.port()` |
45+
| Stalls the WebSocket handshake while it announces itself to report.ppsspp.org at startup | `connect()` keeps trying until its timeout (30 s by default) |
46+
| Sends input and game events unasked, and buffers the log while it is switched off | Switches on only the kinds an open stream wants, and drops what was held back |
47+
48+
There are no callbacks: events arrive as a stream read in your own code, so nothing the reader
49+
does can wait on a reply only the reader can deliver.
50+
51+
## Starting PPSSPP
52+
53+
```python
54+
from ppsspp_debug import Client, DockerEmulator, LocalEmulator
55+
56+
with LocalEmulator("/path/to/PPSSPPSDL", "/path/to/game.iso") as emu:
57+
with Client.connect(port=emu.port()) as ppsspp:
58+
ppsspp.wait_for_game()
59+
```
60+
61+
`DockerEmulator(container)` drives PPSSPP in the modkit's headless container through its
62+
`ppsspp-ctl`, and relaunches it when it dies right after reporting ready. A `state=` launch
63+
skips the cold boot, so PRX plugins do not load.
64+
65+
## Patched PPSSPP
66+
67+
`save_state()`, `load_state()` and `speed()` need the PPSSPP patches that ship with the modkit;
68+
stock PPSSPP raises `Unsupported`.
69+
70+
`screenshot()` depends on the GPU backend: PPSSPP 1.20.4 with Vulkan answers "Could not
71+
download output" whether the game runs or is stopped.
72+
73+
## Tests
74+
75+
`ppsspp_debug.testing.FakePPSSPP` is a debugger server with PPSSPP's quirks, for testing code
76+
that uses this client without an emulator.
77+
78+
The live tests run against a real PPSSPP when `PPSSPP_BINARY` and `PPSSPP_GAME` are set, and
79+
print what they measure with `-s`:
80+
81+
```bash
82+
PPSSPP_BINARY=/path/to/PPSSPPSDL PPSSPP_GAME=/path/to/game.iso uv run pytest packages/ppsspp-debug -s
83+
```
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[project]
2+
name = "ppsspp-debug"
3+
version = "0.1.0"
4+
description = "A typed client for PPSSPP's WebSocket debugger, blocking and asyncio."
5+
readme = "README.md"
6+
requires-python = ">=3.11"
7+
license = "MIT"
8+
dependencies = ["websockets>=15", "psutil>=6"]
9+
classifiers = ["Typing :: Typed"]
10+
11+
[build-system]
12+
requires = ["uv_build>=0.11,<0.12"]
13+
build-backend = "uv_build"
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""A typed client for PPSSPP's WebSocket debugger, blocking (`Client`) and asyncio (`AsyncClient`).
2+
3+
from ppsspp_debug import Client
4+
5+
with Client.connect() as ppsspp:
6+
ppsspp.read_u32(address)
7+
with ppsspp.watchpoint(address, 2) as hits:
8+
hit = hits.next(timeout=30)
9+
ppsspp.registers()
10+
ppsspp.resume()
11+
"""
12+
13+
from ._client import AsyncClient, Stream
14+
from ._errors import DebuggerError, Disconnected, Unsupported
15+
from ._launch import DockerEmulator, LocalEmulator, find_debuggers
16+
from ._sync import Client, SyncStream
17+
from ._types import (
18+
BUTTONS,
19+
Breakpoint,
20+
Button,
21+
CpuStatus,
22+
Event,
23+
FrameStats,
24+
GameEvent,
25+
GameInfo,
26+
Hit,
27+
Instruction,
28+
LogLine,
29+
RawEvent,
30+
Resumed,
31+
Screenshot,
32+
Stepping,
33+
Watchpoint,
34+
parse_hit,
35+
)
36+
37+
__all__ = [
38+
"BUTTONS",
39+
"AsyncClient",
40+
"Breakpoint",
41+
"Button",
42+
"Client",
43+
"CpuStatus",
44+
"DebuggerError",
45+
"Disconnected",
46+
"DockerEmulator",
47+
"Event",
48+
"FrameStats",
49+
"GameEvent",
50+
"GameInfo",
51+
"Hit",
52+
"Instruction",
53+
"LocalEmulator",
54+
"LogLine",
55+
"RawEvent",
56+
"Resumed",
57+
"Screenshot",
58+
"Stepping",
59+
"Stream",
60+
"SyncStream",
61+
"Unsupported",
62+
"Watchpoint",
63+
"find_debuggers",
64+
"parse_hit",
65+
]

0 commit comments

Comments
 (0)