dbxdebug 0.3.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 (63) hide show
  1. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/.github/workflows/release.yml +21 -8
  2. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/.github/workflows/test.yml +6 -6
  3. {dbxdebug-0.3.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.3.0 → dbxdebug-0.5.0}/CHANGELOG.md +45 -0
  6. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/PKG-INFO +187 -62
  7. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/README.md +186 -61
  8. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/docs/migration.md +180 -100
  9. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/pyproject.toml +21 -2
  10. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/skills/debug-dos-programs/SKILL.md +79 -54
  11. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/skills/debug-dos-programs/references/recipes.md +103 -44
  12. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/skills/debug-dos-programs/references/troubleshooting.md +113 -75
  13. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/skills/dosbox-x-debug-protocol/references/history.md +13 -4
  14. dbxdebug-0.5.0/src/dbxdebug/__init__.py +282 -0
  15. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/src/dbxdebug/addressing.py +11 -0
  16. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/src/dbxdebug/capture_io.py +7 -0
  17. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/src/dbxdebug/cli.py +80 -14
  18. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/src/dbxdebug/dbx_kbd.py +10 -0
  19. dbxdebug-0.5.0/src/dbxdebug/frames.py +427 -0
  20. dbxdebug-0.5.0/src/dbxdebug/gdb.py +994 -0
  21. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/src/dbxdebug/html.py +10 -0
  22. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/src/dbxdebug/keyboard.py +37 -0
  23. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/src/dbxdebug/paths.py +7 -0
  24. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/src/dbxdebug/qmp.py +203 -6
  25. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/src/dbxdebug/registry.py +13 -0
  26. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/src/dbxdebug/session.py +219 -25
  27. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/src/dbxdebug/utils.py +5 -0
  28. dbxdebug-0.5.0/src/dbxdebug/video.py +333 -0
  29. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/tests/integration/conftest.py +14 -11
  30. dbxdebug-0.5.0/tests/integration/test_headless.py +282 -0
  31. dbxdebug-0.5.0/tests/integration/test_live_session.py +1275 -0
  32. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/tests/test_cli_session.py +10 -0
  33. dbxdebug-0.5.0/tests/test_exports.py +195 -0
  34. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/tests/test_frames.py +144 -11
  35. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/tests/test_gdb_capabilities.py +10 -0
  36. dbxdebug-0.5.0/tests/test_gdb_framing.py +668 -0
  37. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/tests/test_gdb_registers.py +10 -0
  38. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/tests/test_qmp_commands.py +129 -0
  39. dbxdebug-0.5.0/tests/test_read_bulk.py +325 -0
  40. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/tests/test_session.py +69 -2
  41. dbxdebug-0.5.0/tests/test_video_client_ownership.py +345 -0
  42. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/uv.lock +36 -40
  43. dbxdebug-0.3.0/.release-please-manifest.json +0 -3
  44. dbxdebug-0.3.0/src/dbxdebug/__init__.py +0 -134
  45. dbxdebug-0.3.0/src/dbxdebug/frames.py +0 -282
  46. dbxdebug-0.3.0/src/dbxdebug/gdb.py +0 -439
  47. dbxdebug-0.3.0/src/dbxdebug/video.py +0 -239
  48. dbxdebug-0.3.0/tests/integration/test_live_session.py +0 -538
  49. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/.github/dependabot.yml +0 -0
  50. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/.gitignore +0 -0
  51. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/LICENSE +0 -0
  52. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/plugin.json +0 -0
  53. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/release-please-config.json +0 -0
  54. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/skills/dosbox-x-debug-protocol/SKILL.md +0 -0
  55. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/skills/dosbox-x-debug-protocol/references/packets.md +0 -0
  56. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/skills/dosbox-x-debug-protocol/references/qmp-commands.md +0 -0
  57. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/src/dbxdebug/doctor.py +0 -0
  58. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/tests/__init__.py +0 -0
  59. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/tests/integration/__init__.py +0 -0
  60. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/tests/test_addressing.py +0 -0
  61. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/tests/test_basic.py +0 -0
  62. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/tests/test_paths.py +0 -0
  63. {dbxdebug-0.3.0 → dbxdebug-0.5.0}/tests/test_registry.py +0 -0
