dbxdebug 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/.github/workflows/release.yml +3 -3
  2. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/.github/workflows/test.yml +6 -6
  3. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/.pre-commit-config.yaml +22 -7
  4. dbxdebug-0.5.0/.release-please-manifest.json +3 -0
  5. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/CHANGELOG.md +14 -0
  6. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/PKG-INFO +55 -18
  7. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/README.md +54 -17
  8. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/docs/migration.md +59 -35
  9. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/pyproject.toml +17 -3
  10. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/skills/debug-dos-programs/SKILL.md +8 -6
  11. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/skills/debug-dos-programs/references/recipes.md +43 -11
  12. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/skills/debug-dos-programs/references/troubleshooting.md +20 -10
  13. dbxdebug-0.5.0/src/dbxdebug/__init__.py +282 -0
  14. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/src/dbxdebug/addressing.py +11 -0
  15. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/src/dbxdebug/capture_io.py +7 -0
  16. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/src/dbxdebug/dbx_kbd.py +10 -0
  17. dbxdebug-0.5.0/src/dbxdebug/frames.py +427 -0
  18. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/src/dbxdebug/gdb.py +200 -10
  19. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/src/dbxdebug/html.py +10 -0
  20. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/src/dbxdebug/keyboard.py +37 -0
  21. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/src/dbxdebug/paths.py +7 -0
  22. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/src/dbxdebug/qmp.py +140 -1
  23. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/src/dbxdebug/registry.py +13 -0
  24. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/src/dbxdebug/session.py +10 -1
  25. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/src/dbxdebug/utils.py +5 -0
  26. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/src/dbxdebug/video.py +17 -0
  27. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/tests/integration/test_live_session.py +507 -1
  28. dbxdebug-0.5.0/tests/test_exports.py +195 -0
  29. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/tests/test_frames.py +144 -11
  30. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/tests/test_gdb_framing.py +232 -0
  31. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/tests/test_qmp_commands.py +126 -0
  32. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/uv.lock +36 -40
  33. dbxdebug-0.4.0/.release-please-manifest.json +0 -3
  34. dbxdebug-0.4.0/src/dbxdebug/__init__.py +0 -137
  35. dbxdebug-0.4.0/src/dbxdebug/frames.py +0 -285
  36. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/.github/dependabot.yml +0 -0
  37. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/.gitignore +0 -0
  38. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/LICENSE +0 -0
  39. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/plugin.json +0 -0
  40. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/release-please-config.json +0 -0
  41. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/skills/dosbox-x-debug-protocol/SKILL.md +0 -0
  42. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/skills/dosbox-x-debug-protocol/references/history.md +0 -0
  43. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/skills/dosbox-x-debug-protocol/references/packets.md +0 -0
  44. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/skills/dosbox-x-debug-protocol/references/qmp-commands.md +0 -0
  45. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/src/dbxdebug/cli.py +0 -0
  46. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/src/dbxdebug/doctor.py +0 -0
  47. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/tests/__init__.py +0 -0
  48. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/tests/integration/__init__.py +0 -0
  49. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/tests/integration/conftest.py +0 -0
  50. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/tests/integration/test_headless.py +0 -0
  51. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/tests/test_addressing.py +0 -0
  52. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/tests/test_basic.py +0 -0
  53. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/tests/test_cli_session.py +0 -0
  54. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/tests/test_gdb_capabilities.py +0 -0
  55. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/tests/test_gdb_registers.py +0 -0
  56. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/tests/test_paths.py +0 -0
  57. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/tests/test_read_bulk.py +0 -0
  58. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/tests/test_registry.py +0 -0
  59. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/tests/test_session.py +0 -0
  60. {dbxdebug-0.4.0 → dbxdebug-0.5.0}/tests/test_video_client_ownership.py +0 -0
@@ -39,7 +39,7 @@ jobs:
39
39
  release_created: ${{ steps.release.outputs.release_created }}
40
40
  tag_name: ${{ steps.release.outputs.tag_name }}
41
41
  steps:
42
- - uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1
42
+ - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
43
43
  id: release
44
44
  with:
45
45
  config-file: release-please-config.json
@@ -65,13 +65,13 @@ jobs:
65
65
  id-token: write # OIDC for PyPI Trusted Publishing
66
66
  contents: write # upload built artifacts to the GitHub Release
67
67
  steps:
68
- - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
68
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
69
69
  with:
70
70
  ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || needs.release-please.outputs.tag_name }}
71
71
  fetch-depth: 0 # Full history + tags for hatch-vcs version
72
72
 
73
73
  - name: Install uv and set up Python
74
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
74
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
75
75
  with:
76
76
  python-version: "3.11"
