dbgscope 0.3.0

Typed WinDbg/DbgEng debug sessions, with kernel pool and user heap walkers built on them.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
# Changelog

All notable changes to this project are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.3.0] - 2026-10-17

This release lets a breakpoint callback read its hit and decide whether the target stops, decodes
ARM64 instructions in full and walks ARM64 kernel pools and user heaps, adds a PE reader and
bounded reads and symbol reloads, and corrects how the pool and heap walkers read current Windows
builds.

### Migration from 0.2

- `BreakpointCallback` is `Box<dyn Fn(&BreakpointHit<'_>) -> BreakpointAction>`: it reads the hit
  through `BreakpointHit` and answers `Default`, `Go` or `Break`. Register it with
  `set_breakpoint_callback` and remove it with `clear_breakpoint_callback`;
  `create_debug_event_context_callbacks`, `set_breakpoint_event_callbacks` and the public
  `DebugEventContextCallbacks` are removed.
- `end_session` returns `TargetLeft` rather than `()`. `KernelHalted` means the session ended and
  the guest is probably still stopped at a break; attach again and detach to release it.
- A command that fails returns `DbgEngError::CommandFailedWithOutput` where it returned
  `CommandFailed`. Match both, or read the engine's own line through `DbgEngError::output()`.
- Public enums gain variants and public structs gain fields: `DbgEngError` (five variants),
  `Operand::Undecoded`, `PoolState::Uncommitted` and `HeapState::Uncommitted`,
  `HeapQueryError::Wow64Process` and `InvalidTeb`, `VsSemanticFamily::AffinitySlotsSelfRelative`,
  `Instruction::privilege` and `reads`, `HeapWalkReport::uncommitted_gaps` and
  `object::Globals::upcase`. Update exhaustive matches and struct literals.
- `HeapRoot::index` is a position in `ntdll`'s heap list rather than in the PEB's `ProcessHeaps`,
  and every pool and heap layout fingerprint moves. Re-resolve anything cached by fingerprint.
- A caller that read `Flow::Unknown` as "operands were not decoded" asks
  `InstructionSet::operands_are_read` instead; `flow_is_read` is the separate question for `flow`.

### Changed

- **A breakpoint callback is handed a typed `BreakpointHit`** -- the breakpoint's `id()`, its
  `address()`, and an `engine()` stopped at the hit -- where it was handed the raw
  `IDebugBreakpoint2`, the event context and its size. A callback that records a hit has to read
  it, and it is a `'static` closure called from inside `dbgeng.dll`, so it cannot borrow the
  `DebugEngine` that registered it; the view is a borrowed engine on **the client the callback is
  registered on**, built per hit, and a hit whose view cannot be built is answered `Default`. Not
  the breakpoint's adder: a DbgEng client may be used only on the thread that made it, and a
  breakpoint added by another client on another thread would have handed the callback one it may
  not call. So registering is one call, `set_breakpoint_callback`, which makes the callbacks and
  registers them on its own client, and `clear_breakpoint_callback` unregisters them -- replacing
  `create_debug_event_context_callbacks` with `set_breakpoint_event_callbacks` and
  `clear_breakpoint_event_callbacks`, which earlier in this release could register callbacks on a
  client other than the one they would read through.
  **Breaking** for a `BreakpointCallback`; the only one was `examples/breakpoint_status_probe.rs`,
  which reads through the view now.
  `test_a_breakpoint_callback_reads_its_hit_and_lets_it_go_past` is the real-engine check on CI's
  runners: a launched process, `Go` on the first hit and `Break` on the second, each read through
  its view.

- **A breakpoint callback decides whether the target stops.** `BreakpointCallback` returns a
  `BreakpointAction` -- `Default`, `Go` or `Break`, the `DEBUG_STATUS_*` an
  `IDebugEventContextCallbacks::Breakpoint` answers with -- where it returned
  `windows::core::Result<()>`. That type could not say it: the engine reads the status out of the
  callback's `HRESULT`, every status is a success code, and a `Result`'s `Ok` reaches the engine as
  `S_OK`, which is `DEBUG_STATUS_NO_CHANGE`; the dispatch discarded what the callback returned
  besides. So a callback could watch a breakpoint but never let one go past. **Breaking** for a caller
  that implements one; nothing in this crate or in `windbg-mcp` does.

  The status leaves through one function, `event_status`, as an `Err` carrying the code, because
  `windows` gives an implementer no other way to return a success `HRESULT`: `From<Result<T>> for
  HRESULT` passes the code through unchanged. That is pinned by
  `test_a_breakpoint_action_reaches_the_engine_as_its_status`, which fails against an
  `event_status` that answers `Ok(())` (mutated and run on ARM64). Upstream:
  microsoft/win32metadata#2075.

  Two hazards in the same dispatch went with it: a callback that **panics** now answers `Default`
  rather than unwinding into `dbgeng.dll`, which aborts the process, and a null breakpoint is no
  longer `unwrap`ped. `set_breakpoint_event_callbacks` returns `Result<(), DbgEngError>` rather
  than panicking, and `clear_breakpoint_event_callbacks` unregisters them.

  `examples/breakpoint_status_probe.rs` is the measurement against a real engine, user-mode and
  live kernel over serial: `Default` stops exactly as no callback does, `Go` lets every hit through
  and stops on the one answered `Break`, the callback can read the engine from inside the hit, and a
  hit costs one trap however it is let through.

- **CI's ARM64 entry names an image rather than the moving `windows-11-arm` label**, and the
  architecture is a matrix value rather than that label matched a second time. Both entries here
  drive a real engine — 46 of this crate's non-ignored tests reach `DebugCreate`, and several
  launch or attach to a live process — so a run is a reading of that runner image's inbox
  `dbgeng.dll`. A label is not that: GitHub migrated `windows-11-arm` onto the Visual Studio 2026
  ARM64 image on 2026-09-23, and this workflow's own runs report `Image: windows-11-arm64` on
  2026-09-22T19:45Z against `windows-11-vs2026-arm64`, `Version: 20260920.164.1` on run
  36040398859 — a different OS build and therefore a different engine, under an unchanged
  workflow and with nothing printed to say so. The entry is `windows-11-vs2026-arm` now, and a
  step records which image answered.

  **The hazard was in the step that reads the label, not in the label.** `vs-architecture` was
  `${{ matrix.os == 'windows-11-arm' && 'arm64' || 'x64' }}`, so renaming the runner without
  touching it would have selected the **x64** toolchain on an ARM64 machine — wrong, and green.
  `arch` is now stated per entry through an `include` that adds a key to an existing combination
  rather than creating new ones, and the job name is `build (<arch>, <toolchain>)`, which no
  image migration can move. Nothing was stranded by the rename: the repository ruleset protects
  deletion, non-fast-forward and linear history and requires **no status check contexts**, which
  was read rather than assumed.

  Sibling to `windbg-mcp`'s `FOLLOWUPS.md` item 32, which was the same migration met by a repo
  that had run the two labels side by side through it. This one had not, so the migration landed
  here unobserved — which is the argument for the recording step.

- **`end_session` says what became of the target.** It returns `TargetLeft` -- `KernelRunning`,
  `KernelHalted` or `Unspoken` -- where it returned `()`, because the session ending and the target
  running are different answers and only the first was kept. A live kernel's resume was
  `let _ =`, so a guest never told to run was reported as released: on 2026-09-15 a reattach found
  one at the same `System Uptime` it had been left at. A failed resume is `KernelHalted` rather
  than an `Err`, since the session did end and the useful thing to do is go and look at the guest;
  `Err` stays for a detach that did not happen (`DbgEngError::IncompleteKernelDetach`). The release
  itself now goes through the engine's own `qd` rather than `SetExecutionStatus(GO)` followed by an
  active detach, which left a 29671 hypervisor unresponsive while reporting success.
  **Breaking** for a caller that names the return type.

- **Object names fold the way the target's object manager folds them.** `Namespace::object_at`
  matched each path component with `eq_ignore_ascii_case`, so a name differing from the one asked
  for only outside ASCII -- one object to the kernel -- came back `ObjectError::NotFound`. The walk
  now folds through the target's own `UnicodeUpcaseTable844`, reached from `nt!PspHostSiloGlobals`,
  and falls back to this host's `RtlUpcaseUnicodeChar` where the target will not say where its
  table is. `Upcase::source` reports which answered: `Target`, `Host`, or `Mixed` when a read into
  the target's table missed and a unit was folded here instead, which is what a partial dump
  produces. An ASCII name reads no table at all. `Namespace::upcase` exposes the walk's fold, and
  `same_object_name` is the host's fold for a caller holding two names and no target.
  windbg-mcp `FOLLOWUPS.md` items 70 and 77.

### Added

- **A command that fails keeps what the engine printed**: `DbgEngError::CommandFailedWithOutput`,
  read through `DbgEngError::output()`. `execute_command`, `execute_command_bounded`,
  `execute_and_wait`, `settle` and `run_to_address` answer with it where they answered
  `CommandFailed`, and the message is unchanged. The code alone said little -- `0x80040205` is the
  answer to a mistyped command, to an argument naming nothing, to `g` on a dump and to `||1s` on a
  system not yet loaded -- while the engine's own line, which says which, was printed into the
  capture and dropped with it. Measured on dbgeng 10.0.29617.1000 and 10.0.26100.1742 (ARM64), the
  same on both (`examples/failed_command_output.rs`): `Couldn't resolve error at 'ntdll!Nope'`,
  `^ No runnable debuggees error in 'g'`, `^ Illegal debuggee error in '||1s'`, with the output of
  any segment that ran before the failing one kept beside it. Not every failure takes this road:
  `dt` of a missing type and an extension command that does not exist print their complaint and
  return success. Asked for by windbg-mcp `FOLLOWUPS.md` item 126.
- **`DebugEngine::reload_symbols_bounded(args, timeout_ms)`** and **`SymbolReload`**: a reload with
  the crate's watchdog around it, reporting a break by either origin in `cut_short` rather than as
  the `0xD000013A` it provokes. Measured before it was written, since a watchdog that cannot break
  the call in claims a bound that does not exist: against a live ARM64 kernel (`ntoskrnl`
  10.0.26100.9457, Windows 11 25H2) over 115200-baud serial, the first reload after an attach read
  181 modules in 3.9s, and bounds of 0.5s, 1s and 2s returned at 517ms, 1,074ms and 2,005ms
  (`examples/reload_interrupt_probe.rs`).
  **A reload cut short leaves a smaller inventory than it started with** -- 20, 43 and 91 of 181 --
  because it rebuilds the list rather than adding to it; only another reload restores it. Asked for
  by windbg-mcp `FOLLOWUPS.md` item 54.
- **`DebugEngine::integer_register(name)`**: one integer register by name, zero-extended -- the read
  a breakpoint callback makes, where `register_values` describes and reads every register the
  engine knows (194 on an ARM64 user-mode target, 214 on its kernel). A register holding no integer
  is a new `DbgEngError::Register` rather than a number made of part of it.
- **`DebugEngine::current_thread_data_offset()`**: `GetCurrentThreadDataOffset`, the TEB in user
  mode and the thread running on the current processor on a kernel -- which is what names a
  *thread* there, where `current_thread_system_id` names a processor. Checked against the engine's
  own `@$thread` at a stop, user mode and live kernel, by `examples/breakpoint_status_probe.rs`.
- **`pe::read_import_address_table` and `Image::iat_directory`**: the import address table
  (`IMAGE_DIRECTORY_ENTRY_IAT`) slot by slot, each with the value it holds, leaving out the zero
  that ends a library. The import directory is what `read_imports` names imports from, and a
  linker may put it in a discardable section the loader frees once the driver starts: HEVD's ARM64
  build keeps it in `INIT`, which reads `??` on a live ARM64 kernel while its fifteen address-table
  slots read and hold `nt!ExAllocatePoolWithTag` and the rest. On a loaded image a slot holds the
  address its import was bound to, so naming that address -- which needs an engine -- names the
  import. Declared spans are checked against the image, refused rather than truncated.
- **`pe::read_exports_at`**: an image's own library name and the names its exports have at a set of
  addresses -- what an import bound to one of them is called, and the library it is filed under.
  The library is the spelling an importer uses: the kernel loads as `ntkrnlmp.exe` and is imported
  as `ntoskrnl.exe`. It reads the export address table and the name tables whole, since an address
  is found only by looking, and a **name string** only for an export at a requested address, which
  for a kernel is a handful of strings rather than thousands. A symbol lookup is the tempting
  alternative and the wrong one: on a live ARM64 kernel HEVD's `__C_specific_handler` slot is bound
  to an address the engine names `nt!_C_specific_handler`. A directory declared smaller than its
  header or past the image, a library name at RVA zero, and an ordinal past the address table are
  refused rather than read.