@@ -9,6 +9,18 @@ name: Release
9
9
  on:
10
10
  push:
11
11
  branches: [main]
12
+ # Recovery path. A tag/release created with GITHUB_TOKEN does not trigger a
13
+ # workflow, so a publish that fails for an environmental reason (a bad
14
+ # publisher/metadata pairing, a PyPI outage) cannot be retried by re-running
15
+ # the original run -- a re-run replays the workflow file from that commit,
16
+ # including whatever was broken. Dispatch this against the existing tag
17
+ # instead. It rebuilds from that tag and republishes; it never creates a tag.
18
+ workflow_dispatch:
19
+ inputs:
20
+ tag:
21
+ description: "Existing tag to build and publish, e.g. v0.3.0"
22
+ required: true
23
+ type: string
12
24
 
13
25
  permissions: {}
14
26
 
@@ -18,6 +30,7 @@ concurrency:
18
30
 
19
31
  jobs:
20
32
  release-please:
33
+ if: ${{ github.event_name == 'push' }}
21
34
  runs-on: ubuntu-latest
22
35
  permissions:
23
36
  contents: write # create the release + tag
@@ -26,7 +39,7 @@ jobs:
26
39
  release_created: ${{ steps.release.outputs.release_created }}
27
40
  tag_name: ${{ steps.release.outputs.tag_name }}
28
41
  steps:
29
- - uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1
42
+ - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
30
43
  id: release
31
44
  with:
32
45
  config-file: release-please-config.json
@@ -36,14 +49,14 @@ jobs:
36
49
  # If anything here fails, the publish job below is skipped and nothing ships.
37
50
  ci:
38
51
  needs: release-please
39
- if: ${{ needs.release-please.outputs.release_created == 'true' }}
52
+ if: ${{ !cancelled() && (needs.release-please.outputs.release_created == 'true' || github.event_name == 'workflow_dispatch') }}
40
53
  permissions:
41
54
  contents: read
42
55
  uses: ./.github/workflows/test.yml
43
56
 
44
57
  publish:
45
58
  needs: [release-please, ci]
46
- if: ${{ needs.release-please.outputs.release_created == 'true' }}
59
+ if: ${{ !cancelled() && needs.ci.result == 'success' && (needs.release-please.outputs.release_created == 'true' || github.event_name == 'workflow_dispatch') }}
47
60
  runs-on: ubuntu-latest
48
61
  environment:
49
62
  name: pypi
@@ -52,13 +65,13 @@ jobs:
52
65
  id-token: write # OIDC for PyPI Trusted Publishing
53
66
  contents: write # upload built artifacts to the GitHub Release
54
67
  steps:
55
- - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
68
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
56
69
  with:
57
- ref: ${{ needs.release-please.outputs.tag_name }}
70
+ ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || needs.release-please.outputs.tag_name }}
58
71
  fetch-depth: 0 # Full history + tags for hatch-vcs version
59
72
 
60
73
  - name: Install uv and set up Python
61
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
74
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
62
75
  with:
63
76
  python-version: "3.11"
64
77
 
@@ -66,9 +79,9 @@ jobs:
66
79
  run: uv build
67
80
 
68
81
  - name: Publish to PyPI
69
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
82
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
70
83
 
71
84
  - name: Attach artifacts to the GitHub Release
72
85
  env:
73
86
  GH_TOKEN: ${{ github.token }}