77
77
 
@@ -18,10 +18,10 @@ jobs:
18
18
  lint:
19
19
  runs-on: ubuntu-latest
20
20
  steps:
21
- - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
21
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
22
22
 
23
23
  - name: Install uv and set up Python
24
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
24
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
25
25
  with:
26
26
  python-version: "3.11"
27
27
 
@@ -47,10 +47,10 @@ jobs:
47
47
  matrix:
48
48
  python-version: ["3.11", "3.12"]
49
49
  steps:
50
- - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
50
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
51
51
 
52
52
  - name: Install uv and set up Python ${{ matrix.python-version }}
53
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
53
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
54
54
  with:
55
55
  python-version: ${{ matrix.python-version }}
56
56
 
@@ -64,12 +64,12 @@ jobs:
64
64
  needs: test
65
65
  runs-on: ubuntu-latest
66
66
  steps:
67
- - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
67
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
68
68
  with:
69
69
  fetch-depth: 0 # Full history for hatch-vcs
70
70
 
71
71
  - name: Install uv
72
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
72
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
73
73
 
74
74
  - name: Build (packaging smoke test)
75
75
  run: uv build
@@ -27,19 +27,32 @@ repos:
27
27
  hooks:
28
28
  - id: gitleaks
29
29
 
30
- # Ruff for linting and formatting
30
+ # Ruff for linting and formatting.
31
+ #
32
+ # This rev and the `ruff` floor in pyproject.toml are ONE version, pinned in
33
+ # two places, and they must be bumped together. Two ruffs run against this
34
+ # repo -- this hook on every commit, and the resolved dev environment in CI
35
+ # -- and when they drifted six minor versions apart they disagreed about
36
+ # formatting: 0.8.4 rewrote a multi-line `assert ..., "message"` into a shape
37
+ # 0.14.9's `--check` then rejected, so a commit blessed here was refused by
38
+ # CI and each tool undid the other (lokkju/dbxdebug#17). Neither reports its
39
+ # version in the diff it produces, which is what made that confusing.
31
40
  - repo: https://github.com/astral-sh/ruff-pre-commit
32
- rev: v0.8.4
41
+ rev: v0.14.9
33
42
  hooks:
34
- - id: ruff
43
+ # `ruff-check`, not `ruff`: the latter is a legacy alias in 0.14.x and
44
+ # says so on every run.
45
+ - id: ruff-check
35
46
  types_or: [python, pyi]
36
47
  args: [--fix]
37
48
  - id: ruff-format
38
49
  types_or: [python, pyi]
39
50
 
40
- # Pyright for type checking
51
+ # Pyright for type checking. Same rule as ruff above: this rev and the
52
+ # `pyright` floor in pyproject.toml are one version in two places. A pyright
53
+ # that is stale here accepts code CI's newer one rejects.
41
54
  - repo: https://github.com/RobertCraigie/pyright-python
42
- rev: v1.1.390
55
+ rev: v1.1.411
43
56
  hooks:
44
57
  - id: pyright
45
58
  additional_dependencies:
@@ -68,8 +81,10 @@ repos:
68
81
  hooks:
69
82
  - id: actionlint
70
83
 
71
- # UV for dependency management - ensures uv.lock is synced
84
+ # UV for dependency management - ensures uv.lock is synced. Matched to the
85
+ # uv that resolved the current lockfile: this hook REWRITES uv.lock, so an
86
+ # older uv here can churn the file the developer's own uv just wrote.
72
87
  - repo: https://github.com/astral-sh/uv-pre-commit
73
- rev: 0.7.13
88
+ rev: 0.9.21
74
89
  hooks:
75
90
  - id: uv-lock
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.5.0"
3
+ }
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.0](https://github.com/lokkju/dbxdebug/compare/v0.4.0...v0.5.0) (2026-09-06)
4
+
5
+
6
+ ### Features
7
+
8
+ * **api:** derive the package root's exports from the modules' __all__ ([f4fbb2d](https://github.com/lokkju/dbxdebug/commit/f4fbb2d18a7a4b7c329c7593a26e39e99930b1c6)), closes [#7](https://github.com/lokkju/dbxdebug/issues/7)
9
+ * **qmp:** add mouse input to QMPClient ([a25a356](https://github.com/lokkju/dbxdebug/commit/a25a356c19115d5ea12189aca3c221413602dc9f)), closes [#2](https://github.com/lokkju/dbxdebug/issues/2)
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * **frames:** end steps_out at the return address, and wrap segment reads ([40e2817](https://github.com/lokkju/dbxdebug/commit/40e28174473ada9997f650b84051cf296f72d0dd)), closes [#6](https://github.com/lokkju/dbxdebug/issues/6)
15
+ * **gdb:** make the pending-stop queue service itself ([908eae0](https://github.com/lokkju/dbxdebug/commit/908eae0ea621ea460c4627a4f7d4320eaaace81a)), closes [#18](https://github.com/lokkju/dbxdebug/issues/18)
16
+
3
17
  ## [0.4.0](https://github.com/lokkju/dbxdebug/compare/v0.3.0...v0.4.0) (2026-09-05)
4
18
 
5
19
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dbxdebug
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Client library and CLI for DOSBox-X remote debug protocols (GDB and QMP)
5
5
  Author: lokkju
6
6
  License-Expression: LicenseRef-Polyform-Shield-1.0.0
@@ -70,7 +70,7 @@ For working on dbxdebug itself, `uv sync`.
70
70
  ## Quick start
71
71
 
72
72
  ```python
73
- from dbxdebug.session import DosboxSession
73
+ from dbxdebug import DosboxSession
74
74
 
75
75
  # Headless by default: no window, no keyboard focus, no audio device.
76
76
  # Pass headless=False when you want to watch the guest.
@@ -231,10 +231,36 @@ old build fails at `start()` rather than at the first breakpoint.
231
231
  | `dbxdebug.video` / `.html` / `.capture_io` | `DOSVideoTools`, HTML rendering, `ScreenRecorder`, `load_capture` |
232
232
  | `dbxdebug.keyboard` / `.dbx_kbd` | key-chord helpers and constants (`CTRL_C`, `ctrl_key`, `DBX_KEY`, ...) |
233
233
 
234
- `dbxdebug/__init__.py` re-exports the clients, the video tools, and the
235
- keyboard helpers. It does **not** yet re-export `DosboxSession`, `addressing`,
236
- `frames`, `registry`, `paths` or `doctor` -- import those from their modules,
237
- as every example here does ([#7](https://github.com/lokkju/dbxdebug/issues/7)).
234
+ ### The export rule
235
+
236
+ Every module declares its supported surface in `__all__`, and
237
+ `dbxdebug/__init__.py` re-exports the union of the **library** modules'
238
+ `__all__`. So everything in the table above except the last three rows'
239
+ worth of command machinery is importable straight from `dbxdebug`:
240
+
241
+ ```python
242
+ from dbxdebug import DosboxSession, GDBClient, QMPClient, linear, walk_frames
243
+ ```
244
+
245
+ The exceptions are `dbxdebug.cli`, `dbxdebug.registry` and `dbxdebug.doctor`
246
+ -- the `dbxdebug` command's own machinery. They act on the host's whole set
247
+ of sessions rather than on the one you launched, and their names mean
248
+ nothing unqualified: a package root should not own `run`, `reap`,
249
+ `list_sessions` or `free_port`. Import those from their modules:
250
+
251
+ ```python
252
+ from dbxdebug.registry import list_sessions, reap
253
+ from dbxdebug import doctor
254
+
255
+ report = doctor.run()
256
+ ```
257
+
258
+ Leaving `cli` out is also what keeps `import dbxdebug` free of `click`.
259
+
260
+ Module-path imports keep working everywhere, and the examples below use
261
+ whichever form reads better in context -- `addressing.bp_addr` says more
262
+ about `bp_addr` than a bare name does. `tests/test_exports.py` holds the
263
+ root to the union so the two cannot drift.
238
264
 
239
265
  ### Locating the emulator
240
266
 
@@ -270,17 +296,22 @@ steps_out(gdb) # single-step until the current frame
270
296
  BP, a saved BP that is not strictly above the current one (which is also what
271
297
  terminates a cyclic chain), a short or failed read, or `max_depth`.
272
298
 
273
- `steps_out` is **a heuristic over SP**, with bounds worth knowing before you
274
- rely on it. It records the entry BP and steps until `SP & 0xFFFF` is strictly
275
- greater than `BP + 2` -- past the return-address slot, which only the `ret`
276
- itself reaches, not the `pop bp` or `leave` before it. Consequences:
299
+ `steps_out` reads the frame's own return address once, then steps until the
300
+ CPU is **at that address with the return slot popped**: `IP` equal to
301
+ `[BP+2]`, and either `CS` unchanged with `SP >= BP+4` (a near return) or `CS`
302
+ equal to `[BP+4]` with `SP >= BP+6` (a far one). Both halves matter -- SP
303
+ alone stops early on a callee that raises SP without returning
304
+ (`pop ax / add sp,N / jmp ax`), and the return address alone would accept a
305
+ callee merely branching through the caller's code. Nothing is decoded; every
306
+ value compared already arrives with each step's register read. Consequences:
277
307
 
278
308
  * it raises `FrameWalkError` if `SP > BP` on entry (no frame pointer
279
- established, or a stale BP) rather than returning after a single step;
280
- * a callee that pops BP and jumps to a shared epilogue popping further
281
- registers raises SP past `BP+2` while still inside the callee, and this
282
- stops there, early. Telling that apart from a real return needs instruction
283
- decoding, which this does not do;
309
+ established, or a stale BP) rather than returning after a single step, and
310
+ likewise for a `BP` so near the top of SS that no return SP would fit in 16
311
+ bits;
312
+ * a return address that never arrives -- a guest that rewrites its own return
313
+ slot -- runs to `timeout`/`max_steps` and **raises**, naming the address it
314
+ was waiting for. It does not stop somewhere plausible;
284
315
  * called at a procedure's first instruction, before the prologue has run, BP
285
316
  still belongs to the caller and this measures the caller's frame;
286
317
  * **clear every breakpoint first.** A breakpoint hit during one of these steps
@@ -394,10 +425,13 @@ with GDBClient() as gdb: # localhost:2159
394
425
  gdb.set_breakpoint("1000:0020") # the same address, seg:off form
395
426
  gdb.step()
396
427
  gdb.continue_execution()
428
+ gdb.wait_for_stop(timeout=30.0) # a stop NOBODY asked for
397
429
 
398
430
  with QMPClient() as qmp: # localhost:4444
399
431
  qmp.send_key(CTRL_C)
400
432
  qmp.type_text("Hello World!")
433
+ qmp.mouse_click("left") # buttons reach the guest
434
+ qmp.mouse_move(50, -20) # motion does NOT, headless
401
435
 
402
436
  with DOSVideoTools() as video: # owns its own client
403
437
  lines = video.screen_dump()
@@ -433,9 +467,12 @@ break-on-exec fires one nobody asked for) and a timed-out request leaving its
433
467
  reply in the stream -- and both are handled at the framing layer:
434
468
 
435
469
  * an unrequested stop reply is diverted to `gdb.pending_stops` instead of
436
- being read as an answer. Drain it with `gdb.take_pending_stops()`; the
437
- queue keeps the most recent 64. This is how you learn the CPU stopped
438
- without polling QMP;
470
+ being read as an answer. Drain it with `gdb.take_pending_stops()`, or wait
471
+ on it with `gdb.wait_for_stop(timeout=...)`; both read the socket
472
+ themselves, so no other request is needed to shake a stop loose
473
+ ([#18](https://github.com/lokkju/dbxdebug/issues/18), fixed). The queue
474
+ keeps the most recent 64. This is how you learn the CPU stopped without
475
+ polling QMP;
439
476
  * an abandoned exchange is drained before the next packet is sent, so the
440
477
  request after a `GDBTimeoutError` gets its own reply rather than the
441
478
  previous one's;
@@ -48,7 +48,7 @@ For working on dbxdebug itself, `uv sync`.
48
48
  ## Quick start
49
49
 
50
50
  ```python
51
- from dbxdebug.session import DosboxSession
51
+ from dbxdebug import DosboxSession
52
52
 
53
53
  # Headless by default: no window, no keyboard focus, no audio device.
54
54
  # Pass headless=False when you want to watch the guest.
@@ -209,10 +209,36 @@ old build fails at `start()` rather than at the first breakpoint.
209
209
  | `dbxdebug.video` / `.html` / `.capture_io` | `DOSVideoTools`, HTML rendering, `ScreenRecorder`, `load_capture` |
210
210
  | `dbxdebug.keyboard` / `.dbx_kbd` | key-chord helpers and constants (`CTRL_C`, `ctrl_key`, `DBX_KEY`, ...) |
211
211
 
212
- `dbxdebug/__init__.py` re-exports the clients, the video tools, and the
213
- keyboard helpers. It does **not** yet re-export `DosboxSession`, `addressing`,
214
- `frames`, `registry`, `paths` or `doctor` -- import those from their modules,
215
- as every example here does ([#7](https://github.com/lokkju/dbxdebug/issues/7)).
212
+ ### The export rule
213
+
214
+ Every module declares its supported surface in `__all__`, and
215
+ `dbxdebug/__init__.py` re-exports the union of the **library** modules'
216
+ `__all__`. So everything in the table above except the last three rows'
217
+ worth of command machinery is importable straight from `dbxdebug`:
218
+
219
+ ```python
220
+ from dbxdebug import DosboxSession, GDBClient, QMPClient, linear, walk_frames
221
+ ```
222
+
223
+ The exceptions are `dbxdebug.cli`, `dbxdebug.registry` and `dbxdebug.doctor`
224
+ -- the `dbxdebug` command's own machinery. They act on the host's whole set
225
+ of sessions rather than on the one you launched, and their names mean
226
+ nothing unqualified: a package root should not own `run`, `reap`,
227
+ `list_sessions` or `free_port`. Import those from their modules:
228
+
229
+ ```python
230
+ from dbxdebug.registry import list_sessions, reap
231
+ from dbxdebug import doctor
232
+
233
+ report = doctor.run()
234
+ ```
235
+
236
+ Leaving `cli` out is also what keeps `import dbxdebug` free of `click`.
237
+
238
+ Module-path imports keep working everywhere, and the examples below use
239
+ whichever form reads better in context -- `addressing.bp_addr` says more
240
+ about `bp_addr` than a bare name does. `tests/test_exports.py` holds the
241
+ root to the union so the two cannot drift.
216
242
 
217
243
  ### Locating the emulator
218
244
 
@@ -248,17 +274,22 @@ steps_out(gdb) # single-step until the current frame
248
274
  BP, a saved BP that is not strictly above the current one (which is also what
249
275
  terminates a cyclic chain), a short or failed read, or `max_depth`.
250
276
 
251
- `steps_out` is **a heuristic over SP**, with bounds worth knowing before you
252
- rely on it. It records the entry BP and steps until `SP & 0xFFFF` is strictly
253
- greater than `BP + 2` -- past the return-address slot, which only the `ret`
254
- itself reaches, not the `pop bp` or `leave` before it. Consequences:
277
+ `steps_out` reads the frame's own return address once, then steps until the
278
+ CPU is **at that address with the return slot popped**: `IP` equal to
279
+ `[BP+2]`, and either `CS` unchanged with `SP >= BP+4` (a near return) or `CS`
280
+ equal to `[BP+4]` with `SP >= BP+6` (a far one). Both halves matter -- SP
281
+ alone stops early on a callee that raises SP without returning
282
+ (`pop ax / add sp,N / jmp ax`), and the return address alone would accept a
283
+ callee merely branching through the caller's code. Nothing is decoded; every
284
+ value compared already arrives with each step's register read. Consequences:
255
285
 
256
286
  * it raises `FrameWalkError` if `SP > BP` on entry (no frame pointer
257
- established, or a stale BP) rather than returning after a single step;
258
- * a callee that pops BP and jumps to a shared epilogue popping further
259
- registers raises SP past `BP+2` while still inside the callee, and this
260
- stops there, early. Telling that apart from a real return needs instruction
261
- decoding, which this does not do;
287
+ established, or a stale BP) rather than returning after a single step, and
288
+ likewise for a `BP` so near the top of SS that no return SP would fit in 16
289
+ bits;
290
+ * a return address that never arrives -- a guest that rewrites its own return
291
+ slot -- runs to `timeout`/`max_steps` and **raises**, naming the address it
292
+ was waiting for. It does not stop somewhere plausible;
262
293
  * called at a procedure's first instruction, before the prologue has run, BP
263
294
  still belongs to the caller and this measures the caller's frame;
264
295
  * **clear every breakpoint first.** A breakpoint hit during one of these steps
@@ -372,10 +403,13 @@ with GDBClient() as gdb: # localhost:2159
372
403
  gdb.set_breakpoint("1000:0020") # the same address, seg:off form
373
404
  gdb.step()
374
405
  gdb.continue_execution()
406
+ gdb.wait_for_stop(timeout=30.0) # a stop NOBODY asked for
375
407
 
376
408
  with QMPClient() as qmp: # localhost:4444
377
409
  qmp.send_key(CTRL_C)
378
410
  qmp.type_text("Hello World!")
411
+ qmp.mouse_click("left") # buttons reach the guest
412
+ qmp.mouse_move(50, -20) # motion does NOT, headless
379
413
 
380
414
  with DOSVideoTools() as video: # owns its own client
381
415
  lines = video.screen_dump()
@@ -411,9 +445,12 @@ break-on-exec fires one nobody asked for) and a timed-out request leaving its
411
445
  reply in the stream -- and both are handled at the framing layer:
412
446
 
413
447
  * an unrequested stop reply is diverted to `gdb.pending_stops` instead of
414
- being read as an answer. Drain it with `gdb.take_pending_stops()`; the
415
- queue keeps the most recent 64. This is how you learn the CPU stopped
416
- without polling QMP;
448
+ being read as an answer. Drain it with `gdb.take_pending_stops()`, or wait
449
+ on it with `gdb.wait_for_stop(timeout=...)`; both read the socket
450
+ themselves, so no other request is needed to shake a stop loose
451
+ ([#18](https://github.com/lokkju/dbxdebug/issues/18), fixed). The queue
452
+ keeps the most recent 64. This is how you learn the CPU stopped without
453
+ polling QMP;
417
454
  * an abandoned exchange is drained before the next packet is sent, so the
418
455
  request after a `GDBTimeoutError` gets its own reply rather than the
419
456
  previous one's;
@@ -63,36 +63,42 @@ Then delete the vendored copy. Nothing in the consumer should still import
63
63
 
64
64
  | What you had | Where it lives now |
65
65
  |---|---|
66
- | The launcher class (`Popen`, ports, cleanup) | `from dbxdebug.session import DosboxSession` |
66
+ | The launcher class (`Popen`, ports, cleanup) | `from dbxdebug import DosboxSession` |
67
67
  | `dosbox_debug.GDBClient` | `from dbxdebug import GDBClient` |
68
68
  | `dosbox_debug.QMPClient`, `QMPError` | `from dbxdebug import QMPClient, QMPError` |
69
69
  | `dosbox_debug.GDBError` | No equivalent: see section 3 |
70
- | The launcher's `bp_addr` | `from dbxdebug.addressing import linear` (and see section 2.2) |
70
+ | The launcher's `bp_addr` | `from dbxdebug import linear` (and see section 2.2) |
71
71
  | Screen decoding on the GDB client | `DosboxSession.screen_lines()`, or `from dbxdebug import DOSVideoTools` |
72
- | Frame walking, if you had it | `from dbxdebug.frames import walk_frames, steps_out` |
73
- | Finding the emulator binary | `from dbxdebug.paths import find_dosbox_x`, or `$DBXDEBUG_DOSBOX` |
72
+ | Frame walking, if you had it | `from dbxdebug import walk_frames, steps_out` |
73
+ | Finding the emulator binary | `from dbxdebug import find_dosbox_x`, or `$DBXDEBUG_DOSBOX` |
74
74
  | Nothing: this is new | `from dbxdebug.registry import list_sessions`; the `dbxdebug session` and `dbxdebug doctor` CLI |
75
75
 
76
- ### The surface is not tidy, and this is worth knowing before you write imports
76
+ ### Where to import from
77
77
 
78
- `dbxdebug/__init__.py` exports the original client surface only. The newer
79
- modules -- the session, the registry, the addressing helpers, the frame
80
- walker -- are importable from their own modules and nowhere else. So the
81
- package's primary entry point is the one thing you cannot import from the
82
- package root:
78
+ Everything a debugging script needs comes from the package root:
83
79
 
84
80
  ```python
85
- >>> from dbxdebug import GDBClient, QMPClient, DOSVideoTools # fine
86
- >>> from dbxdebug import DosboxSession
87
- ImportError: cannot import name 'DosboxSession' from 'dbxdebug'
88
- >>> from dbxdebug.session import DosboxSession # this is the way
81
+ from dbxdebug import DosboxSession, GDBClient, QMPClient, DOSVideoTools, linear
89
82
  ```
90
83
 
91
- This is self-consistent (no newer module is exported, so there is no
92
- arbitrary line) but it is an odd shape, and it is an open question, not a
93
- settled convention: lokkju/dbxdebug#7. Import from the modules for now. If
94
- the re-exports land, module-level imports keep working, so nothing written
95
- today has to be rewritten.
84
+ The rule is that every module declares its surface in `__all__` and the root
85
+ re-exports the union of the library modules' `__all__`. The exceptions are
86
+ `dbxdebug.cli`, `dbxdebug.registry` and `dbxdebug.doctor`, the `dbxdebug`
87
+ command's own machinery, whose names say nothing unqualified:
88
+
89
+ ```python
90
+ from dbxdebug.registry import list_sessions, reap
91
+ from dbxdebug import doctor
92
+ ```
93
+
94
+ Module-path imports work for everything, root or not, and the examples
95
+ further down use them where naming the module makes the call clearer
96
+ (`addressing.bp_addr`, `frames.steps_out`). Nothing you write either way
97
+ has to be rewritten later.
98
+
99
+ If `from dbxdebug import DosboxSession` raises `ImportError`, you are on a
100
+ build from before lokkju/dbxdebug#7 landed; `from dbxdebug.session import
101
+ DosboxSession` works there.
96
102
 
97
103
  ### The launcher itself
98
104
 
@@ -105,7 +111,7 @@ so only a SIGKILL of the owner can leave a stray emulator behind (which is
105
111
  what `dbxdebug session reap` is for).
106
112
 
107
113
  ```python
108
- from dbxdebug.session import DosboxSession
114
+ from dbxdebug import DosboxSession
109
115
 
110
116
  with DosboxSession(mounts={"c": "/path/to/host/dir"}, autoexec=["c:"]) as session:
111
117
  session.qmp.type_text("PROG\r")
@@ -347,7 +353,8 @@ were two loops was the first item of lokkju/dbxdebug#7.
347
353
  | `key_down(key) -> dict` / `key_up(key) -> dict` | `key_down(key) -> None` / `key_up(key) -> None` | Same `input-send-event` payload |
348
354
  | `key_press(key, hold_time=0.1) -> dict` | `key_press(key, hold_time=0.05) -> None` | **Default hold time halved** |
349
355
  | `type_text(text, delay=0.1)` | `type_text(text, delay=0.05)` | **Default delay halved.** Unmapped characters are logged and skipped rather than silently skipped; `\r`, `\n`, `\t`, space and shifted punctuation map the same |
350
- | `input_send_event(events) -> dict` | -- | No raw passthrough. Use `key_down`/`key_up` |
356
+ | `input_send_event(events) -> dict` | -- | No raw passthrough. Use `key_down`/`key_up`, or the mouse methods below |
357
+ | -- | `mouse_move(dx, dy)`, `mouse_button(button, down)`, `mouse_click(button='left', hold_time=0.05)` | New. Relative motion only (the server ignores `abs`), and only `left`/`right`/`middle` -- `MOUSE_BUTTONS` lists them, and anything else raises `ValueError` rather than being accepted and dropped. **Motion has no effect headless**; buttons do. See `mouse_move`'s docstring |
351
358
  | `query_commands() -> list` | `query_commands() -> list[str]` | Same |
352
359
  | `query_status() -> dict` | `query_status() -> dict` | **Returns the `return` payload, not the envelope**: `resp["return"]["running"]` becomes `status["running"]` |
353
360
  | `stop()`, `cont()`, `debug_break_on_exec(enabled)` | Same names | Same unwrapping change as `query_status` |
@@ -449,22 +456,38 @@ gdb.take_pending_stops() # [b'S05'] -- the stop, delivered once
449
456
  Note that a `Z0` breakpoint armed while free-running is inert and never was a
450
457
  trigger -- activation only happens on continue.
451
458
 
452
- **The queue is filled by the client's framing layer while it reads, not by the
453
- socket receiving anything.** Until you issue some GDB request, an unsolicited
454
- `S05` sits unread in the kernel buffer and `take_pending_stops()` returns
455
- empty. A poller written as "loop calling `take_pending_stops()` until it is
456
- non-empty" therefore spins forever on a stop that has genuinely happened:
459
+ **The queue services itself** (was
460
+ [#18](https://github.com/lokkju/dbxdebug/issues/18), fixed). Reading
461
+ `pending_stops` or calling `take_pending_stops()` reads the socket first,
462
+ without blocking, so asking the question also answers it:
457
463
 
458
464
  ```python
459
- gdb.take_pending_stops() # () -- nothing has read the socket yet
460
- gdb.read_memory(0x400, 4) # any request; the framing layer sees the S05
465
+ qmp.debug_break_on_exec(True)
466
+ # ... the break fires; no GDB request is issued at all ...
461
467
  gdb.take_pending_stops() # (b'S05',)
462
468
  ```
463
469
 
464
- Give your poll loop a cheap read -- a few bytes of the BDA will do -- before
465
- taking the queue. `qmp.query_status()` on the separate QMP socket has no such
466
- ordering requirement and is the simpler signal when you are not otherwise
467
- talking GDB.
470
+ Before that fix the queue filled only as a side effect of some OTHER request
471
+ passing through the framing layer, so a poller written as "loop calling
472
+ `take_pending_stops()` until it is non-empty" spun forever on a stop that had
473
+ genuinely happened, and its caller concluded the breakpoint had never fired.
474
+ A read interposed in the loop was the workaround; it is no longer needed.
475
+
476
+ Better still, do not write the loop:
477
+
478
+ ```python
479
+ stop = gdb.wait_for_stop(timeout=30.0) # the payload, or None on timeout
480
+ ```
481
+
482
+ `wait_for_stop` owns the poll and returns the oldest queued stop, removed
483
+ from the queue. It does **not** resume anything -- it is for stops nobody
484
+ asked for. It raises `GDBDesyncError` rather than spinning if the stub still
485
+ owes a reply to a request already on the wire, because the socket cannot be
486
+ polled in that state without stealing that request's own bytes; finish or
487
+ abandon that exchange first (the next request drains an abandoned one).
488
+
489
+ `qmp.query_status()` on the separate QMP socket is still a valid signal, and
490
+ is the one to use when you are not otherwise talking GDB.
468
491
 
469
492
  **Trigger 2 -- a timed-out request.** The abandoned reply used to stay in the
470
493
  stream and be handed to the next request:
@@ -493,9 +516,10 @@ What a consumer should still do:
493
516
  * Do **not** add a read-retry loop. Two identical consecutive requests mask a
494
517
  one-packet lag perfectly, so retrying looks like it works whether or not the
495
518
  stream has shifted.
496
- * If you arm `debug_break_on_exec`, drain `gdb.take_pending_stops()` to learn
497
- the CPU stopped -- after a read, per the ordering note above. Polling `qmp.query_status()` on the separate QMP socket
498
- still works and is still the way to wait for it.
519
+ * If you arm `debug_break_on_exec`, learn that the CPU stopped from
520
+ `gdb.wait_for_stop()`, or from `gdb.take_pending_stops()` in a loop of your
521
+ own -- both read the socket themselves now. Polling `qmp.query_status()` on
522
+ the separate QMP socket still works too.
499
523
 
500
524
  ### 5.3 One GDB client at a time (lokkju/dbxdebug#8, open)
501
525
 
@@ -32,8 +32,13 @@ dbxdebug = "dbxdebug.cli:main"
32
32
  dev = [
33
33
  "pytest>=7.4.0",
34
34
  "pytest-cov>=4.1.0",
35
- "ruff>=0.8.0",
36
- "pyright>=1.1.0",
35
+ # These two floors match the `rev:` pins in .pre-commit-config.yaml on
36
+ # purpose, and are bumped with them. Each tool runs twice against this
37
+ # repo -- once as a commit hook, once as the resolved dev environment CI
38
+ # uses -- and a floor low enough to resolve behind the hook lets the two
39
+ # disagree about the same file (lokkju/dbxdebug#17).
40
+ "ruff>=0.14.9",
41
+ "pyright>=1.1.411",
37
42
  "pre-commit>=3.5.0",
38
43
  ]
39
44
 
@@ -66,7 +71,16 @@ target-version = "py311"
66
71
  # `ruff check .` from the root lints other branches' in-progress code and
67
72
  # reports their errors as this tree's. pyright already scopes itself via
68
73
  # `include`; ruff needs telling.
69
- extend-exclude = [".claude"]
74
+ extend-exclude = [
75
+ ".claude",
76
+ # ruff formats Python inside Markdown code blocks, and it wants to
77
+ # restretch the aligned trailing comments in our docs samples. CI runs
78
+ # `ruff format --check src/ tests/` and the pre-commit hook is scoped to
79
+ # `types_or: [python, pyi]`, so neither ever saw this -- only a bare
80
+ # `ruff format --check .` did, which made the repo look broken to anyone
81
+ # running the obvious command. Excluded so all three agree.
82
+ "*.md",
83
+ ]
70
84
 
71
85
  [tool.ruff.lint]
72
86
  select = [
@@ -56,7 +56,7 @@ Point it at a specific build with `DBXDEBUG_DOSBOX=/path/to/dosbox-x`.
56
56
  Always as a context manager. Never hand-roll a `subprocess.Popen`.
57
57
 
58
58
  ```python
59
- from dbxdebug.session import DosboxSession # NOT `from dbxdebug import ...`
59
+ from dbxdebug import DosboxSession # or dbxdebug.session; both work
60
60
 
61
61
  with DosboxSession(mounts={"c": host_dir}) as session:
62
62
  ...
@@ -244,11 +244,13 @@ So, verified behaviour:
244
244
 
245
245
  - The GDB connection **survives** the break. The read that follows is still
246
246
  answered with its own bytes.
247
- - Learn that the CPU stopped from **`gdb.take_pending_stops()`** — it returns
248
- the queued stop replies and empties the queue — or by polling
249
- **`qmp.query_status()["running"]`** on the separate QMP socket. Either
250
- works; the QMP poll is what you want when you are waiting rather than
251
- reacting.
247
+ - Learn that the CPU stopped from **`gdb.wait_for_stop(timeout=...)`** — it
248
+ polls for you and returns the stop reply, or None if none arrives — or from
249
+ **`gdb.take_pending_stops()`**, which returns the queued stop replies and
250
+ empties the queue. Both read the socket themselves, so neither needs any
251
+ other GDB request to shake a stop loose (lokkju/dbxdebug#18, fixed).
252
+ Polling **`qmp.query_status()["running"]`** on the separate QMP socket works
253
+ too, and is what you want when you are not otherwise talking GDB.
252
254
  - A plain `Z0` breakpoint armed while free-running is **inert** and is not a
253
255
  trigger — activation only happens on continue. This is why the ordinary
254
256
  breakpoint recipe (`halt` → `set_breakpoint` → `continue_execution`) is