- **`Instruction::privilege`: which family a privileged instruction reaches** (#153) — an I/O
  port, a model-specific register, a control or debug register, a descriptor table, the interrupt
  mask, a cache or TLB, hardware virtualisation, or `Privilege::Other` for the rest. It is `Some`
  exactly when `privileged` is `true`, and that is how both are built rather than a property to
  hope for: each decoder produces the family and `privileged` is whether there is one. So a
  privileged instruction no family names is `Other`, with its mnemonic beside it, rather than
  absent.

  **From the decoder rather than from a table of mnemonics**, for the reason `privileged` is.
  #151 took membership out of `windbg-mcp`'s `driver_hazards`, and the family stayed behind as a
  per-architecture list — the next architecture would have needed one of its own, and x86's `str`
  and A64's `str` already collide. On x86 the family is read from iced: its CPUID features for the
  VMX, SVM, SEV-SNP and TDX families and for the MSR instructions; the registers an instruction
  uses, which is how `clts` and `lmsw` are control-register writes by a `cr0` neither names; and
  the flags it **sets or clears**, which keeps `sysret`, `rsm` and `erets` — which *write* `IF`,
  restoring every flag at once — out of the interrupt-mask family. Port I/O, the descriptor-table
  loads and cache or TLB maintenance have no such signal and are matched on iced's typed
  `Mnemonic`, as is `xsetbv`, whose `XCR0` iced does not model. Measured
  over iced 1.21's whole table: of the 156 encodings it calls privileged, the 132 that decode
  without a decoder option each answer exactly one family, except `skinit`, which clears `IF` and
  is placed by its SVM feature.

  On A64 it is the encoding. A system register is a control register unless it is `DAIF` or
  `ALLINT` — the interrupt masks, by either encoding — or lies in the IMPLEMENTATION DEFINED space,
  `op0` 3 with `CRn` 11 or 15, which is A64's model-specific registers (none of the 1,118
  registers `disarm64`'s generated table names is there). `dc`, `ic` and `tlbi` are cache or TLB
  maintenance; `hvc` and `smc` are virtualisation; `eret`, `at` and the rest of the `sys` space are
  `Other`. Over the executable sections of the 26100 ARM64 `ntoskrnl.exe`, 2,084 of 2,448,790
  words are privileged: 956 interrupt-mask, 841 control-register, 206 cache or TLB, 41
  virtualisation and 40 other — 32 of those the `cfp`/`dvp`/`cpp rctx` speculation restrictions —
  and no model-specific register access at all.

  It is not a severity, and not membership: `sgdt` reads the descriptor table from user mode,
  needs no privilege, and answers `None`. A consumer that reports it anyway is making its own call.

- **`DebugEngine::debuggee_type` and `DebugEngine::dump_files`** — the two engine queries that
  answer *what is this engine holding right now*, rather than what the opener asked for.
  `debuggee_type` is `GetDebuggeeType`'s `(class, qualifier)` pair as a `DebuggeeType`, with
  `is_kernel` and `is_live_kernel` over it; `dump_files` is `GetNumberDumpFiles` plus
  `GetDumpFileWide`, the files the session is open on, in the engine's order.

  **Both were already here and neither was reachable.** `is_kernel_target` read the pair and threw
  the qualifier away, and `is_live_kernel` read it again privately — so a caller could learn that a
  target was a kernel one and had no way to learn whether it was a live link or a dump. It now goes
  through `debuggee_type` like everything else, which is one read of `GetDebuggeeType` in the crate
  instead of three.

  **What they are for is a question no other query here answers: has the target been swapped?**
  `target_identity` reads as though it would, and does not — it is a generation *this crate* hands
  out at its own openers and teardowns, so a `.opendump` typed straight at the engine, or reached
  through a `.if` or an alias, leaves it exactly where it was. These two are read off the engine on
  every call, so a caller can take a reading when it opens a target and compare it after each
  command. `dump_files` returns an error rather than an empty list where the engine will not
  answer, for that reason: a failure folded into "no dump files" matches every live target, and
  would report a swapped target as an unchanged one.

  **`dump_files` refuses an engine with no debuggee**, and that guard was measured rather than
  anticipated: `GetNumberDumpFiles` on one is a `STATUS_ACCESS_VIOLATION` *inside* DbgEng — a
  structured exception `catch_unwind` cannot trap, so it takes the calling process down instead
  of failing the call. It killed `windbg-mcp`'s engine worker on the first run of the tier that
  exercises it, as a launched program running to completion. `debuggee_type` in the same state
  answers `DEBUG_CLASS_UNINITIALIZED` perfectly happily, which is how two queries sitting beside
  each other come to be asked in one place. `examples/held_target_probe.rs` is the record: it
  prints both, plus `has_target` and the process id, for a fresh engine, a launched process, that
  process once it has exited, and a dump either side of its load wait — which is where the other
  two things worth knowing came from too. **A dump's class and qualifier are known before the
  load wait and its file name is not**, so a reading taken between `open_dump` and the
  `WaitForEvent` that loads it is not comparable with a later one. And a **kernel** target has no
  process id to read at all (`E_NOTIMPL`).

- **`DebugEngine::session_processes` is public**, having been the crate's own answer to which
  user-mode processes a session holds since the teardown needed it. It is the stable half of a
  pair whose other half reads like the same question and is not:
  `current_process_system_id` is the *selection*, which DbgEng moves by itself at a child-process
  event and which `|Ns` moves by hand — neither of which changes what the session is debugging.
  Exposed for a caller fingerprinting its target, where using the selection retires a perfectly
  good session the first time the debugger points somewhere else.

- **`DebugEngine::virtual_region`** — `IDebugDataSpaces2::QueryVirtual` as a typed answer
  (`VirtualRegion`, `VirtualState`), which is what the memory manager says about a run of pages
  rather than what the debugger can read there. `VirtualState::Unknown(u32)` keeps a state this
  crate does not name instead of folding it into reserved or committed, because either guess is
  what would make the primitive lie. User-mode only; a kernel session has no answer to give and
  says so as an error.

  **Its output buffer must be 16-byte aligned**, which `MEMORY_BASIC_INFORMATION64` is not: the
  engine's live-target path copies the 48-byte answer out with three `movaps` stores and takes
  an access violation *inside dbgeng* otherwise. The dump path copies field by field, so the
  same call against a full dump answered 22 queries from an 8-aligned buffer without complaint —
  testing this on a dump proves nothing about it. Measured on 26200, 2026-09-24
  (`dbgeng!Ordinal367+0x14f96`, `movaps xmmword ptr [rbx],xmm0`).

- **`examples/user_heap_smoke.rs` creates its Segment Heap through `RtlCreateHeap`**, because
  `HeapCreate` will not pass the flag on. `HEAP_CREATE_SEGMENT_HEAP` is documented as a
  `HeapCreate` option and is not one: `KERNELBASE!HeapCreate` opens with `and ecx,40005h` —
  `HEAP_CREATE_ENABLE_EXECUTE | HEAP_GENERATE_EXCEPTIONS | HEAP_NO_SERIALIZE` — so 0x100 is
  dropped before `RtlCreateHeap` is reached. Measured on x64 26200 (2026-09-24): growable, with
  an initial size and with a fixed maximum all came back a classic NT heap through `HeapCreate`,
  and the direct call returned a Segment Heap **in the same process moments later**, so the
  wrapper is the whole of the difference. Until now this example built an NT heap on such a host
  and failed two hundred lines later saying the created heap was not among the roots — which
  reads as a defect in root enumeration and is not one, and which is why an earlier note here
  blamed ARM64 and the debug heap. It now reads the heap's signature back at creation and says
  so there instead. The separate per-process switch, which governs the heaps an image gets
  *without* asking, is `ntdll!RtlpHpHeapFeatures` bit 0: 1 in `sihost`, whose four heaps are
  Segment, and 0 in `cmd.exe`.

- **`examples/heap_coverage.rs`** — what, if anything, holds a user heap walk short of
  `Complete`: every gap it filed, put back to the memory manager, with an allocated chunk and a
  free one as controls. An address as a second argument answers that one question, which is how
  the dump direction is checked.

- **`pe`: a loaded image's PE structures, read through a memory reader** (#150). `read_image`
  takes a base and a reader and returns an `Image` -- its bitness, machine, sections and the
  directories later reads start from; `read_imports` names its imports and `imports_by_slot` keys
  them by the address-table slot a call site reads through, which is what names a call in a driver
  with no symbols. Moved from `windbg-mcp` once four of its tools had exercised it, the parser
  unchanged. It asks for a range at a time rather than a contiguous slice, because a loaded image
  in a dump has holes -- the reason `goblin`, `object` and `pelite` were not the answer. Declared
  spans are checked against the image and refused rather than clipped, and a machine and a magic
  that disagree refuse the image.

- **`DebugEngine::read_memory_bounded(address, size, budget_ms)`** and **`MemoryRead`** (#95): a
  read with a deadline, taken a page at a time with the deadline checked before each chunk.
  `ReadVirtual` is not documented to poll for `SetInterrupt`, so the watchdog the other bounded
  operations use would be a bound that only looks taken. The residual is one chunk; a short chunk
  is reported as the `ShortRead` one call of the whole range would have given; and a host's
  `interrupt` reaches a read between chunks. `read_memory` stays unbounded. Every allocation on
  both read paths is fallible, a failed one answering `DbgEngError::OutOfMemory` with how far the
  read had got rather than aborting the host. Release build, 1 MiB of `ntdll` from a local
  process: 486-581µs as one `ReadVirtual` against 735-768µs as 256.

- **`Instruction::reads`**: the registers an instruction reads, beside `writes`. `operands` names
  only the explicit reads, so `mul ecx` reading `eax`, `cmpxchg` reading `rax`, the string
  instructions reading `rsi`/`rdi`/`rcx` and `cmp dword ptr [rcx+8],5` reading `rcx` were invisible
  to a consumer asking whether an instruction looks at the value it is tracking. Empty means not
  decoded rather than "reads nothing". windbg-mcp `FOLLOWUPS.md` item 75.

- **ARM64 control flow and function extents are decoded** (windbg-mcp#297). `Instruction::flow`
  and `function_extent` answered "not this architecture" on ARM64. A64 is fixed-width, so every
  transfer of control is one of six encoding classes and a word outside them falls through; `b.al`
  and `b.nv` are unconditional. The unwind record is read as ARM64's two words, where x64's
  three-RVA reading produced a plausible region containing the address asked about.
  `InstructionSet::flow_is_read` is new, separate from `operands_are_read`.

- **`DebugEngine::attach_kernel_announcement_begin(connection_string)`**, an experimental attach
  for a known-running KDNET lab target: one break, requested on the engine's English
  `Connected to target` output, with no `INITIAL_BREAK` and no extra `g`. That text is not a
  Microsoft readiness contract, so missing or changed output, an interrupt and a timeout fail with
  `DbgEngError::ExperimentalKernelAttach` rather than falling back to a break, and an unconnected
  transport may still block without bound -- run it in a worker process that can be killed.

- **`DebugEngine::kernel_connection_options`**: a live kernel session's connection options, in
  DbgEng's own canonical `KdSrv:` form. Two live kernels are otherwise indistinguishable by
  everything this crate exposes -- same class and qualifier, no dump files, no process set -- so
  this is what a caller fingerprinting its target compares. **It can carry a secret**: a KDNET form
  includes the target's `key=`, so keep a hash of it rather than the string.

### Changed

- **A walk that reached the end of the pool is cached even where some of it would not read.**
  Only a `complete` snapshot was kept, and on a live kernel no walk is complete — paged pool the
  memory manager has trimmed does not read over KD, and 3,442 extents on one 29671 guest were in
  that state — so every query walked the whole pool again: four questions on `ctf-vm` were four
  walks of ~80 s each, and windbg-mcp's live pool tier, twenty-odd questions against one halted
  target, took 2,665 s. A trimmed page reads no better on the next walk, and the target has not
  moved between questions, so the snapshot is kept. What is still never cached is a walk **cut
  short** — by its budget or by a match threshold — because what it did not reach is unwalked
  rather than unreadable; the index carries `complete`, `budget_expired` and
  `stopped_after_matches` with it, and `refresh` walks again as before. Same sequence afterwards:
  the second `pool_chunk` 79.8 s → 0 s, `pool_find_tag` 76.8 s → 0.1 s, and that tier
  2,665 s → 1,032 s.

- **A heap walk no longer calls reserved address space a hole in its own coverage.** A user-mode
  walk came back `coverage: Partial` on every healthy live process, because the tails of
  subsegments and page ranges — reserved and never committed — read the same way a paged-out
  page does, and *would not read* was the only thing the walk could observe. `PoolState` gains
  `Uncommitted` (and `HeapState` with it), and `PoolState::is_coverage_gap` is now the single
  definition of which gap costs a walk its `complete`.

  What separates them is `virtual_region`, not the allocator's records: `CommittedPageCount`,
  `CommitBitmap` and the LFH commit state are three structures that move between builds and say
  what one allocator believes, while the memory manager answers about the target in one call.
  Only a positive `MEM_RESERVE`/`MEM_FREE` excuses a gap — a failed query, a run that cannot
  advance, an unnamed state and a source that cannot be asked are each conservative, so the
  excuse is never granted by an absence of evidence. The kernel pool walk is not asked at all, so
  it **classifies** exactly as before — not quite the same as *unchanged*, which this entry said
  until a live kernel was walked on the new pin: the `walk_vs` site below is silent on a kernel
  too, and now names what it drops. On `ctf-vm` (live 26100 over KDNET, 2026-09-24, 633,665
  chunks walked, `coverage: partial`, 42.3s) that is the largest diagnostic shape on the target,
  2,768 occurrences, beside 2,617 of the unchanged `region # is only committed through #`. Those
  chunks were dropped before this change as well and already cleared `complete`; nothing in the
  answer said which, or how many. So coverage is unchanged on a kernel and
  `PoolDiagnostics::emitted` rises. Which also fixes what `Unreadable` *means*: it is now
  everything that could not be established as empty, including every case nothing could be asked
  about, so it is the conservative bucket rather than a claim that the target has the memory.

  A free chunk whose middle the allocator decommitted is the same question reached another way:
  it runs past the committed extent it starts in, and `walk_vs` emitted no span for it and
  cleared `complete` **without a diagnostic**. A span is geometry and state, both of which are
  known there, so where the tail holds nothing the chunk is now reported; where it is memory the
  process has, it is still refused, and the walk now names the chunk it dropped.

  Measured on a live 26200 process (`sihost`, four Segment Heaps, 20,426 chunks, 2026-09-24):
  every one of its 33 gaps `MEM_RESERVE`, both controls `MEM_COMMIT`, and an answer that had
  been `Partial` is `Complete`. Checked from the other end on a thin dump of the same process:
  an address whose page the dump does not carry still answers `Committed`, the read still fails,
  and the walk still counts it. `HeapWalkReport` gains `uncommitted_gaps` so that what a
  `Complete` answer forgave is still on the report. windbg-mcp `FOLLOWUPS.md` item 98.

- **The kernel pool walker takes ARM64 targets.** `pool::query` accepted
  `IMAGE_FILE_MACHINE_AMD64` and nothing else, so every `pool_*` query against an ARM64 kernel
  came back `pool walking supports x64 targets only (machine 0xaa64)`. It now admits ARM64
  alongside x64, from the same `IMAGE_FILE_MACHINE_*` constants `heap::validate_target` uses.
  The gate was waiting on a walk rather than an argument, and the walk was run: on a live ARM64
  kernel (26100, AArch64), 18 LFH subsegments whose `BlockBitmap` reproduced `FreeCount` exactly
  under `LfhBitmap::ContiguousBits`, the big-page hash landing on each in-use entry's own slot
  where the truncating one landed on none, and — with the gate lifted — a walk agreeing with
  `!pool` block for block on two subsegments (59/59 and 7/7 allocated blocks), where the
  pre-fix decoder reported 25 of those live blocks as free. Still a list of two machines rather
  than "anything 64-bit": x86 and ARM32 have neither the pointer width the decoders assume nor a
  kernel segment heap, and admitting them would produce confident wrong answers instead of an
  error. windbg-mcp `FOLLOWUPS.md` item 96.

### Fixed

- **A VS user block that would begin in the last sixteen bytes of a page is read where the
  allocator put it: at the next page start.** Under
  `_HEAP_VS_CONTEXT.Config.Flags.PageAlignLargeAllocs`, `nt!RtlpHpVsContextAllocate` hands out
  `chunk + 0x20` rather than `chunk + 0x10` when the former is page-aligned, and
  `RtlpHpVsContextFree` takes a page-aligned user block's chunk from `block - 0x20` -- the
  kernel's own way of keeping a `_POOL_HEADER` out of a page's last unit, since `ExFreeHeapPool`
  tells a chunk from a page allocation by `P & 0xfff` (x64 26100.33438, read at
  `RtlpHpVsContextAllocate+0xf6..+0x12a`, `RtlpHpVsContextAllocateFinalize+0x3d` and
  `RtlpHpVsContextFree+0xf2`). The walk placed every such header one unit early, decoding two
  stale free-tree pointers as a `_POOL_HEADER`: the tag came out as the top half of a kernel
  address, the span a unit early and a unit long, and the real header at the page start went
  unclaimed. Measured on a live 26100.33438 kernel 2026-10-14: 1,275 paged chunks under the "tag"
  `0x84e7ffff`, every one with its candidate header at page offset 0xff0;
  `0xffffe78459203fe0` is a 0x80-byte chunk holding `ffffe78459207e18 ffffe78459213bc8` at
  `+0x10` and the `MmDT` header `!pool` names at `0xffffe78459204000`. The flag is read from each
  heap's context at discovery -- `_HEAP_VS_CONTEXT.Config` is a new optional field, so **every
  layout fingerprint moves** -- carried on the region, and applied only where set: a target
  without it is walked as before, and a PDB without the field says so in a diagnostic.
  `adjust_page_end_header`, which moved a candidate *back* to the end of the previous page and
  could never fire on a sixteen-byte-aligned one, is gone. windbg-mcp `FOLLOWUPS.md` item 101.
  `test_a_vs_user_block_that_would_end_a_page_begins_the_next_one` pins the placement,
  `test_without_the_flag_a_page_end_user_block_is_read_at_its_chunk_header` the gate,
  `test_a_vs_user_block_on_a_page_boundary_is_left_where_it_is` the boundary and
  `test_discovery_reads_whether_a_vs_user_block_skips_a_page_end_from_the_context` the read.
  Beside it, `tree_nodes` now polls the budget before its first read: the one extra read per
  heap moved the sampled deadlines onto a five-read stretch between polls that
  `test_the_walk_stops_reading_promptly_once_its_budget_is_gone` had never landed on.

- **A break left pending no longer empties the next reload.** `Execute` discards a pending
  `SetInterrupt` as a command begins; `Reload` does not. A host interrupt that landed between
  operations (`BreakRequest::NothingRunning`) made the next `reload_symbols("")` on a live kernel
  return success in 4ms with the engine holding **no modules** and the break still pending, and on
  a launched process fail with `0xD000013A`. Both reload methods now drain it first -- unless an
  operation other than the reload's own is running, since a reload called from a breakpoint
  callback runs inside the wait's operation and a pending break there may be a host's request to
  stop it. `test_a_pending_break_does_not_empty_the_next_reload` fails with the drains backed out,
  and `test_a_reload_inside_an_operation_leaves_its_break_pending` with either call site draining
  unconditionally.

- **An import name beside a hole that starts mid-page is read.** `read_c_string` never spanned a
  page, because a minidump's holes are page-granular -- but an **image-file** target (a `.sys`
  opened as a dump) maps each section only as far as its `SizeOfRawData`, so its readable bytes can
  stop mid-page. HEVD's ARM64 build keeps its import directory and names in `INIT` (VA `0x8D000`,
  raw size `0x800`): `0x8D7FF` reads and `0x8D800` does not, the library name `ntoskrnl.exe` sits at
  `0x8D68C`, and the 512-byte read of it crossed `0x8D800`, so the whole import table came back
  unreadable with every byte of it there. Reads now stop at 512-byte boundaries,
  `FileAlignment`'s smallest legal value for page-aligned sections, so no raw-data end can fall
  inside one. `test_a_name_beside_a_hole_inside_a_page_is_still_read` fails with the granule put
  back to a page (measured on ARM64).

- **A64's cache, TLB and address-translation operations are named from their whole encoding,
  not from their cell**, and the cell rule was wrong in both directions. Every word in a `dc`,
  `ic`, `at` or `tlbi` cell was given that name, though only some `op1`/`op2` pairs there are
  allocated — 85 of the 1,024 combinations in `CRn` 8 are `tlbi` operations — and the rest are
  generic `sys` words; a `sysl` in one was named the same way. And three cells were missed outright:
  `CRn` 9 (`tlbi`'s `nXS` forms), `CRm` 9 (`at s1e1rp`, `s1e1wp`, `s1e1a`) and `CRm` 15
  (`dc civaps`, `cigdvaps`, `civaoc`, `cigdvaoc`). Each cell now carries a mask of its allocated
  pairs, generated by disassembling every word of `CRn` 7, 8 and 9 with LLVM, so an operation that
  disassembler does not know reads as `sys` — a lost name rather than a wrong one.

  It matters more now because `Instruction::privilege` comes out of the same decision. The first
  draft of that change named the three missed cells wholesale, and one `sysl` word in the 26100
  ARM64 kernel came out `tlbi` and cache maintenance (review on #192).

- **A big-pool allocation on a page the memory manager had trimmed lost its name, in both of the
  places the table names one.** The table was consulted correctly — on two live guests the hash
  landed on each failing entry's own slot, zero probes — and the walk then never asked it for a
  span it could not *read*. A segment page range the table names was read anyway, only to run a
  decoder the name makes unnecessary, and where the read failed the range was filed as an untagged
  unreadable gap. A VS chunk the table names whose tail runs into a trimmed page was dropped by the
  "runs past the committed extent" rule before the containment match could run, and its pages
  filed the same way; `pool_chunk` at the allocation's own address then answered `....`, 4096
  bytes, `unreadable`. Measured on 2026-10-07: `Gcac` at `0xffffa4b05e1b5000` (8 KB, segment) on a
  29671 lab kernel and `CIcr` at `0xffffa9099c86f000` (0x12a0 bytes, 0x20 into a 0x12c0-byte VS
  chunk with a resident header) on `ctf-vm` 26100.33438, `!pool` naming both from the table.
  Neither is a regression: the code was identical at every pin since the entries below, and the
  same test had passed on the same guests' previous boots — which allocation the oracle samples,
  and whether its page is resident, is a property of the boot. A named segment range is now
  answered from the table without reading its pages at all, and a VS chunk is matched against the
  table *before* the extent check, with the extent loop told not to file the hole a matched chunk
  covers as a gap. The first of those is also most of a walk's wasted reads: the live entries
  summed to 111 MB on the 29671 guest and 89 MB on `ctf-vm`, every byte read and then discarded.
  Same guest, same sequence, the previous build against this one on `ctf-vm`: a full walk
  82.8 s → 61.9 s, and `pool_find_tag CIcr` 40 matches → 104, the other 64 having been dropped
  over trimmed tails; on the 29671 guest a full walk 136 s → 79.7 s. One of the five entries
  windbg-mcp's tier sampled there still has no span, and it is not this case: `smCB` at
  `0xffffe67bde598000`, a nonpaged 0x1000-byte entry, lies in no region the walk discovers at
  all — `pool_chunk` answers `covered: false` and `pool_find_tag smCB` finds nothing — which is a
  discovery gap, reported by that tier rather than asserted. windbg-mcp `FOLLOWUPS.md` item 99.

- **And a big-pool allocation served out of a VS subsegment kept no tag either.** The fix above
  asked the page range descriptor which allocations have no `_POOL_HEADER`, and that is only where
  *most* of them are: `nt` also puts them inside VS subsegments, where the descriptor says `0x0f`
  and the chunk chain runs straight through them. The size answers it instead, and structurally
  rather than by measurement — `_POOL_HEADER.BlockSize` is **eight bits** of sixteen-byte units
  (`dt nt!_POOL_HEADER`, x64 26100.33438), so 4080 bytes of chunk is the most it can describe and
  an allocation needing a page has nowhere to record its own length. That is why every one of the
  7,639 live entries on a 26100 guest recorded `NumberOfBytes` of `0x1000` or more, and none
  fewer. A chunk past that limit is now matched against the entries discovery found inside its
  region, **by containment and length** rather than by arithmetic on the chunk header: the table's
  `Va` is where the allocation starts and `NumberOfBytes` is how long it is, and taking both as
  given assumes nothing about what sits between the chunk header and the data — which on that
  guest is 0x10 that is measured and not yet explained. Measured there: `!pool` calls
  `0xffffac09da29f000` an `MiRr` allocation of `0xe1c0` bytes and the walk called it 57,792
  untagged bytes — the same length, with only the name lost. windbg-mcp `FOLLOWUPS.md` item 99.

- **Every big-pool allocation was reported with a tag read out of the caller's own data.**
  `ExAllocatePoolWithTag` sends anything that will not fit inside a page to `ExpAllocateBigPool`,
  which takes whole pages from the segment allocator and records the tag and length in
  `nt!PoolBigPageTable` **instead of** in a `_POOL_HEADER`. Nothing in the page range descriptor
  distinguishes one from a plain page-range allocation — both are `RangeFlags` `0x03` — so the
  walker decoded the page as though a header were there, which reads the caller's first sixteen
  bytes as `PreviousSize`/`BlockSize`/`PoolType`/`PoolTag` and then reports the block as starting
  0x10 in and 0x10 short. Measured on a live 26100.33438 kernel (2026-09-24): `!pool` calls
  `ffffac09dd0f5000` a 0x1000-byte `CM25` allocation and the walk called it a 4080-byte block at
  `+0x10` tagged `..N.` — those being the bytes of the registry hive bin's own `hbin` header. An
  allocated kernel page range is now looked up in that table, and where it is named it carries the
  table's tag, no header, and the length the caller asked for rather than the pages it was given.
  `windbg-mcp` `FOLLOWUPS.md` item 99.

- **A freed big-page entry answered for its address, with the tag it used to have.** The lookup
  matched on `Va & !1`, and bit 0 of `Va` is `POOL_BIG_TABLE_ENTRY_FREE`: `ExpRemoveTagForBigPages`
  frees an entry with `lock inc qword ptr [rax]`, so a released allocation stays in the table under
  `address | 1` with its old tag and size until something claims the slot. `nt`'s own comparison is
  `cmp rcx,rdi` — exact — and so is this one now. On the same kernel `ffffac09e50f5001` sat one slot
  from a live entry and its page would not even read.

- **The probe's stop condition never fired, so a miss read the whole table.** It stopped at
  `Va == 0`, and a slot the allocator has never used reads `1`, not `0`. Every lookup that found
  nothing therefore scanned all 32,768 entries. It stops at a never-used slot now, which is sound
  because `ExpAddTagForBigPages` claims the first bit-0-set slot from the hash and so cannot have
  walked past one. The table is also read a batch at a time and **kept**, which is what makes
  asking this question of every page range affordable rather than of large allocations only.

- **`!poolmap` says what the walk missed.** It drew the map and listed every diagnostic but
  rendered none of the walk's `complete`, unplaced bytes, stalls or refused chunks, so an operator
  got a picture with no statement of how much of the pool was not in it -- on a live 29671 kernel,
  `coverage: partial` with 15,626,240 unplaced bytes under a map that said nothing. A
  `--- pool walk ---` summary now follows the map, on the empty-map path too, where "no allocation
  matches" and "the walk never reached the pool" otherwise read the same. It is the walk's report
  rather than the filtered index's, so `--paged` does not shrink the count of chunks walked.
  windbg-mcp `FOLLOWUPS.md` item 100.

- **The heap walker read an LFH subsegment's busy blocks from the wrong bits, on every current
  build.** `_HEAP_LFH_SUBSEGMENT.BlockBitmap` was read as two adjacent bits per block, busy the
  lower. `ntdll` packs it otherwise: each 64-bit word covers 32 blocks, busy bits in its low half
  and each block's unused-bytes bit 32 above — read out of `ntdll!RtlpHpLfhSubsegmentWalk`, the
  routine `HeapWalk` reaches, identically on x64 26100.8972 and ARM64 26100.1. Measured on a live
  ARM64 subsegment of 62 blocks, the old reading reported fifteen busy, eight of which were free,
  and missed nine that were busy. The heap queries now agree with `HeapWalk` block for block on
  the heap `user_heap_smoke` creates, which now asserts exactly that, live and over its dump. The
  arrangement is chosen by the allocator the schema was resolved from (`is_user`), never by a
  build. **The kernel pool walker keeps its reading unchanged**, and it is not `nt`'s either:
  `nt!RtlpHpLfhBlockBitmapInitialize` (x64 26100.32995) and
  `nt!RtlpHpLfhBlockBitmapAllocateNonAtomic` (ARM64 26100) use one bit per block, 64 to a word.
  That wants a live pool to check against and is `windbg-mcp` `FOLLOWUPS.md` item 96.

- **A segment list whose head is 8-aligned was walked one entry too far.** Its links were masked
  to 16 bytes, and on current `ntdll` the head is not 16-aligned — `SegContexts` at +0x140 of the
  heap and `SegmentListHead` at +0x48 of a context, on x64 and ARM64 alike — so the last segment's
  link back never matched the head, and the walk read the heap's own fields as a segment header.
  Nothing was lost, but every heap said `cannot read segment header` twice and called its walk
  partial. The links are compared exactly now.

- **Every free page range in a segment's free-page tree was dropped.** The descriptor check
  asked a range's first descriptor for `TreeSignature` before asking whether it was a node of
  the free-page tree, and the two share bytes: a free range in the tree holds its node's links
  there. Tree membership is now that range's evidence, and it is reported as free.

- **The VS free tree was walked from the wrong address.** A free VS chunk's `Node` is at +0x8 of
  its 16-aligned header, and tree links and roots were masked to 16 bytes, which moved every node
  onto the chunk's encoded `Sizes` word: the walk followed that word as a left link, took the real
  left link for the right one, and reported the result as unreadable nodes (`0x84a23d…` on ARM64
  26100.1). It changed no chunk's state — a VS header carries its own — but it was the rest of
  why a user-mode walk was partial.

  These last three are in code the kernel pool walker shares, so it has them too. They were
  measured in user mode only; checking them against a live pool is `windbg-mcp` `FOLLOWUPS.md`
  item 96, with the LFH bitmap above.

- **The heap tools saw one heap in processes that have several.** `heap::list` and everything
  built on it took its roots from the PEB's `ProcessHeaps`, and on current Windows that array names
  the process heap and nothing else: `RtlpProcessHeapsInsert` writes it for the first heap only and
  links every heap — the first included — onto a list in `ntdll`'s data, which is what
  `GetProcessHeaps` walks. So a `HeapCreate` return value was never a root, and the answer still
  called itself complete. Measured 2026-09-22 on a live ARM64 26100.1 process holding three heaps
  against a PEB naming one, the missing two including the heap it had just created; and on two
  x64 26200 dumps whose list head links two entries against a PEB naming one. Roots now come from
  that list, in its order, reached through the process heap's typed `UserContext` — which names
  its entry on both builds, where only 26200's PDB names the list head. Every entry is checked
  rather than trusted: its heap has to name it back, its `Blink` has to be the entry before it,
  and exactly one entry, the head, lies inside `ntdll`. An entry that fails any of those ends the
  walk as **unseen** rather than absent — the roots before it stay listed, the walk reports
  `Partial`, and a diagnostic names the entry. A build whose process heap names no entry keeps no
  list and gets the PEB answer exactly as before. `HeapRoot::index` is now the position in that
  order, and the user-mode layout fingerprint moves on every build that carries `UserContext`,
  because the schema reads it; the kernel schema does not, so no pool fingerprint moves.
  `windbg-mcp` `FOLLOWUPS.md` item 79.

- **A KD attach left break-ins owing, and the teardown paid one with the target's only continue.**
  `DEBUG_ENGOPT_INITIAL_BREAK` leaves a pending host break-in behind it, and
  `absorb_initial_break_artifact` consumes exactly *one* with a single `g` — right for NT, whose
  `nt!DbgBreakPointWithStatus` artifact its comment names, and one short on a Microsoft hypervisor.
  The leftover is invisible in the attach's result, and `end_session` spends it: `qd` sends one
  `DbgKdContinue`, the pending break-in takes it, and the target stops again with **no debugger
  attached** — a frozen guest, reported as a clean release. `quit_and_detach_target` now spends
  them first, resuming with a 500 ms bound until **two consecutive** resumes run free. One free run
  is not evidence: a break-in merely slow to arrive reads exactly the same, and a version draining
  once survived 2 detach cycles of 3 where draining to two survived 5 of 5 — each confirmed over
  WinRM as the same boot with advancing uptime. NT is unaffected, checked across two pool-walk
  cycles on a four-processor guest.

  **Between `clear_all_breakpoints` and `qd`, and that placement is the whole of it.** The clear has
  already succeeded, so the target holds no breakpoint a drain resume could stop at — and a resume
  cannot otherwise tell a caller's breakpoint from the break-in it is hunting, both coming back with
  no `cut_short`. The quit has not yet spent the target's one continue. It runs on **every**
  live-kernel quit, whatever the attach shape — it was gated on the KD-connection attach, and the
  entry below is what that gate cost. Two
  readings pinned the cause, one of them ruling out the obvious answer — stepping past the
  hypervisor's own `int 3` before detaching does *not* help, so it is not where the instruction
  pointer sits; and the first `g` after a completed attach returns at once with the CTRL+BREAK
  banner while every later one runs to its bound.

- **A breakpoint in code every processor runs owes a stop to each of the others, and the drain is
  now sized for them.** Measured on a four-processor Microsoft hypervisor, 2026-09-21: a
  `run_to_address` that returned `verdict: hit` with an empty breakpoint inventory after it left
  **three** further stops behind it — processors 2, 3 and 1 where the hit was on 0, each a
  first-chance `0x80000003` at the breakpoint's own address and on that processor's own stack,
  delivered one per resume in 1–3 ms, after which the target ran free. The others reach the patched
  instruction before the debugger removes it, and `qd`'s single continue is then taken by the first
  of them: the guest stops with no debugger attached. It races the quit, so it is intermittent —
  **2 of 4** hit-then-detach runs froze, both leaving the guest black with a pending stop at that
  address, each released by one attach and one `end_session` whose drain ran.

  Two changes follow, and the first is why the entry above no longer names an attach shape. These
  stops owe nothing to the attach, so the gate left the drain unrun on exactly the
  `experimental_break_on_connect` path a running hypervisor has to be attached with. And the
  attempt cap is now `GetNumberProcessors` plus the two free runs rather than a fixed five, which
  four processors met exactly by coincidence — three stops plus two free runs — so one more
  processor would have exhausted it with a stop still owing. `DRAIN_BUDGET` bounds the wall clock
  at four seconds where the attempt count no longer does. **Ten of ten** four-processor
  hit-then-detach cycles then came back clean, each confirmed over WinRM as the same boot with
  uptime advancing afterwards. A **local** kernel is the one target the old gate excluded by
  construction: it owes nothing, and its first drain resume errors out because local kernel
  debugging refuses execution control, which ends the loop there.

- **A slot that names its VS context by displacement is decoded, so current Windows builds walk
  again.** `_HEAP_VS_AFFINITY_SLOT::VsContext` — a back-pointer to the owning `_HEAP_VS_CONTEXT` —
  is spelled `VsContextOffset` on 26100.33438 and 26200, and holds `slot - context` rather than an
  address. Because the old name sat in a *required* field list, one rename took the whole type down
  and the family's other fields with it, so every pool and heap query on such a build refused with
  `unsupported allocator layout … no recognized VS structural family is complete`. Measured rather
  than inferred: `RtlpHpVsSlotCreate` stores `sub rax,rdi` against the context the slot was created
  for, and a live slot's `0xa80` was both `slot - context` and `SlotRef << 6`. The slot-map
  arithmetic itself is unchanged — `RtlpHpVsContextGetSlotInfo` still reads `ctx + (word[ctx] << 6)`
  over `byte[ctx+2] + 1` four-byte entries.
- Both older shapes are **unchanged**, which is the point rather than a side effect: selection is by
  the fields a target's own PDB carries and never by a build number, and their resolved schemas —
  fingerprints included — are pinned against values recorded before this change.

### Added

- **The heap queries walk ARM64 processes.** `heap::*` refused any processor but AMD64. It now
  accepts ARM64 as well — native processes, and x64 processes emulated on ARM64, whose heaps
  are the ARM64X `ntdll`'s like any other's. Nothing else in the walker was specific to either
  architecture: the four fixes above are x64's as much as ARM64's, and were found by running
  `user_heap_smoke` on ARM64 for the first time. What it does refuse, on both, is a **WoW64**
  process (`HeapQueryError::Wow64Process`): the PEB and `ntdll` a 64-bit engine sees there are
  the emulation layer's, so a walk would list those heaps and call itself complete while the
  program's own, 32-bit, heaps were absent. No processor type says so — the effective one is
  still 64-bit at a WoW64 launch's first break — so it is read from `_TEB.WowTebOffset`, which
  was `+0x2000` at both of that launch's breaks and zero in native and emulated-x64 processes.
  A TEB that cannot be read is `HeapQueryError::InvalidTeb`, not a pass.

- **`DebugEngine::current_thread_teb`**, beside `current_process_peb`.

- **ARM64 operands, registers, effect, condition and privilege are decoded**, so
  `InstructionSet::operands_are_read` answers `true` there and every `Instruction` field is a real
  answer on that architecture rather than an empty one. A64's flow landed on its own (#148); this
  is the half two static analyses in `windbg-mcp` were waiting on (#170) — an IOCTL map, which
  needs the immediate a compare holds, and a hazard scan, which needs `privileged`.

  What is decoded is the general-purpose architecture in full, with the aliases a compiler emits
  resolved out of their base forms (`cmp` from `subs`, `mov` from `orr`, `lsr` from `ubfm`) because
  an alias changes the operand *list* and not only the spelling. The vector spaces are named rather
  than shaped — as a single `Operand::Undecoded` carrying the space's name, which is a caller's
  tell that nothing was read — with one exception: in **Advanced SIMD and scalar floating-point**, every
  encoding that reaches a general-purpose register or the flags is decoded, and the list is short
  enough to give in full (the floating-point conversions, `fmov` between the register files
  including its upper-lane form, `umov`/`smov`/`ins`/`dup`, `fcmp`/`fccmp` and `fjcvtzs`, and a
  vector load's base-register writeback). The Reserved space's one allocated member, `udf #imm16`,
  is decoded too — the engine renders that whole space `???`, so this is one of the few places the
  decode says more than the rendering it was checked against.

  A **vector-structure access carries the width it transfers** on its memory operand, that being
  the one thing its register list does not give a caller: `ld1 {v0.16b},[x1]` reports 16 bytes,
  `ld2 {v0.b,v1.b}[0],[x1]` reports 2, and a replicating `ld1r {v3.4s},[x4]` reports **4** — one
  element read and splatted, rather than the sixteen bytes it writes. More generally, **every
  access whose width is encoded reports it**, and the four positions that report none each have a
  reason the module documents: `adr`/`adrp` perform no access, a prefetch has no architectural
  width, and the MOPS copies and the whole-granule tag forms move an amount only a register holds
  at run time.

  **SVE and SME have no such exception**, so an `incb x0` there comes back claiming nothing about
  `x0`. That is deliberate rather than an oversight: a partial decode of that space would remove
  the marker from the encodings it shaped while leaving the gather loads and `ctermeq`'s flags
  unread, handing back an access list that looks complete and is not. Closing it means enumerating
  that space's general-purpose surface and shaping all of it at once.
- **Four A64 encodings that the architecture does not allocate no longer decode as though it did**,
  and one that it does allocate is no longer refused. A prefetch exists only in the unscaled form,
  so the post-indexed and pre-indexed rows of its slot were instructions this decoder invented and
  the unprivileged row came back as `sttr` — a real mnemonic wearing a prefetch's semantics, with
  no transfer width, `Effect::Other` for a store and its `Rt` rendered as a prefetch operation. The
  unprivileged mode has no vector form either, where `ldtr b0,[x0]` and `sttr b0,[x0]` were being
  produced. In the other direction, every `ldg` with a nonzero displacement was refused as
  unallocated, and the one that survived reported no access width, because the flag separating the
  whole-granule tag forms from their neighbours was reading the wrong field.

  All five were found by auditing which memory operands report no access width, rather than by
  review or by either existing harness — a corpus contains none of these encodings, and the family
  enumeration compares *definitions* and so cannot see a field nobody constrained. Each was then
  settled against the generated instruction table rather than a recalled one.
- **A register-branch form now refuses the words that leave its fixed fields set**, `0xd61f0001`
  having decoded as `br x0` and an authenticated return with a stray `Rn` having reported reads of
  the link register and the stack pointer its word does not name. The rule was already written in
  the comment above the code that did not apply it. Settled by differencing that whole encoding
  space against a generated instruction table, which now agrees with it exactly but for `texit`,
  an extension this declines. The same check refuses an unauthenticated `braa`/`blraa`, which has
  no encoding: `0xd71f0000` was a `br x0` that also read `x0` as a modifier.

- **Every A64 system operation is reported privileged**, where `sys`, `dc`, `ic` and `tlbi`
  previously took their answer from `op1` alone and so called the `op1`-three encodings EL0's.
  They are not: there is no unconditionally-EL0 member of that family. `SCTLR_EL1.UCI` gates
  `dc cvau`, `dc civac`, `dc cvac`, `dc cvap`, `dc cvadp` and `ic ivau`; `SCTLR_EL1.DZE` gates
  `dc zva` and the MTE zeroing forms; `GCSCRE0_EL1` gates the guarded-stack pushes. A hazard scan
  was seeing none of the 74 by-address cache maintenance instructions in this bench's kernel.

  The same argument had already carved `DAIF` out of the system *register* space one round
  earlier. That carve-out stays, registers under `op1` three being mostly EL0's own; for
  operations the exception is the whole set, so that arm now reads no `op1` at all rather than
  growing a second list a round at a time.

- **`examples/undecoded_families.rs` reports both directions of the differential.** Beside the
  families this decoder leaves unread, it now lists the words it *shapes* that the generated table
  refuses — a field nobody constrained rather than a family nobody decoded, which the first listing
  structurally cannot find because it compares definitions. It is the same sweep and costs nothing
  extra. Five of this release's decoder fixes came out of running it, none of which any corpus
  contains an instance of.

- **Three more A64 encodings read the field they were actually given.** `isb`'s `CRm` is an option
  and shared `dsb`'s shareability table, so `isb #7` was spelled `isb nsh` — a domain `isb` has no
  concept of. `dsb #0` and `dsb #4` are `ssbb` and `pssbb`, the speculative-store-bypass barriers,
  which a caller matching mnemonics for a speculation mitigation could not find and which take no
  operand. And `casp` built each pair's second register with `rs | 1`, which is the successor only
  where the field is even — an odd one came back as a pair of a register with itself, where the
  architecture makes it CONSTRAINED UNPREDICTABLE and there is nothing to read.

- **`Operand::Undecoded`**, which is how an instruction says its fields are defaults rather than
  answers. `InstructionSet::operands_are_read` answers for a *set*, and that was enough while every
  decoder here was complete over its own; A64's is the first that is not, and the distinction
  previously lived in a convention about what an `Operand::Other` contained — which a consumer had
  to know the space names to apply. It is the whole operand list wherever it appears.

  **Migration:** an exhaustive `match` on `Operand` gains an arm. A consumer that must not believe
  a stale value treats `Undecoded` as clobbering whatever it is tracking; one that would rather
  lose a finding than invent one stops at it.

  Measured against the engine's own rendering of all 1,233,502 words of a 26100 ARM64 kernel's
  `nt` (`.text` and `PAGE`): 5,019 instructions — 0.42% of the 1,189,047 the engine could render —
  are left unshaped, all of them in those vector spaces; every register a rendering names is in the
  decode's reads or writes but one, whose `Rn` field the engine prints as `sp` where the encoding
  says the zero register; all 42,244 resolved addresses match the engine's; and thirty mnemonics
  differ, each of them the debugger's own spelling rather than the architecture's.

  Two shapes worth knowing about before reading A64 fields with an x64 habit. A **store names its
  source first**, so `operands[0]` is not the destination there — `writes` is the field that
  answers what changed. And a **shift or extension folded into an arithmetic operand** is named as
  an `Operand::Other` and drops the effect to `Effect::Other`, rather than being reported as an
  immediate a consumer would add: `add x8,x9,x10,lsl #3` is not `x9 + 3`. A shifted *immediate* is
  folded into its value instead, `sub w0,w0,#0x222,lsl #12` carrying `0x222000`, since that is a
  number the operand can hold.
- **`decode_instruction`**, which decodes one instruction from bytes a caller already has, with no
  debug session anywhere. `Instruction::bytes` carries the bytes the instruction **occupies**
  rather than the buffer it was handed — an x86 caller cannot know an instruction's length before
  decoding it, so `[0x90, 0xcc]` answers `nop` with `90`, and a walker may step by that length.
  Bytes that decode to nothing report the whole input, there being no extent to report and
  `Flow::Unknown` beside them saying so. Everything else here reaches an instruction through a target;
  this is the same decoding for a caller holding the encoding — bytes out of a file, an image
  mapped by something else, or a word under test. `Instruction::text` is empty, nothing having
  rendered it.
- `examples/undecoded_families.rs`, which decodes every 32-bit word twice — once with this crate's
  A64 decoder and once with tables generated from the architecture — and lists the instruction
  families the second knows and the first does not. Seven review rounds each found one or two of
  those by hand, because a corpus finds only what a target contains and no Windows ARM64 image
  contains memory tagging, `brab`, `subps` or `cpyfp`; this answers the whole question in fifty
  seconds. It is what found MOPS' guarded forms and CSSC's literal minimum and maximum, and what
  turned this crate's coverage claim from a sentence into a table.
- `examples/decode_against_rendering.rs`, which decodes every word of an image's executable
  sections and cross-checks each one against the engine's own rendering of the same bytes. The
  rendering is the only independent reading of those bytes this crate has, and a corpus of a
  million real instructions finds shapes a hand-written fixture never contains: it found seven
  defects in the A64 decoder above, including a no-allocate pair load decoded out of an `opc` that
  form does not allocate, which only a sweep past `.text` reaches. Nothing in it is a pass or a
  fail — every count has a floor that is not a defect, so the run prints *what* each one was.
- `VsSemanticFamily::AffinitySlotsSelfRelative` (`affinity_slot_vs_offset`), reported apart from
  `AffinitySlots` because the two are *checked* differently — an address against the context, a
  displacement against `slot - context` — and reading one as the other rejects every slot silently
  rather than failing. A PDB carrying both spellings is refused as ambiguous rather than resolved by
  precedence.
- An unsupported layout now **names the fields each candidate family wanted and which were
  missing**. The previous message said only that no family was complete, which reads as a symbol
  problem: the first diagnosis of this rename went to `.reload /f nt` and a PDB check before anyone
  compared a field name.

## [0.2.0] - 2026-09-13

This release adds typed instruction analysis, kernel object namespace queries, breakpoint
management and crash-event reads, and fixes live-target arrival and interrupt handling.

### Migration from 0.1

- `DebugEngine` is no longer `Send` or `Sync`. Create and use it on the same thread; send an
  `InterruptHandle` to another thread when it needs to interrupt the engine.
- The public `Breakpoint` wrapper is removed. Use `set_breakpoint`, `remove_breakpoint` and
  `enable_breakpoint` with breakpoint IDs.
- `Instruction` and `BreakpointInfo` gain fields, and public enums gain variants. Update
  struct literals and exhaustive matches when upgrading.

### Added

- **Kernel object namespace queries** through the new `object` module:
  `DebugEngine::object_at`, `objects_in` and `symbolic_link_target` resolve names, list directory
  entries and read symbolic-link targets as values. Layouts come from target symbols and are
  validated before walking. Listings report unreadable and malformed entries separately;
  failed lookups distinguish absence from an incomplete search. `Namespace::halting` lets a
  caller stop the walk and reports that stop explicitly. Names use ASCII case folding, and
  queries require the target's namespace data pages.
- **Instruction semantics** include privilege requirements, effects, conditions, register
  identity and width, and registers written. These accompany the operands and control flow
  decoded from x86/x64 instruction bytes.
- **Disassembly carries its operands as values, decoded from the encoding.** `Instruction` gains
  `mnemonic`, `operands` and `flow` beside the `text` it already had, so a caller asking what an
  instruction *compares against* or *branches to* reads a field instead of re-parsing a rendering
  downstream. They come from decoding `bytes` — the engine's own read of the instruction, so no
  extra round trip — with `iced-x86`, and the rendering stays verbatim in `text` because it is what
  a listing prints.
  **Decoding, rather than reading the rendering, is the whole design and was arrived at the hard
  way.** The first implementation parsed the third column, and a symbol's own punctuation kept
  taking operands apart: a comma inside `std::map<int,int>`, a parenthesis inside `operator()`, a
  bracket inside `operator[]` — three review rounds, three characters, each severing a direct call
  edge that a walk then dropped as indirect. The mnemonic table had the same shape, growing an
  entry a round for `int` by vector, `xbegin`, `xabort` and `hlt`, because a hand-written list of
  what transfers control is never finished. An encoding has neither ambiguity, and a decoder's
  flow control is complete by construction. Symbols leave the picture entirely: a destination is an
  address, and naming it is `symbol_for`'s job.
  `Operand` is `Register`, `Immediate`, `Memory`, `Target` or `Other`. `Flow` carries every
  destination as an `Option`, because a direct transfer encodes a displacement and an indirect one
  encodes a register, and a caller treating `None` as "no edge" stays sound. `Unknown` and
  `Unreadable` are separate, and the line between them is whether there are bytes: a `???`
  rendering has none and stops a walk, while an instruction set this does not decode — or an
  encoding newer than the pinned decoder — has an instruction there and falls through. A walk that
  fell through the first would step through *bytes*, one address at a time, to its own cap; one
  that stopped at the second would discard the rest of a routine over a version skew.
  A memory operand claims a static `address` only where the instruction alone determines one. A
  segment override does not: `gs:[188h]` is the KPCR, its linear address is the segment base plus
  the displacement, and that base is a runtime fact. A RIP-relative operand keeps the displacement
  it **encodes** rather than the decoder's normalised target, which would otherwise report
  `[rip+0xffa]` at `0x1000` as a displacement of `0x2000` — a second copy of `address` where the
  addressing expression should be. A displacement is signed at the width its *address registers*
  are: the decoder keeps a 32-bit effective address in 32 bits, so `[ebp-8]` arrives as
  `0xfffffff8` and a straight widening cast reported the commonest local-variable reference there
  is as 4,294,967,288. An absolute reference with no register stays unsigned, a 32-bit
  `[0xfffff000]` being a high address rather than a negative offset.
  A near branch's destination lands in the address space its instruction came from. Decoding 32-bit
  code computes a 32-bit target, so an instruction whose own address carries a high half — a narrow
  effective machine over a wide address, which `.effmach x86` produces — would otherwise name a
  destination in a different address space from itself, which a module-bounds check rejects and a
  reader follows to the wrong place. The high half is inherited from the instruction rather than
  sign-extended, taking the address form from the caller's own value instead of assuming the
  engine's. 64-bit decoding is left alone deliberately: a `rel32` reaches ±2 GB and so may cross a
  4 GB boundary, where inheriting would drag a correct target back four gigabytes. An **absolute**
  memory address is canonicalised the same way and for the same reason, absolute globals and
  import slots being ordinary in x86 kernel code.
  Inherited rather than sign-extended, and that is measured rather than preferred: against a
  32-bit target, `? 80002000` evaluates to `80002000` and `.formats` prints `Hex: 80002000` —
  eight digits, unextended — so sign-extending would invent `ffffffff80002000` for an address the
  engine calls `80002000`, and on a 32-bit kernel would do so for nearly every address. The cost
  is one straddling case, a low instruction reaching a high absolute, which keeps the low half and
  is pinned by a test naming the measurement.
  A displacement's signed width is the **effective address width**, and neither of the two simpler
  readings of that survives: the *index register's* width breaks a VSIB gather, whose index is an
  `xmm`/`ymm`/`zmm`, so the extension becomes a no-op and a negative displacement comes back as
  four billion; the *encoded field's* width breaks EVEX, whose `disp8` is compressed, so the
  decoder returns it already scaled by the tuple while the field is still one byte and extending
  from eight bits turns a real `+128` into `-128`. The address registers give the width where
  there are any, an address-size override being exactly what makes them narrow, and the encoded
  field gives it for a pure VSIB form, where compression cannot arise because it needs a base. All
  three readings are pinned, so the two wrong ones fail a test rather than being re-proposed.
  An `EIP`-relative operand — a 64-bit instruction under an address-size override — has its
  displacement computed at 32 bits, the decoder wrapping the target to that width while the
  instruction's own next address stays 64, which otherwise puts the delta four gigabytes out.
  Reading is gated on `InstructionSet`: x86 and x64 are decoded, and anything else — ARM64 today —
  reports its mnemonic, no operands and `Flow::Unknown`.
  Measured against a whole real dispatch routine rather than composed lines
  (`examples/typed_disassembly.rs`): 376 instructions of `mountmgr!MountMgrDeviceControl` on a
  26100 image, **zero** unrecognised operands, zero unknown flows, and its eleven control-code
  compares recovered as values — identical before and after the decoder replaced the parser.
- `DebugEngine::decode_range` decodes a span from **one** memory read instead of one engine call
  per instruction, which is what a bounded traversal over a hundred functions needs. Its
  instructions carry no `text`, nothing having rendered them; a caller needing a rendering for the
  few it displays asks `disassemble` for those. The two paths are compared against each other in
  the example over a real function's first region: 23 instructions, 23 compared, 0 disagreements.
- `DebugEngine::effective_processor_type` reports the processor the engine is **rendering** in, as
  against the physical one `processor_type` already answered. The two diverge wherever one machine
  runs another's code — a WOW64 process, x64 emulated on ARM64, any target after `.effmach` — and
  it is the effective one that discriminates a *rendering*, so `instruction_set` reads it. Measured
  by forcing the divergence: `.effmach x86` on an x64 kernel dump moves the effective type to
  `0x14c` while the physical stays `0x8664`, and the reading follows it rather than decoding an x64
  unwind record against x86 output. Anything reading the target's **structures** still wants the
  physical type, a pointer's width being a fact about the machine rather than about a rendering,
  so the pool and heap walkers are unchanged.
- `DebugEngine::function_extent` returns the unwind **region** containing an address, from the
  image's `.pdata`, rebased. A region is **not** a function, and using it as one loses code: MSVC
  splits a function across several entries, and this answers `0x14750..0x147a3` for
  `mountmgr!MountMgrDeviceControl` — 83 bytes, which `.fnent` confirms — while that routine's
  compare chain lives past `0x147dd`. Bounding a walk with it recovered zero control codes where
  following the flow recovered twelve. The x64 entry is three `u32` RVAs and not the 64-bit
  addresses the API's name suggests; reading them as `u64` reports "no entry" for a function that
  plainly has one.
  It answers a three-state `FunctionExtent` rather than an `Option`, because the two non-answers
  are different facts. `NoEntry` is reported for the one measured failure that means it —
  `E_NOINTERFACE`, which a real dbgeng 10.x gives for address zero, for a header page, and for
  every x86 address, 32-bit Windows having no unwind table — and any other failure is returned as
  an error rather than read as a leaf. `Unsupported` covers every instruction set but x64:
  ARM64's record is two words whose second is packed unwind data, measured as `needed = 8` with
  `[0x0025df60, 0x0005f218]` for `nt!KeBugCheckEx` on an ARM64 kernel dump, and read as an end
  address that is a bogus region which — for any function below the `.xdata` RVA — contains the
  address asked about and passes every sanity check. The engine's own `needed` is checked against
  the x64 shape rather than assumed.
- `DebugEngine::symbol_for` is the public half of the existing symbol lookup: the `module!Symbol`
  an address resolves to and how far past it, or `None` for a driver with no PDB.
- **Exception events are readable as values.** `DebugEngine::last_event` returns a `DebugEvent` —
  kind, engine process and thread, and, when the event carried one, an `ExceptionRecord` with the
  code, flags, faulting address and parameters. That is `.exr -1` typed, and it is the user-mode
  counterpart to `bug_check`: on a target stopped by a fault it is the record that stopped it.
  `Ok(None)` where the engine has seen no event, which is any engine before its first wait —
  including a dump `open_dump` has *named* but nothing has pumped. That case is `None` rather than
  an error because the engine does not fail it: it answers `S_OK` with kind `0` and `DEBUG_ANY_ID`
  for both ids, and kind `0` is not a `DEBUG_EVENT_*` value.
  `ExceptionRecord::parameters` arrives already cut to the record's own `NumberParameters` (and
  clamped to the fifteen slots there are), because the count is the field that tells the two shapes
  of a `0xc0000409` apart — one parameter is the CRT's `abort`, three is WIL's, whose second is the
  `HRESULT` — and a leftover read as a parameter would answer the question wrongly rather than
  cosmetically.
- `DebugEngine::stored_event` returns the event a dump was **written for**, with the register
  context it was written with, as an opaque `ThreadContext`. Unlike `last_event` it does not move:
  it still answers after a caller has stepped, gone, or changed threads. `Ok(None)` where there is
  no stored event — every live target, and every dump not written for a fault, including kernel
  crash dumps, whose bug check `ReadBugCheckData` reads instead. That is read off the engine's own
  refusal (`E_UNEXPECTED`, measured on both) rather than probed for, so a genuine failure still
  reaches the caller as one.
- `DebugEngine::stack_frames_from` walks the stack a recorded context was in, which is what
  `.ecxr; k` produces without `.ecxr`'s effect on the session: the caller's selected thread and
  frame are left exactly where they were, so a triage built on it is still a read. **What makes it
  differ from `stack_frames` is the selected thread and only that** — measured on a two-thread
  fail-fast dump, after `~1s` the other walk returns the parked thread's six frames while this one
  still returns the crash's twelve, while `.frame`, `.cxr` and `.ecxr` move neither, since they
  change the symbol scope and `GetStackTrace` walks from the thread's registers.
  A `ThreadContext` carries the target it was read from and is refused
  (`DbgEngError::ContextFromAnotherTarget`) by an engine that no longer holds it, exactly as
  `set_scope` refuses a stale `Scope` — and for a sharper reason: a stale scope points the session
  somewhere visibly wrong, while a stale context comes back as frames, which is an answer a caller
  cannot tell from the right one.

### Fixed

- **An opener that replaces the session now reissues the target identity.** Only `end_session` did,
  so a caller who opened dump A, saved a `Scope` or a `ThreadContext`, and opened dump B *through
  the same engine* got the same identity back — and the stale registers were accepted against the
  new target. The three openers that replace a session (`open_dump`, which `open_trace` delegates
  to, and both kernel attaches) already funnel through `forget_the_previous_session`, so the
  reissue lives there rather than in each of them: "the previous session is gone" now means all of
  what it says, and the next opener gets it without remembering to.
- `examples/stored_event_probe.rs`, the measurements behind the three. It also caught the one that
  would otherwise have shipped: `GetStoredEventInformation` does **not** refuse a context buffer
  that is too small the way `GetScope` does. It truncates — offered 716 bytes for an x64 dump it
  writes 716, reports 716 and returns success, and the damage surfaces three calls later when
  `GetContextStackTrace` rejects the truncated context with `E_INVALIDARG`. So the context ladder
  here starts *above* every real `CONTEXT` rather than below it, and grows only on the one signal
  the call gives that there was more to write.
- Breakpoints can be **set**, not only listed. `DebugEngine::set_breakpoint` and
  `set_breakpoint_bounded` take a `BreakpointSpec` — a location (`BreakpointAt::Address` or
  `::Expression`), an optional command, match thread, pass count, one-shot flag, and a `DataWatch`
  that makes it a data breakpoint (`ba`) — and answer with a `BreakpointSet` carrying the new
  breakpoint **as the engine holds it**, read back through the same getters `breakpoints()` uses.
  `remove_breakpoint` and `enable_breakpoint` take an id, as `bc`/`be`/`bd` do. Previously the only
  write path was the `execute` text hatch, so a caller building `bp <expr> "<command>"` had to
  escape a quoted string inside a `;`-separated command line and then screen the operand for both
  characters; a command now arrives as a parameter, and `examples/breakpoint_probe.rs` checks that
  one containing both survives byte-identical.
- `set_breakpoint_bounded` bounds the **location resolve**, the one step that can block: a symbolic
  location is evaluated eagerly, so on a module whose PDB is not local it is a symbol-server fetch
  with the engine held. Measured on dbgeng 10.0.29547.1002 — 2445 ms for a cold
  `KERNELBASE!CreateFileW` against an empty store, 151 ms warm, 0 ms for an address, and 0 ms to
  defer when the module is absent. `SetInterrupt` reaches it, so the bound is real; and because a
  break is otherwise **silent** — it returns `Ok` with a breakpoint, having abandoned the symbol
  load and left the module on export symbols for the rest of the session — the result carries
  `cut_short` rather than being a bare `Result<(), _>`.
- `OnExisting` says what to do about breakpoints already at the resolved address. The engine
  deduplicates nothing: three typed sets on one address leave three breakpoints. What deduplicates
  is the command layer — `bp` and `bu` resolve and then remove whatever is there, printing
  `breakpoint N redefined` — keyed by the resolved address, so a *deferred* expression duplicates
  freely. `OnExisting::Replace` reproduces that as a value, reporting the removed ids as
  `BreakpointSet::replaced`; `Add` is the default, since a primitive should not destroy what the
  caller did not name. Worth choosing deliberately: duplicates at one address stop the target
  **once** but activate every breakpoint there, so each one's command runs, and removing one by id
  leaves the address armed by the others. Nothing is removed until the replacement is fully
  configured and certain to be armed, so a call that fails part-way leaves the caller's existing
  breakpoints alone rather than handing them an error and an address they had already lost.
- `BreakpointInfo::data` reports a data breakpoint's watched region — what access, over how many
  bytes — read through `GetDataParameters`. The read side could previously say a breakpoint *was* a
  data breakpoint and not what it watched, which left the new read-back unable to confirm the half
  of a spec most worth confirming. `DataAccess::Other` keeps an access combination this build does
  not name rather than folding it into a plausible neighbour, as `BreakpointKind::Other` does.
- `examples/breakpoint_probe.rs`, the record behind all of the above.

### Removed

- **`unsafe impl Send` and `unsafe impl Sync` for `DebugEngine`** (#136 stage 4). They asserted
  the opposite of what this crate says about its own threading: `SetInterrupt` is the one DbgEng
  call documented as safe from any thread *because the rest of the engine is
  single-thread-affine*, so `Sync` promised concurrent `&self` calls into an engine that cannot
  take them and `Send` promised a move to another thread, which is the same claim one step
  weaker. Neither carried a safety comment, and neither could have been given a true one.

  **Semver-visible, and measured against both consumers before it was made**: removing each and
  building leaves this crate (`--all-targets`, tests and examples included) and windbg-mcp
  compiling unchanged, because both already create the engine on the thread that uses it. A
  consumer that did move an engine between threads is the case this breaks, and it was relying
  on an unsound impl to do it.

  `InterruptHandle` is untouched and is now the crate's only `Send + Sync` type — one
  `SetInterrupt` from anywhere, and nothing else. `deferred_inputs` becomes a `RefCell` rather
  than a `Mutex`, since `&self` now implies one thread. And
  `test_the_engine_does_not_cross_threads_and_the_handle_does` asserts all four bounds, because
  re-adding an `unsafe impl` is one line that compiles and reads as a fix for whatever error it
  silences.

- The public `Breakpoint<'a>` type, which had no caller in `src/` or `examples/` and was a trap for
  anyone who found it: built on the v1 `IDebugBreakpoint` where the read path uses v2, offering no
  setter but `set_offset_expression`, and panicking in three of its four methods — `enable`'s
  message was a copy of `set_offset_expression`'s. A breakpoint is created *disabled and at address
  zero*, so its documented use left a breakpoint on the null page that never fired, and the method
  that would have armed it was one of the three that panicked. Superseded by `set_breakpoint` and
  the id-taking `remove_breakpoint`/`enable_breakpoint`; the private `ScopedBreakpoint` is now the
  only wrapper over a raw breakpoint object, so there is one answer to who removes a breakpoint and
  when rather than two that disagreed.

### Changed

- **An arrival is delivered to the open waiting for it, instead of broadcast into a set every
  guard polls.** An opener registers what it is waiting for (`Registered`), the pump routes a stop
  to the first open that wants it and has nothing yet (`Arrivals`), and the entry dies with its
  guard. Stage 3 of [#136]https://github.com/glslang/dbgscope/issues/136.

  What it replaces, `stopped_on`, was an engine-wide set of every `(engine id, system pid)` the
  engine had ever stopped on. Because it outlived the opens that read it, it needed a lifecycle of
  its own — pruned at both openers for pid reuse, cleared where a session is replaced, and cleared
  again where one is ended — and each of those three arrived as a review finding on
  [#133]https://github.com/glslang/dbgscope/pull/133 rather than as a design. None of them is
  needed now: nothing outlives its reader, so nothing can go stale.
  `prune_processes_that_left` is down to the attachment record, which is about the teardown
  decision rather than about an open.

  **Two launches pending at once are told apart**, which `Arrival` documented as an accepted
  ambiguity: a launch is identified by elimination, so the first arrival was new to both snapshots
  and ended both waits. An arrival is now *claimed* by the open it is delivered to, so the second
  launch is still waiting when the next one comes. That fix was weighed and rejected at the time
  because it needed "new engine-wide state, cleared everywhere a session is replaced and pruned for
  pid reuse" — which was the cost of the record it would have joined, and is the cost this shape
  does not have.

  **A claim outlives the open that made it**, for as long as anything is still waiting: when a
  guard goes, the process it was given is inherited by the opens that remain. Without it the
  ambiguity above is closed only while both guards are held: the first launch's target stops
  again, nobody has it claimed any more, and it is absent from the second launch's snapshot because
  it did not exist when that snapshot was taken. Not a lifecycle creeping back in: the claim goes
  when the opens holding it do, where the record this replaces lived for the whole session.

  **The state is per client rather than per wrapper**, in a new `ClientState` held by `Arc` and
  keyed by client pointer in a `Weak` map. Two `DebugEngine`s can be live around one
  `IDebugClient6`, and a `wait_for_event` through one used to complete an open held by the other in
  its own copy of the record alone — the other then read `Listed`, waited again, and spent its
  whole bound on an event that had already happened. That was written down as a known gap for two
  releases; the interrupt scope from stage 2 had the mirror of it and moves into the same `Arc`,
  because it is the same field. A `Weak` map needs no equivalent of `reissue_identity`: a dead entry
  identifies itself, where a stale *identity* costs only a re-read and a stale *arrival* would
  answer `Arrived` for a target that never stopped.

  **`attached_processes` moves with them**, which was not planned and is the sharper of the two
  gaps that placement had. Delivery reads it to keep an attach's process from being claimed by a
  pending launch, and a pump through a second wrapper read an empty set; but an `end_session`
  through a wrapper that did not perform the attach also saw no attachment to detach, so its
  passive end **killed** somebody else's process — the exact failure that record exists to
  prevent, reached through the wrapper boundary. The sentence that used to defend the old
  placement argued that sharing would put the decision "behind an eviction policy" -- true of the
  identity cache, and not of a `Weak` map whose entry dies with the last wrapper holding it.

  **Two more from review, one of them pre-existing.** "Somebody else has this process" is now one
  rule in `Pending::wants`, so `presence` applies it as well as `deliver` did. A second launch was
  otherwise told `Listed` on the strength of a process the first had been given, and `Listed` is
  not `Absent`, so an interrupted wait answered `Ok(())` instead of `LiveTargetInterrupted`.

  And `prune_processes_that_left` no longer drops an attachment that has not joined yet.
  `AttachProcess` joins its process at the next `WaitForEvent`, so between `attach_process_begin`
  and that wait the pid is recorded and the session does not list it. An opener pruning in that
  window dropped the record — after which the teardown treats somebody else's process as one this
  engine launched, and takes it. An attachment now carries whether it has been seen
  (`Attachment::Deferred` / `Joined`), promoted wherever the engine lists the session on its own
  account: the prune, and the pump.

  And a third round on the state that introduced: `Deferred` is kept because a deferred attach has
  not arrived and so cannot have left, but only a listing promotes one — so an `AttachProcess` the
  engine accepted for a process that then exited before the first `WaitForEvent` left a pid
  recorded for the life of the session, where the prune used to bound it. A live open that waits
  out `LIVE_WAIT_MS` and never sees its process now retires the record, which is the only party
  that can say the attach cannot join. Not on an *interrupted* open, which says nothing about
  whether the attach is still coming.

  And a fourth round, both halves of it about the register being shared where it used to be a
  field. `Drop` tears the session down inline rather than calling `end_session`, so it never
  inherited the line that forgets the pending opens — which cost nothing while each wrapper had its
  own register and leaves a stale entry now, first in line for the next launch's stop through a
  wrapper that outlived the owner. And a **claim** now stops excluding once its process leaves the
  session: engine ids are handed back immediately, so a `.detach` and a reattach of the same pid
  reproduce a pair exactly, and a stale claim made the reattach refuse its own stop as somebody
  else's. That is the reuse the old record was pruned for, arriving from the opposite side — where
  a stale entry there made a new open read `Arrived` for a target that had not stopped, a stale
  claim makes it read `Absent` for one that had.

  A fifth round on the retirement above: it covered the *bound* and not the ending the scenario
  actually takes. When the target exits before the first `WaitForEvent` the session holds nothing,
  so the pump **fails** rather than expiring and the open returns through its `?` without ever
  reaching the bound. The error ending retires too, on the narrower condition that the session
  holds nothing at all — at the bound an open has pumped for `LIVE_WAIT_MS`, so a pid still not
  listed is not coming, where on a failed pump it may have pumped nothing and the same reading
  would retire an attach the engine had not yet had a chance to process.

  No public API changes. Two tests are gone rather than passing, and the constructions that make
  them unreachable are named where they were:
  `test_ending_a_session_forgets_which_processes_it_stopped_on` had no record to forget, and
  `test_a_process_that_left_takes_its_stop_with_it` is now
  `test_reclaiming_an_engine_id_does_not_reclaim_its_arrival`, which asserts the property
  end to end instead of the guard that used to hold it.

- **A break request names the operation it is for.** `InterruptHandle::interrupt` answers a
  `BreakRequest` — `Raised { operation }` or `NothingRunning` — instead of `Result<(), _>`, and
  files the request against the bounded operation the engine is running **under the same lock it
  delivers `SetInterrupt` on**. `DebugEngine::begin_operation` opens one; its guard discards an
  unread request when it drops. Stage 2 of
  [#136]https://github.com/glslang/dbgscope/issues/136, closing
  [#135]https://github.com/glslang/dbgscope/issues/135.

  What it replaced was an engine-wide `AtomicBool` answering *has an interrupt been requested*,
  where every reader wanted *was **this** operation asked to stop*. Six operations cleared it as
  they opened, so a request lodged between an operation's clear and its wait was **erased while its
  break was still on the way** — and the synthetic Ctrl+Break that arrived next was then reported
  as the target's own stop, up to and including being recorded in `stopped_on` as a target's
  initial break, which is the exact misattribution that record's gate exists to prevent, reached
  around it rather than through it. That is #135 half A. Half B — a request outliving the wait it
  ended — was closed by stage 1, which made every pump *take* what it read.

  **The lock is the fix, not the identity.** A generation counter does not close it: if
  `interrupt()` bumps and the operation samples after the bump but before `SetInterrupt`, the
  request is erased exactly as before. The window is between two writes, not between two values, so
  what closes it is making the record and the operation boundary mutually exclusive. The id earns
  its place elsewhere — operations **nest**, since `wait_for_kernel_break_in` holds one across an
  `absorb_initial_break_artifact` that runs a whole `execute_and_wait`, so `running` is a stack and
  `asked` a set and a `bool` could not express either.

  Two consequences. **Delivery stays engine-wide and only attribution is scoped**: `SetInterrupt`
  cannot be aimed, so the break is issued unconditionally — that is what lets a host abort a long
  unbounded `execute_command` that no bounded operation covers — and `NothingRunning` is the
  honest answer when nothing will report it. And **the watchdog files nothing**, reaching the engine
  through a private `break_in_only`, which deletes the `by_watchdog | flag` reconciliation from five
  sites: a deadline and a host request are now independent signals rather than two readings of one
  bit.

  **The residue is named rather than closed**, and it is two shapes of one fact — `SetInterrupt`
  is engine-wide, so which operation a break *lands* on is not this crate's to decide. A break
  aimed at operation N can land on N+1, because N ended between the host reading what was running
  and the break arriving; and a request can be filed against N *after* N's last read of one, since
  an operation accepts requests for slightly longer than it reads them. Neither is reportable. What
  the second one gets is a **drain**: an operation closing on a request nobody read consumes the
  engine's own pending break, so it cannot go on to stop the next operation with nothing to explain
  it — the policy `execute_and_wait`, `settle` and the bounded command path already applied
  wherever a break belonged to no operation, generalised to the one window with no site to put it
  at. `BreakRequest::Raised` says which operation a request was filed against and deliberately does
  not promise that operation will report it: whether the engine thread has a read left is not
  knowable to the calling thread. `examples/interrupt_provenance.rs`
  is the measurement #136 asked for before anything relies on one: a request that *ended* a wait is
  consumed before the wait returns (`[false; 5]`), one that did not is still readable
  (`[true, false, …]`), two back to back are one flag rather than two, and one lodged after the
  wait it was too late for belongs to the next operation. So a post-wait `GetInterrupt` is a
  **forward** signal, which is stage 3's to use.

- **A wait returns what it did.** `DebugEngine::wait_for_event` answers a `WaitOutcome` —
  `Stopped { process }`, `Expired`, `Deadline` or `OnRequest` — instead of `Result<(), _>`, and
  every wait in the crate now goes through one private `pump(bound)` that produces that value
  before anything downstream can look. `Bound` says how a pump is bounded: `Finite(ms)` is a plain
  `WaitForEvent(ms)` whose expiry leaves the target running, and `Watchdog(ms)` is
  `WaitForEvent(INFINITE)` with the Ctrl+Break watchdog the old private
  `wait_for_event_bounded` provided. Stage 1 of
  [#136]https://github.com/glslang/dbgscope/issues/136.

  The engine offers four endings and three of them were invisible from outside the wait: `S_OK` and
  `S_FALSE` are flattened into one `Ok(())` by the generated wrapper, and a break has been serviced
  by the time anything else could look. So the outcome used to be reconstructed *afterwards*, by
  three parties, out of shared mutable state — the last-event slot and the session's process list
  each read twice, the interrupt flag read twice, and the `HRESULT` only the waiting call ever saw
  discarded. #136's evidence that this is one root rather than twenty defects: **15 of the 22
  findings** on the [#133]https://github.com/glslang/dbgscope/pull/133 review were one of
  those reads moving, and **9** of them were a single question — may this writer record a stop?
  — asked once per writer. Those nine are now unreachable rather than guarded: an expiry and a
  break are *arms* of the value, and only `Stopped` reaches the recorder.

  Behaviour is otherwise unchanged, with two deliberate exceptions, both of them one rule replacing
  three. **A break outranks the wait's own error**, either origin's — which `execute_and_wait` and
  `settle` already did ("a break makes both of these fail"), `run_to_address` did for the watchdog's
  break alone, and `wait_for_event` did not do at all. Narrow in practice, since `SetInterrupt` ends
  a wait with `S_OK`: it takes the target failing in the same window. And **the request is taken
  rather than read**, by the pump, so no path can leave one standing for the next operation to be
  charged with; `run_to_address` had a line for that and `wait_for_kernel_break_in` had neither that
  nor the clear on the way in.

  `examples/deferred_arrival.rs` is the measurement #133 is held to and it is unmoved: arm A 0 short
  in 40 rounds under load, arm F 4.2 µs, arm H 5.6 µs (x64 bench, Windows 11 26200, 24 spinners).
  `examples/session_fuzz.rs` is clean over seeds 1, 2, 7 and 13. One thing #136 makes visible
  without changing: a **host's** break during a kernel attach is still reported as a clean break-in
  rather than as a timeout, because naming it wants an error of its own — stage 2's.

- `launch_process` launches with `CREATE_NO_WINDOW` instead of `CREATE_NEW_CONSOLE`, so a launched
  console target no longer opens a window on the desktop and takes the foreground with it. The
  guarantee the old flag was there for is unchanged and is what `CREATE_NO_WINDOW` also provides:
  the target gets a console of its **own**, so its prints cannot reach the launching process's
  stdout — which for an MCP host is its JSON-RPC channel. Measured with a `STARTUPINFO` carrying no
  `STARTF_USESTDHANDLES`, the shape DbgEng uses: with no flag at all the target's `echo` lands in
  the launching process's stdout, and with either console flag it does not, `bInheritHandles` either
  way. What is lost is a debuggee's console output being readable on the desktop — it was never
  captured, and a caller that wants it can redirect (`cmd.exe /c prog > file`) rather than have
  every launch open a window on the chance someone is looking. A driver launching targets
  repeatedly made the machine unusable ([#129]https://github.com/glslang/dbgscope/issues/129).
  `test_a_launched_target_has_a_console_of_its_own_and_no_window` asserts three things: that the
  target's console is not this process's, that it *has* one (`mode con` in the target has to report
  `Status for device CON`, which is what separates this flag from `DETACHED_PROCESS`), and that it
  owns no visible window. The last is a negative, so it is calibrated against a control the test
  spawns with `CREATE_NEW_CONSOLE`; a host where that control shows no window either fails the test
  rather than skipping the check, since by then the other two have been made.

### Fixed

- A **user-mode open now waits for its own target**, rather than for one event. `launch_process`
  and `attach_process` completed on a single `WaitForEvent`, which is one event and not necessarily
  theirs: `CreateProcessWide` defers the spawn into that wait, and an engine already holding a
  target can return from it on *that* target's event instead. Measured
  (`examples/deferred_arrival.rs`, 40 rounds under CPU load): an `AttachProcess` break-in whose
  injected thread is slow to be scheduled lands a whole wait late, and the `launch_process` after
  it spends its only wait on that break — returning `Ok` with its process absent from the session,
  3 times in 40, and 0 in 40 on a quiet machine. `PendingTarget::wait` now pumps until the event it
  stopped on belongs to the process the open created or claimed, within the same `LIVE_WAIT_MS`
  bound for the whole open; the event is queued rather than lost, so it arrived on the very next
  wait every time it was observed. **Membership in the session is not the terminal condition** —
  `cpr` is an ignored filter, so a process is registered when its create event is processed and its
  initial breakpoint arrives later, and a competing break in between would leave the open's process
  listed but not where the open promised to leave it — so the pump waits for the process to have
  **stopped**. That is read from a record the engine keeps (`stopped_on`, written by both waits
  from `GetLastEventInformation`, by engine id, which
  `test_the_last_event_names_its_process_by_engine_id` pins against `session_processes`) rather
  than from that call in the moment: it is a single session-wide slot every later event
  overwrites, so read directly it answers the same way for a target still coming and for one that
  stopped before its guard was waited on. A wait that cannot evaluate its own postcondition — a
  snapshot that would not read, a status or process list that would not answer — returns as it did
  before, and only a process demonstrably not in the session by the bound answers the new
  `DbgEngError::LiveTargetTimeout`; one that is there but was never seen to stop ends the wait
  `Ok`, because "not observed to stop" is not "never arrived". A session holding *nothing* is
  absence rather than a question that could not be put, which is a mapping and not a road: a wait
  with no debuggee fails (`E_UNEXPECTED`, 200µs) instead of expiring, so an open never reaches its
  bound holding nothing — measured, and pinned alongside the mapping so that an engine which
  starts expiring instead fails a test rather than a caller. The record is cleared when the session
  is replaced *and* when it is ended, since the next session hands engine ids out from zero again
  — two `attach_process` calls to one pid on one engine would otherwise have the second inherit
  the first's answer. With the fix, 0 short in 40 rounds under the same load. Reported as
  [dbgscope#128]https://github.com/glslang/dbgscope/issues/128, where it had been failing
  `test_a_mixed_session_comes_apart_by_where_each_process_came_from` on CI's coverage job.
- **`run_to_address` no longer leaves its watchdog's interrupt raised.** It was the one bounded
  path that neither cleared the shared flag when it began nor consumed it when it ended, which
  cost it nothing of its own -- it classifies by the watchdog's private flag -- and cost everything
  else once the arrival record began reading the shared one: a single timed-out run left every
  later wait declining to record a real initial break, and any live open still held pumping to its
  bound for a target that had already stopped. All five paths that pump now clear on the way in,
  and this one consumes on the way out.
- **A live open a host interrupts now ends, instead of pumping through the break.** New:
  `DbgEngError::LiveTargetInterrupted`. The pumping this release introduces made an interrupt
  something the open ignored -- before it, a live open was a single `WaitForEvent`, so the break
  ended the wait and `wait()` returned. What that costs is not only the caller's time: measured
  with the check backed out, an interrupted open spends the whole 30s bound and answers
  `CommandFailed(0x8000FFFF)`, because the pumping let the debuggee run to completion and left no
  session to ask. The ending is the same rule the bound uses -- a process visibly in the session
  ends the wait `Ok` -- except that a process which is not there is reported as interrupted rather
  than as a timeout the open never reached, since a timeout says the target is not coming and this
  says nothing about the target at all.
- **A break nobody's target asked for is not an arrival, whichever origin raised it and whichever
  wait took it.** The watchdog's deadline and a host's `InterruptHandle` reach the engine through
  the same `SetInterrupt` and produce the same stop; only the advice differs, which is what
  `Interruption`'s two variants are for. Recording it lets a guard report an initial-break wait
  that never happened, because a Ctrl+Break stops whatever was running -- in a mixed session, a
  deferred target that has not reached its loader breakpoint. This arrived as three review rounds,
  one door at a time: the watchdog on the bounded wait, then a host on the bounded wait, then a
  host on the finite wait -- which is the one a live open pumps with, so the false arrival reaches
  the guard directly. The rule is therefore inside `note_where_it_stopped` and not at its call
  sites: both origins raise the same flag, so one question covers every wait in the crate and a
  new one cannot forget it. It reads the flag rather than consuming it, since the callers still
  need it to say which origin asked, and `wait_for_live_target` now clears it when an open begins
  -- the line `execute_and_wait` and `settle` already carry, without which a stale flag would
  leave an open pumping to its bound for an answer it had.
- **A process that leaves a session takes its recorded stop with it.** `stopped_on` is keyed by
  `(engine id, pid)`, and engine ids are reused immediately -- measured: detaching engine id 0 and
  attaching another process hands the freed 0 straight back. So a session that `.detach`es one of
  its processes through the raw hatch and attaches to the same pid again gets the whole pair back,
  and `presence_of` would answer `Arrived` for a target whose initial breakpoint had not happened.
  Pruned alongside the attach record it sits beside, at the two openers, which is the only cadence
  it needs: nothing reads either record outside an open. `prune_dead_attachments` is
  `prune_processes_that_left`, since it no longer prunes only attachments.
- **A wait that stopped on nothing no longer records a stop.** `stopped_on` is written as each
  wait observes a stop, and two kinds of wait come back having observed none. A **watchdog-forced**
  Ctrl+Break was being recorded, which `wait_for_event_bounded` documents as something callers must
  not treat as a normal completion: it stops whatever was running, so an `execute_and_wait` or
  `run_to_address` pumping a mixed session could stop a deferred target before its initial
  breakpoint and leave that target's still-held guard reporting an initial-break wait that never
  happened. The other is an **expiry**, which `WaitForEvent` reports as `S_FALSE` and the generated
  wrapper flattens into the same `Ok` a stop gets; that one was never reaching the record, because
  an expired wait leaves `GetLastEventInformation` reporting `DEBUG_ANY_ID` rather than the event
  before it -- measured, and so the safety rested on an undocumented sentinel in the one function a
  guard trusts to end its wait early. Both are now gated, the expiry by reading the raw `HRESULT`
  through the vtable as `interrupted` already does, and the sentinel is pinned so that an engine
  which stops supplying it fails a test rather than an open.
- **What a teardown lets go of now turns on `EndSession`'s own outcome**, not on the value
  `end_session` returns. The two differ exactly when a detach fails: `end_session` reports that
  failure to its caller, and rightly, but a process this engine could not detach from is one left
  attached and running — it does not keep the session alive. Gating on the combined result held
  back both things the session owns on a session that had definitely gone: the deferred input
  buffers, where the cost is a leak, and the record of which processes this engine stopped on,
  where the cost is the stale entry the previous entry is about, reached by a second road. Found by
  review rather than by a test, and it stays that way: the split cannot be staged, because the
  detach loop *skips* a process the engine no longer lists rather than failing on it.
- A `PendingTarget` **waited after something else pumped its target in** no longer waits for the
  next event. The guard's own docs describe dropping one and letting the target materialize at the
  next `WaitForEvent` from any source; a guard still held when that happened made its wait anyway,
  which resumes an arrived target and waits out whatever comes next. Measured across the fix:
  **29.36s and `E_UNEXPECTED`** — the debuggee outran the bound and took the session with it —
  against **8.6µs and `Ok`**. Neither opener lists its process before the wait that completes it
  (measured), so the ordinary open still waits exactly once. The same measurement with a *second*
  target arrived since — which overwrites the one slot recording where the engine stopped — is
  29.4s and `E_UNEXPECTED` when the ask reads that slot against single-digit µs when it reads the
  record, and is the argument for `stopped_on` existing at all.
- `a_watchdog_disarmed_before_its_deadline_costs_nothing` measured the machine rather than the
  watchdog, and failed on the coverage job of a docs-only PR. Three things were wrong with it, and
  the first meant it was not testing the property at all: armed and disarmed back to back, the
  watchdog's thread usually had not run yet, so it saw the flag at the top of its loop and returned
  without ever reaching a wait — **the test passed with the condvar reverted**. The timing is now
  taken on a watchdog whose deadline has passed, after its own counter says it fired, so the parked
  thread is a **reading rather than an assumption** (a fixed sleep only makes an unparked thread
  unlikely, and a runner slow enough to matter is where that assumption fails). It bounds the
  disarm by `WATCHDOG_REPEAT` — the poll interval the condvar replaced — halved, rather than by an
  absolute 50ms, and takes the **median** of five rounds: the maximum measures the machine, and the
  minimum lets one stray round excuse a regression. The never-fires half is asserted separately, on
  a watchdog 30s from its deadline, where no handshake is available. Checked both ways: 0.13s
  green, and red against the reverted condvar with all five rounds at 177-182ms.
- `BreakpointInfo::expression`'s documentation described only what `bp` does. A breakpoint whose
  location was set through `SetOffsetExpression` **keeps** its expression beside a resolved address,
  where one set by `bp` has the text discarded once it resolves — so `None` there is not the
  universal case for a live breakpoint. `deferred` is the field that answers whether a breakpoint
  has an address yet.
- `breakpoints()` reads through `GetBreakpointByIndex2`, putting the whole breakpoint path on
  `IDebugBreakpoint2` rather than mixing the two interface versions.
- `examples/session_fuzz.rs` no longer forces its seed odd, which had silently halved what
  `--seed` can name: `Rng::new` did `seed | 1`, so `42` and `43` were one run and `6` and `7` were
  one run. The flag exists so a failing sequence can be reproduced and then *varied*, and a seed
  that aliases another looks like a new sequence while covering nothing new. The forbidden state
  for xorshift64* is zero rather than "even", so that is now the only case handled, and the
  clock-derived default is taken as it comes instead of being forced odd as well. Measured over
  the first 512 seeds, ten corpus draws each: **256** distinct walks before, **512** after. Seeds
  that were already odd — including the `1` this example's notes are written against — walk
  exactly what they walked. Found downstream in
  [windbg-mcp#268]https://github.com/glslang/windbg-mcp/pull/268, which ported this example to
  drive that server's tool surface, where the seed is a small integer someone types while
  scanning.

### Added

- Pool tag queries accept an optional nonzero match threshold. A new walk stops immediately after
  that many in-scope allocated chunks, reports the fired threshold separately from deadline and
  diagnostic truncation, and never caches the intentionally partial snapshot. A complete cached
  snapshot still answers exhaustively without being discarded.

### Added

- `DebugEngine::current_thread_system_id` and `DebugEngine::current_processor` — which thread the
  engine's answers are about, and which of a kernel target's processors it is on. Typed rather
  than parsed out of `~.`, whose text is one shape for a user-mode thread, another for a kernel
  processor, and a third when there is no thread context at all. `current_processor` answers
  `None` for *no processor number applies here* — a user-mode target, a dump of one and a TTD trace,
  by construction — and it is not an answer about the register context, since `.thread` and `.trap`
  change what the debugger displays without changing which processor it is stopped on. It resolves
  through `GetThreadIdByProcessor` rather
  than reading the current thread index as a processor number, so nothing is inferred about the
  mapping it is asking about — the index is tried first, and confirmed by that same call, so the
  ordinary case costs one call rather than one per processor. A lookup that **fails** is not a
  processor that does not match: a match wins whatever else failed, and no match with a failure
  among the lookups is an `Err` rather than an `Ok(None)` that would report absence where the truth
  is unknown. Exercised beside `~.` in `examples/typed_context.rs`.

## [0.1.0] - 2026-08-29

First release. `dbgscope` gives typed access to a WinDbg/DbgEng debug session, and kernel-pool
and user-heap walkers built on one. The organising rule, and the thing to know before reading
the API, is that every answer carries what the answering cost — see
[Unknown, not absent](docs/unknown-not-absent.md).

### Added — debug sessions

- `DebugEngine` over `IDebugClient6` / `IDebugControl4` / `IDebugDataSpaces4` /
  `IDebugSymbols3`, owning its own session (`new`, `Default`) or borrowing an existing WinDbg
  client (`from_windbg_client`, `from_client_interface`, and their `try_` forms). `owns_session`
  governs teardown, so a borrowed client is never ended.
- Openers for every target DbgEng handles: `attach_kernel`, `attach_local_kernel`,
  `launch_process`, `attach_process`, `open_dump`, `open_trace`. The two post-mortem openers
  commit the session and leave the pump to the caller: the engine has no current process or
  thread — and `GetNumberRegisters` answers `0x8000FFFF` — until `wait_for_event` has run.
- `connect(remote_options)` for an existing debugging server, so an extension can load out of
  process.
- **Two-step opening** on the four *live* openers, each a thin `x_begin()?.wait()`. `x_begin`
  performs only the side effect that creates or claims the target and returns a `PendingTarget`;
  `wait` completes the initial break. The split lets a caller distinguish "nothing happened,
  retry is clean" from "the target exists and only the wait failed" — opposite recoveries, since
  re-running the second spawns a second process, attaches twice, or re-dials a live KD link.
  Dropping a guard is safe and non-blocking: deferred input buffers are parked on the engine
  rather than the guard, because `CreateProcessWide` reads the command line at the *next*
  `WaitForEvent`.
- **Per-process teardown.** DbgEng holds several user-mode targets at once and `EndSession`
  takes one flag for the whole session, so provenance is recorded per pid at open time. A
  process this engine *attached* to is detached individually before the session ends — otherwise
  a passive end kills somebody else's service. A live kernel is resumed and actively detached,
  or it stays frozen at its last break with one CPU halted. Failures are per process and
  reported without stopping the teardown.
- `launch_process` uses `DEBUG_ONLY_THIS_PROCESS | CREATE_NEW_CONSOLE`, so a console target
  cannot inherit a host's stdout — which may be a JSON-RPC channel.

### Added — execution control

- `execute_command_bounded`, `execute_and_wait`, `settle` and `run_to_address`: bounded waits
  with a condition-variable watchdog behind them. The watchdog stops the moment it is disarmed
  rather than at the end of a poll interval, so a bound costs nothing until it is reached.
- `CommandRun { output, cut_short, target_gone }` — the output *and* whether the command
  finished. A `String` alone cannot answer "did this run?", and an `Err` would discard the
  output, which on an interrupted search is all there was.
- `Interruption::Deadline { after_ms }` distinguished from `Interruption::OnRequest`, because
  the advice differs and only the first needs saying. The origin is decided by the watchdog's
  own flag, not by the shared interrupt bit that the watchdog also sets.
- `RunToOutcome` names four endings — `Hit`, `StoppedElsewhere { stopped_at }`, `Timeout`,
  `TargetGone` — rather than a boolean.
- `InterruptHandle`: a `Send + Sync` handle that Ctrl+Breaks an engine from another thread, over
  `SetInterrupt`, the one DbgEng call documented as safe there. It holds an owned interface
  reference, so it may outlive the `DebugEngine` it came from.
- **A target that ends is an ending, not a failure.** A debuggee running to completion is
  reported as `CommandRun::target_gone` / `RunToOutcome::TargetGone`, each keeping the output the
  run captured — the module loads, the breakpoint banner, an embedded script's prints, none of
  which a successor will print again. It is terminal, and callers are told so.
- **Nothing runs on an engine with no debuggee.** Driving DbgEng without a target faults inside
  it with a `STATUS_ACCESS_VIOLATION` that `catch_unwind` cannot trap, so `execute_command`,
  `execute_command_bounded`, `execute_and_wait` and `run_to_address` all refuse first.

### Added — typed session state

- `register_values` returning `RegisterValue`, decoded once from `DEBUG_VALUE`'s own tag:
  `Int`, `Float`, `Bytes` for x87 and vector registers that no `f64` can hold, and
  `Unavailable` for state a minidump does not carry — which is not `0`.
- `register_descriptions` for the whole register description rather than one flag of it.
- `modules`, `unloaded_modules`, `module`, `module_at`, `module_identity`, `module_pdb`,
  `module_symbol_file`. `SymbolKind` keeps `Deferred` separate from `None` and preserves an
  unrecognised provider as `Other(u32)`; `has_type_info()` answers the narrow question the pool
  walker actually asks.
- `stack_frames`, `bug_check` as the engine's five values, `breakpoints` as `BreakpointInfo`,
  `disassemble` as `Instruction` records with a line the split does not recognise kept whole
  rather than guessed at.
- Scope save and restore: `scope`, `set_scope`, `scope_guard` and the `ScopeGuard` RAII type,
  for running a command that moves the debugger's scope — measured: `!analyze -v` discards a
  frame or `.ecxr` context the caller had selected. A `Scope` carries the target identity it was
  read from and is refused rather than applied to a later target.
- Memory and symbols: `read_memory`, `valid_virtual_region`, `symbol_offset`, `type_id`,
  `type_size`, `field_offset`, `field_type_and_offset`, `field_names`, `set_symbol_path`,
  `append_symbol_path`, `reload_symbols`.
- Breakpoints: the `Breakpoint<'a>` RAII type, `BreakpointCallback`, and
  `DebugEventContextCallbacks` for event-driven handling.

### Added — kernel pool

- `pool::query`: `find_tag`, `chunk_at` with immediate neighbours, `tag_census`,
  `snapshot_report`, and cache invalidation hooks for a host that resumes or replaces a target.
- `PoolAnswer<T>` pairs every answer with the `PoolSnapshotReport` of the walk it came from, so
  a count and a coverage figure can never be drawn from two different walks — which is what
  happens otherwise, since an incomplete walk is deliberately not cached.
- `WalkCoverage { Complete, BudgetExpired, Partial }`, computed in one place from the walk's own
  two bits. Not a `bool`, because a walk that ran out of time reaches more of the pool if given
  more, and one that met unreadable regions reports the same gaps however long it runs.
- `PoolWalk` with `cached()`, `refreshed()`, `within(Duration)` and `unbounded()`, plus
  `impl From<bool>` so existing `refresh: bool` call sites are unchanged and pick up
  `DEFAULT_WALK_BUDGET` (120s). A walk that runs out of its budget is not an error: it returns
  the chunks it reached with the coverage saying so.
- `PoolDiagnostics` groups complaints by shape — the message with every number standing in for
  itself — keeping `DIAGNOSTIC_EXAMPLES` (8) verbatim per shape and the totals as numbers.
  `emitted()` describes the walk; `examples().len()` describes the struct, and on a busy target
  the two differ by two orders of magnitude.
- `WalkStalls`, `refused_chunks` and `unplaced_bytes` size what conservative decoding cost, so a
  refusal to guess is auditable rather than invisible.
- Both tag forms: one to four ASCII bytes, or the raw form `0x` plus eight hex digits in memory
  order, so it reads in the same direction as the printed tag (`Tgsm` is `0x5467736d`). The two
  cannot collide — the raw form is exactly ten characters and a printed tag at most four.
  `display_is_ambiguous` and `display_round_trips` separate the two distinct ways a rendering
  fails, and `tag_label` is the one rule every output site prints through.
- `PoolKind`'s eight variants are not collapsed to paged/nonpaged, because crossing one of those
  boundaries creates false holes. `PoolState::Unreadable` is distinct from allocated and from
  the two free states, and `chunk_at` returns `Ok(None)` for "not covered by the snapshot",
  which is a different answer from "it is a free hole".
- `find_tag` indexes allocated chunks only: a freed chunk's tag is not reliably preserved by the
  allocator, so returning freed chunks by tag would be inventing information.

### Added — user-mode Segment Heap

- `heap`: `list`, `allocations`, `chunk_at`, `census`, `diagnostics`, `diagnostics_for_heap`,
  with `HeapWalk` mirroring `PoolWalk` and `HeapAnswer<T>` mirroring `PoolAnswer<T>`.
- `HeapScope` names the roots that were skipped and why — `nt_heaps_skipped`,
  `unknown_heaps_skipped`, `unreadable_heaps_skipped` — rather than reporting only the ones that
  worked.
- Shared page-segment, LFH, VS, backend and large-allocation decoding with the pool walker,
  because the two allocators are the same machinery either side of the ring boundary.
- `allocator::LayoutProvenance` carries the image, PDB and a fingerprint of every resolved type
  size and field offset actually used — deliberately with no build-number policy in it.
- `requested_size` is `Option<u64>`, set only where allocator metadata validates it, rather than
  guessed from capacity.

### Added — WinDbg extension

- `!dbgscope.poolmap`, built from the `cdylib` crate type, over the same walker and the same
  caches as `pool::query`, so the interactive and programmatic entry points cannot drift apart.
- `-tag` (either form), `-paged` / `-nonpaged`, `-refresh`, and an address argument for detail
  on the allocation or hole containing it.
- DML colours and clickable address links where WinDbg accepts DML; meaningful ASCII glyphs and
  a legend where it is stripped or the output is captured as plain text.
- The extension lets a walk run to completion, because there is an operator at a prompt who can
  Ctrl+Break. `pool::query` cannot assume anyone is watching the clock, which is why its walks
  carry a budget — and a host that *is* watching can cancel one through `interrupt_handle()`,
  which the walk polls and reports as `PoolQueryError::Interrupted`.

### Known limitations

- **Windows only.** The public surface calls Windows APIs with no `#[cfg]` gating; the crate is
  not designed to build elsewhere. docs.rs is configured for the MSVC targets accordingly.
- **Pool walking is x64 only**, because the allocator encodings it consults are. The rest of the
  crate builds for ARM64, and CI covers both.
- **Windows 10 19H1 or later** allocator algorithms, and full private type information for `nt`.
  Symbols must be on the debugger host — PDBs are never fetched from a target over KD — and
  resolving them needs `msdia140.dll` beside the engine. Without it, a kernel dump presents not
  as missing symbols but as memory reads failing.
- **A live-kernel walk is normally incomplete.** Paged pool is partly on disk, and a page the
  memory manager has paged out cannot be read through the debugger either.
- **`KERNEL_ATTACH_WAIT_MS` bounds less than it looks like.** The watchdog works by
  `SetInterrupt`, which only reaches a target that has *connected*, so it caps a
  connected-but-unresponsive target and nothing else. One that never dials in — powered off,
  wrong key, not booted with `bcdedit /debug on` — blocks past the bound indefinitely.
- **Snapshots are snapshots.** The walkers examine current allocations, reusable frees and
  cached/delay-free spans while the target is stopped. They install no allocation breakpoints
  and reconstruct no history.
- **Per-session paged heaps** are outside the initial pool-map scope, and the command says so.
- **Pre-1.0.** The `dbgeng` surface is large and expected to change; breaking changes may land
  in any `0.x` release.

[Unreleased]: https://github.com/glslang/dbgscope/compare/v0.3.0...HEAD
[0.3.0]: https://github.com/glslang/dbgscope/releases/tag/v0.3.0
[0.2.0]: https://github.com/glslang/dbgscope/releases/tag/v0.2.0
[0.1.0]: https://github.com/glslang/dbgscope/releases/tag/v0.1.0