74
- run: gh release upload "${{ needs.release-please.outputs.tag_name }}" dist/* --clobber
87
+ run: gh release upload "${{ github.event_name == 'workflow_dispatch' && inputs.tag || needs.release-please.outputs.tag_name }}" dist/* --clobber
@@ -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,50 @@
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
+
17
+ ## [0.4.0](https://github.com/lokkju/dbxdebug/compare/v0.3.0...v0.4.0) (2026-09-05)
18
+
19
+
20
+ ### ⚠ BREAKING CHANGES
21
+
22
+ * **session:** DosboxSession now runs the emulator headless by default. A caller that relied on seeing the window -- to watch the guest while debugging, or to drive it by hand -- must now pass headless=False explicitly. Everything else is unaffected: the debug surface is identical headless, and screen capture was verified byte-identical between the two modes. Note also that `headless` sits mid-dataclass, so positional construction past `connect=` shifts.
23
+
24
+ ### Features
25
+
26
+ * **session:** add DosboxSession(headless=True), on by default ([8af1f56](https://github.com/lokkju/dbxdebug/commit/8af1f56528977c2dbc4a2ddec442f4aa6f76933d)), closes [#3](https://github.com/lokkju/dbxdebug/issues/3)
27
+ * **session:** add read_bulk, a one-call bulk memory read ([f8e228b](https://github.com/lokkju/dbxdebug/commit/f8e228b101b14e682e3b4ec98b434f1fd7e5e7c7)), closes [#9](https://github.com/lokkju/dbxdebug/issues/9)
28
+
29
+
30
+ ### Bug Fixes
31
+
32
+ * **ci:** unblock publishing and add a recovery path for a failed one ([9c69a87](https://github.com/lokkju/dbxdebug/commit/9c69a87c73f30b0a249e1bd5ec3c4cc4f2af0736))
33
+ * **gdb:** bound reads and resynchronise the packet stream ([5940242](https://github.com/lokkju/dbxdebug/commit/5940242035181668c4d35e61db56d5b2342aa620))
34
+ * **session:** record that headless is now the default ([d649d70](https://github.com/lokkju/dbxdebug/commit/d649d705bb46261d38c30a23fc9c39bd390f7c87))
35
+ * **video,cli:** let a GDB client be borrowed instead of reopened ([bb9d351](https://github.com/lokkju/dbxdebug/commit/bb9d35170283407f9c7fbd64f4cfd4f1d75f1d64))
36
+
37
+
38
+ ### Performance Improvements
39
+
40
+ * **clients:** set TCP_NODELAY, and correct what read_bulk is now worth ([c535e3b](https://github.com/lokkju/dbxdebug/commit/c535e3bafe2799e708714e4f771046fd35a55d81))
41
+
42
+
43
+ ### Documentation
44
+
45
+ * correct the hazard write-ups now that the GDB stream is fixed ([a1821aa](https://github.com/lokkju/dbxdebug/commit/a1821aa8904987afd73ad1ba97d022fee5e30213)), closes [#4](https://github.com/lokkju/dbxdebug/issues/4) [#5](https://github.com/lokkju/dbxdebug/issues/5)
46
+ * **migration:** fix the gaps a real migration hit ([96ffaa7](https://github.com/lokkju/dbxdebug/commit/96ffaa7a901a35529e14a81828f8a36d550664d9))
47
+
3
48
  ## [0.3.0](https://github.com/lokkju/dbxdebug/compare/v0.2.1...v0.3.0) (2026-09-05)
4
49
 
5
50
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dbxdebug
3
- Version: 0.3.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,12 +70,11 @@ 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
- # Without these DOSBox-X opens a real window and takes your keyboard focus.
76
- HEADLESS = {"SDL_VIDEODRIVER": "dummy", "SDL_AUDIODRIVER": "dummy"}
77
-
78
- with DosboxSession(env=HEADLESS) as session:
75
+ # Headless by default: no window, no keyboard focus, no audio device.
76
+ # Pass headless=False when you want to watch the guest.
77
+ with DosboxSession() as session:
79
78
  print(f"pid={session.pid} gdb={session.gdb_port} qmp={session.qmp_port}")
80
79
 
81
80
  gdb = session.gdb
@@ -103,15 +102,56 @@ Leaving the `with` block kills the emulator's whole process group, deletes the
103
102
  scratch workdir, and removes the registry entry. `atexit` and SIGINT/SIGTERM
104
103
  handlers repeat that teardown for a process that leaves by some other door.
105
104
 
105
+ ### Headless is the default
106
+
107
+ A session runs with SDL's `dummy` video and audio drivers, so it opens **no
108
+ window**, takes no keyboard focus, and claims no audio device. Starting one
109
+ does not take over the display of whoever is at the machine, which is what
110
+ makes a test suite -- or several sessions at once -- usable on a workstation.
111
+
112
+ What you give up is real: you cannot watch the guest. `headless=False` gives
113
+ you a normal window, and that window will take the focus of whoever is at the
114
+ keyboard, so it is for looking at one session, not for a suite.
115
+
116
+ The debug surface is unaffected. `screen_lines()` reads guest video memory
117
+ over GDB and `qmp.screendump()` renders through DOSBox-X's own capture path;
118
+ `dummy` removes the window, not the rendering. Compared live, a headless
119
+ session and a windowed session showing the same screen returned
120
+ **byte-identical** 720x400 PNGs and identical text lines.
121
+
122
+ `env=` still works and takes precedence over the headless variables, so
123
+ `DosboxSession(headless=True, env={"SDL_VIDEODRIVER": "x11"})` keeps the dummy
124
+ audio driver and gets an x11 window. Precedence, lowest to highest: inherited
125
+ `os.environ`, then the headless variables, then `env`.
126
+
106
127
  Useful `DosboxSession` arguments: `mounts={"c": path}`, `program=` and
107
128
  `files=` to stage host files onto the first mounted drive, `autoexec=`,
108
- `conf=` for your own conf template, `cycles=`, `connect=False` for the handle
109
- without clients, `boot_settle=` (default 2.5s -- the debug ports accept long
110
- before the guest reaches a prompt), and `label=` to name the scratch workdir.
111
- Conveniences on the handle: `screen_lines()`, `wait_for_text()`,
129
+ `conf=` for your own conf template, `cycles=`, `headless=False` for a visible
130
+ window, `connect=False` for the handle without clients, `boot_settle=`
131
+ (default 2.5s -- the debug ports accept long before the guest reaches a
132
+ prompt), and `label=` to name the scratch workdir.
133
+ Conveniences on the handle: `screen_lines()`, `read_bulk()`, `wait_for_text()`,
112
134
  `assert_screen_readable()`, `running`, `set_breakpoint()`,
113
135
  `remove_breakpoint()`.
114
136
 
137
+ ### Reading a region: `session.read_bulk(address, length)`
138
+
139
+ One QMP `memdump` reply instead of thousands of GDB `m` round-trips.
140
+ Measured on this build, one 64 KB segment: **2.8 ms** through `read_bulk` against **33.5 ms** for the same bytes through 64 one-kilobyte `gdb.read_memory` calls -- 12x against a running guest, and 2.6x (2.1 ms against 5.5 ms) when the CPU is already halted, where the loop has no emulation competing with it. Both paths return
141
+ identical bytes; the live suite asserts it.
142
+
143
+ ```python
144
+ data = session.read_bulk(0xF0000, 0x10000) # -> 65536 bytes
145
+ ```
146
+
147
+ It exists because the raw sequence has two traps. `memdump` is refused
148
+ while the CPU runs, and the obvious way to stop it -- `qmp.stop()` --
149
+ parks the emulation thread that services the GDB stub, so the dump
150
+ succeeds and every later GDB request goes unanswered. `read_bulk` halts
151
+ through GDB, dumps, and resumes. If the CPU was ALREADY stopped (a
152
+ breakpoint, the interactive debugger, or a QMP stop) it takes the dump
153
+ and leaves it stopped: it resumes only what it halted itself.
154
+
115
155
  ## Addressing -- read this once
116
156
 
117
157
  This is the most consequential behaviour in the package, and the thing most
@@ -182,7 +222,7 @@ old build fails at `start()` rather than at the first breakpoint.
182
222
  |---|---|
183
223
  | `dbxdebug.session` | `DosboxSession` -- launch, connect, tear down. Also `DEFAULT_CONF`, `render_conf`, `DosboxLaunchError` |
184
224
  | `dbxdebug.gdb` | `GDBClient`, `IncompatibleStubError`, `REGISTER_NAMES` |
185
- | `dbxdebug.qmp` | `QMPClient`, `QMPError` -- keys, `memdump`, `screendump`, save/load state, `stop`/`cont`, `debug_break_on_exec` |
225
+ | `dbxdebug.qmp` | `QMPClient`, `QMPError`, `CpuNotStoppedError` -- keys, `memdump`, `screendump`, save/load state, `stop`/`cont`, `debug_break_on_exec` |
186
226
  | `dbxdebug.addressing` | `linear`, `linear_pc`, `parse_address`, `bp_addr`, `PackedAddressError` |
187
227
  | `dbxdebug.frames` | `walk_frames`, `steps_out`, `Frame`, `FrameWalkError` |
188
228
  | `dbxdebug.registry` | `list_sessions`, `reap`, `format_table`, `free_port`, `kill_group` |
@@ -191,10 +231,36 @@ old build fails at `start()` rather than at the first breakpoint.
191
231
  | `dbxdebug.video` / `.html` / `.capture_io` | `DOSVideoTools`, HTML rendering, `ScreenRecorder`, `load_capture` |
192
232
  | `dbxdebug.keyboard` / `.dbx_kbd` | key-chord helpers and constants (`CTRL_C`, `ctrl_key`, `DBX_KEY`, ...) |
193
233
 
194
- `dbxdebug/__init__.py` re-exports the clients, the video tools, and the
195
- keyboard helpers. It does **not** yet re-export `DosboxSession`, `addressing`,
196
- `frames`, `registry`, `paths` or `doctor` -- import those from their modules,
197
- 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.
198
264
 
199
265
  ### Locating the emulator
200
266
 
@@ -230,22 +296,29 @@ steps_out(gdb) # single-step until the current frame
230
296
  BP, a saved BP that is not strictly above the current one (which is also what
231
297
  terminates a cyclic chain), a short or failed read, or `max_depth`.
232
298
 
233
- `steps_out` is **a heuristic over SP**, with bounds worth knowing before you
234
- rely on it. It records the entry BP and steps until `SP & 0xFFFF` is strictly
235
- greater than `BP + 2` -- past the return-address slot, which only the `ret`
236
- 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:
237
307
 
238
308
  * it raises `FrameWalkError` if `SP > BP` on entry (no frame pointer
239
- established, or a stale BP) rather than returning after a single step;
240
- * a callee that pops BP and jumps to a shared epilogue popping further
241
- registers raises SP past `BP+2` while still inside the callee, and this
242
- stops there, early. Telling that apart from a real return needs instruction
243
- 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;
244
315
  * called at a procedure's first instruction, before the prologue has run, BP
245
316
  still belongs to the caller and this measures the caller's frame;
246
317
  * **clear every breakpoint first.** A breakpoint hit during one of these steps
247
- makes the stub emit an unsolicited stop reply, which permanently desyncs the
248
- GDB connection -- see Known hazards.
318
+ makes the stub emit an unsolicited stop reply. The connection no longer
319
+ desyncs on one -- it is queued on `gdb.pending_stops` -- but the stop you
320
+ get is still not the step you asked for, so the walk ends up measuring a
321
+ frame you did not mean to be in. See Known hazards.
249
322
 
250
323
  ## CLI
251
324
 
@@ -352,56 +425,99 @@ with GDBClient() as gdb: # localhost:2159
352
425
  gdb.set_breakpoint("1000:0020") # the same address, seg:off form
353
426
  gdb.step()
354
427
  gdb.continue_execution()
428
+ gdb.wait_for_stop(timeout=30.0) # a stop NOBODY asked for
355
429
 
356
430
  with QMPClient() as qmp: # localhost:4444
357
431
  qmp.send_key(CTRL_C)
358
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
359
435
 
360
- with DOSVideoTools() as video:
436
+ with DOSVideoTools() as video: # owns its own client
361
437
  lines = video.screen_dump()
362
438
  lines, ticks = video.screen_dump_with_ticks()
439
+
440
+ with DOSVideoTools(gdb=session.gdb) as video: # borrows a session's client
441
+ lines = video.screen_dump() # and never closes it
363
442
  ```
364
443
 
365
444
  ## Known hazards
366
445
 
367
- Three open defects. All three are reproduced, all have tests pinning today's
368
- behaviour, and none is fixed. Plan around them.
446
+ One open defect, and two that are fixed but still shape how you should write
447
+ against this library.
448
+
449
+ **Unanswered GDB packets: bounded, not silent** (was
450
+ [#4](https://github.com/lokkju/dbxdebug/issues/4), fixed). `GDBClient` arms a
451
+ 30 s read timeout on every read, not just the connect, and raises
452
+ `GDBTimeoutError` naming the packet that went unanswered. Override it per
453
+ client with `GDBClient(timeout=...)`, or pass `timeout=None` for the old
454
+ unbounded blocking. The underlying interaction is unchanged and still worth
455
+ knowing: while the emulator is QMP-stopped the GDB stub is not serviced at
456
+ all, so `qmp.stop()` followed by any GDB request cannot be answered. It now
457
+ fails in 30 s with a message instead of deadlocking. To read memory, reach
458
+ for `session.read_bulk()`, which halts over GDB for you; otherwise halt
459
+ with `gdb.halt()` rather than stopping over QMP. A `memdump` refused for
460
+ this reason now raises `CpuNotStoppedError` (a `QMPError`) naming the fix,
461
+ rather than surfacing the stub's refusal alone.
462
+
463
+ **Stream desync: resynchronised, or refused** (was
464
+ [#5](https://github.com/lokkju/dbxdebug/issues/5), fixed). Both triggers were
465
+ reproduced against a live build -- an unsolicited `$S05` stop reply (QMP
466
+ break-on-exec fires one nobody asked for) and a timed-out request leaving its
467
+ reply in the stream -- and both are handled at the framing layer:
468
+
469
+ * an unrequested stop reply is diverted to `gdb.pending_stops` instead of
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;
476
+ * an abandoned exchange is drained before the next packet is sent, so the
477
+ request after a `GDBTimeoutError` gets its own reply rather than the
478
+ previous one's;
479
+ * if that drain cannot complete, the client marks itself **permanently
480
+ unusable** and every later call raises `GDBDesyncError`. That is
481
+ deliberate: a loud failure beats a plausible wrong answer. Open a new
482
+ `GDBClient`.
483
+
484
+ Still true, and still worth doing: keep GDB traffic serialised on one thread,
485
+ and never add a read-retry loop. Two identical consecutive requests mask a
486
+ one-packet lag perfectly, so retrying would look like it worked whether or
487
+ not the stream had shifted.
488
+
489
+ **One GDB client at a time**
490
+ ([#8](https://github.com/lokkju/dbxdebug/issues/8)). The stub serves a single
491
+ GDB client. A second one completes the TCP connect and then never gets its
492
+ `qSupported` reply -- no refusal, and nothing on the wire. Two things changed
493
+ independently: it now fails after the read timeout instead of hanging forever
494
+ ([#5](https://github.com/lokkju/dbxdebug/issues/5)), and nothing in this
495
+ package opens a competing connection behind your back any more
496
+ ([#11](https://github.com/lokkju/dbxdebug/issues/11)). The stub limitation
497
+ itself is still open ([#8](https://github.com/lokkju/dbxdebug/issues/8)).
369
498
 
370
- **No read timeout** ([#4](https://github.com/lokkju/dbxdebug/issues/4)).
371
- `GDBClient` never calls `settimeout`, so any packet the stub does not answer
372
- hangs the caller forever with no diagnostic. This is easy to reach by
373
- accident, because the two protocols interact: while the emulator is
374
- QMP-stopped the GDB stub does not answer at all, so `qmp.stop()` followed by
375
- any GDB request is a deadlock. Arm the socket yourself right after `start()`:
499
+ Lend the session's client out instead of opening a second one:
376
500
 
377
501
  ```python
378
- if session.gdb is not None and session.gdb.sock is not None:
379
- session.gdb.sock.settimeout(30.0)
380
- ```
502
+ from dbxdebug.cli import GDB_CLIENT_KEY, main
503
+ from dbxdebug.video import DOSVideoTools
381
504
 
382
- **Desync after a timeout or an unsolicited stop reply**
383
- ([#5](https://github.com/lokkju/dbxdebug/issues/5) -- confirmed and
384
- reproduced). `GDBClient` assumes strict request/response and never
385
- resynchronises. Once a reply is left unread, every later request returns the
386
- *previous* request's payload, silently and permanently. Both triggers are
387
- confirmed against a live build: an unsolicited `$S05` stop reply (QMP
388
- break-on-exec fires one nobody asked for), and a timed-out request leaving its
389
- reply in the stream -- so the `settimeout` above converts a hang into a
390
- `TimeoutError` that lands you here instead. Keep GDB traffic serialised on one
391
- thread, treat `TimeoutError` as fatal to the connection rather than retryable,
392
- and check the length of every `read_memory` result against what you asked for:
393
- it is the one cheap symptom visible from outside. Do not add a read-retry
394
- loop -- two identical consecutive requests mask a one-packet lag perfectly, so
395
- it would look like it worked whether or not the stream had shifted.
505
+ with DosboxSession(...) as session:
506
+ with DOSVideoTools(gdb=session.gdb) as video: # borrowed, not reopened
507
+ lines = video.screen_dump()
396
508
 
397
- **One GDB client at a time**
398
- ([#8](https://github.com/lokkju/dbxdebug/issues/8)). The stub serves a single
399
- GDB client. A second one completes the TCP connect and then blocks forever in
400
- the `qSupported` handshake -- no refusal, no error, just a hang. In particular,
401
- pointing a `dbxdebug mem` / `cpu` / `screen` command at a session that already
402
- holds its own GDB client hangs that command. Use
403
- `DosboxSession(connect=False)` if you want the CLI to be the one client, or
404
- drive the session's own `session.gdb` from Python.
509
+ # the CLI, driven in-process, borrows the same client
510
+ main(["screen", "show"], obj={GDB_CLIENT_KEY: session.gdb}, standalone_mode=False)
511
+ ```
512
+
513
+ A borrowed client is never closed by the borrower -- the session stays its
514
+ owner. `DOSVideoTools()` with no `gdb=` still builds and closes its own, which
515
+ is the right thing when it is the only client, and so does a `dbxdebug` command
516
+ run as a separate process. Running the CLI as a separate process against a
517
+ session that holds its own client is still a second connection however it is
518
+ spelled, so it now fails on the timeout rather than working: use
519
+ `DosboxSession(connect=False)` there, or drive the session's `session.gdb`
520
+ from Python.
405
521
 
406
522
  QMP is a separate socket and is undisturbed by any of this, which is why
407
523
  `qmp.query_status()` is the way to learn that the CPU stopped.
@@ -425,11 +541,20 @@ prove the library actually drives one: the vendor GDB capabilities, `eip` as an
425
541
  offset rather than a linear address, a breakpoint above 64 KB firing, `memdump`
426
542
  agreeing with GDB reads and refusing while the CPU runs, `frames.steps_out`
427
543
  stopping after a real 16-bit `ret`, and what the GDB client does when the
428
- stream is disturbed -- which today is desync, pinned by tests that fail the
429
- moment it is fixed. The binary is located with `dbxdebug.paths.find_dosbox_x`
544
+ stream is disturbed -- an unrequested stop reply queued rather than read as an
545
+ answer, an abandoned reply drained rather than handed to the next request, and
546
+ a request the stub will never answer bounded rather than deadlocked. The
547
+ framing paths a live emulator will not produce on demand are covered against a
548
+ fake socket in `tests/test_gdb_framing.py`.
549
+ The binary is located with `dbxdebug.paths.find_dosbox_x`
430
550
  -- set `DBXDEBUG_DOSBOX` to choose a specific build -- and the tests skip when
431
551
  none is found.
432
552
 
553
+ One test in `tests/integration/test_headless.py` is skipped even under the
554
+ `integration` marker: the one that compares a headless screen capture against
555
+ a windowed one has to launch a real window, which takes the keyboard focus of
556
+ whoever is at the machine. Set `DBXDEBUG_ALLOW_WINDOWED=1` to run it.
557
+
433
558
  The other gates:
434
559
 
435
560
  ```bash