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
//! Debugger control seam (D8, issue #3186): breakpoints, pause/resume, and
//! step in/over/out — the part of the debugger epic (#452) that turns the
//! read-only [`crate::DebugSnapshot`] (D4, #3182) into something that can
//! actually halt and single-step a running story.
//!
//! **Why this is provably zero-cost when `debug-hooks` is off.** The
//! `effect-trace`/`bench-counters` precedent this feature follows
//! (`docs/debugger-spec.md` §1.4, `vm.rs:~1544-1620`) threads paired
//! `#[cfg(feature)]`/no-op-stub *call sites* directly into `vm::step_impl`'s
//! dispatch body, because that instrumentation (per-opcode fault/effect
//! attribution) genuinely needs to run inline with specific opcodes. A
//! breakpoint check does not: all it needs is the position
//! `(container_idx, offset)` *before* an instruction executes, which is
//! already fully available from outside the hot loop — [`Story`]'s own
//! `container_stack.last()` (the same read `debug_snapshot`/D4's
//! `debug_position` already do, `story/mod.rs`'s `build_debug_snapshot`).
//! So this module does not add anything to `vm::step_impl` or to
//! `FlowInstance::advance_with_limit` (the production per-turn loop) at
//! all — on or off. Instead it wraps the existing `pub(crate) vm::step`
//! (already used this way by the `testing`-gated `Story::step_once` probe)
//! in its **own** loop, entered only through the `Story::debug_run`/
//! `debug_step*` methods this module's types back — methods that exist at
//! all only when `debug-hooks` is enabled (declared behind
//! `#[cfg(feature = "debug-hooks")]` in `lib.rs` and `story/mod.rs`). With
//! the feature off: this module doesn't compile, those methods don't
//! exist, and every byte of `vm.rs`/`flow_instance.rs` that the production
//! path (`continue_single`/`continue_maximally`/…) actually executes is
//! untouched — not merely "the branch is cheap", there is no branch. This
//! is a *stronger* zero-cost property than the effect-trace template's own
//! (which does add cfg-compiled-out call sites inside the hot loop), and
//! it is exactly what CLAUDE.md's "instrumentation doesn't belong in the
//! production path" principle asks for: "if an `if observer` branch
//! appears in a hot loop, the abstraction boundary is wrong" — so this
//! seam doesn't put one there.
//!
//! **The step-limit ruling (issue #3186 decision comment, 2026-08-28).**
//! Debug stepping gets its own budget, entirely separate from the
//! production step limit (`FlowInstance::STEP_LIMIT`,
//! `Stats::steps`/`RuntimeError::StepLimitExceeded`):
//!
//! - Production step accounting is unchanged and unread from debug-hook
//! code: [`Story::debug_run`]/[`Story::debug_step`] call `vm::step`
//! directly (bypassing `advance_with_limit` entirely, per the module doc
//! above), and count VM steps in a **local** loop variable — never
//! `Stats::steps`. (`Stats` itself is still threaded through, because
//! `vm::step`'s signature requires `&mut Stats` for its own
//! *non*-step-limit bookkeeping — `frames_pushed`, `materializations`,
//! `choices_presented` — real per-event counters, not the step-limit
//! counter this ruling is about. Debug-hook code never reads or writes
//! `Stats::steps` specifically.)
//! - [`DEFAULT_DEBUG_BUDGET`] is the debug-only ceiling: generous enough
//! that ordinary single-stepping never trips it, low enough that a
//! `debug_run` that never reaches an armed breakpoint (or a `debug_step`
//! step-over/out that never returns to/leaves the target frame — a
//! runaway loop between the two) surfaces promptly rather than hanging a
//! studio UI. Callers may pass a tighter or looser ceiling per call.
//! - Exceeding it is [`RuntimeError::DebugBudgetExceeded`] — never
//! [`RuntimeError::StepLimitExceeded`], which would misreport a debug
//! budget as the production one.
//!
//! **Frame semantics** (breakpoint/step-into/step-over/step-out) are
//! derived from call-stack depth deltas per `docs/debugger-spec.md` §4 and
//! the issue's own framing — see [`Story::debug_step`]'s doc for exactly
//! how each [`StepMode`] maps to a depth comparison, and for what is
//! deliberately *not* attempted here (source-level/statement-boundary
//! stepping needs the `DebugInfo` section's `IS_STMT` entries, D6/#3184,
//! not shipped yet; this seam only has opcode-level positions to work
//! with, which is exactly what "derived from call-stack depth deltas" — a
//! phrase from the issue text itself — asks for).
//!
//! **Watchpoints reuse [`WriteObserver`]/[`ObservedContext`]** (`state.rs`)
//! rather than inventing a second observer, per the issue's own
//! instruction. [`WatchpointObserver`] is the whole addition: a
//! `WriteObserver` impl that records a hit when a *watched* global slot is
//! written. [`Story::debug_run_watching`] wraps the routing context in
//! [`ObservedContext`] around it, exactly as `Story::continue_single_observed`
//! already does for the production line-buffered path — no VM change
//! needed for this half either.
use String;
use Vec;
use Value;
use crateDebugPosition;
use crateWriteObserver;
/// Debug stepping's own step budget ceiling — separate from the
/// production step limit (`FlowInstance::STEP_LIMIT` = 1,000,000 per
/// call). See the module doc's "step-limit ruling" section for why this
/// exists and what it does and doesn't share with production accounting.
///
/// Chosen generous relative to a single step-into/step-over/breakpoint-run
/// (ordinary stepping and running to a breakpoint a handful of frames away
/// stays orders of magnitude under this), low enough that a predicate/loop
/// that never satisfies its stop condition reports back in well under a
/// second of VM-step work instead of hanging a studio UI indefinitely.
pub const DEFAULT_DEBUG_BUDGET: u64 = 200_000;
/// Identifies one breakpoint within a [`BreakpointSet`].
pub type BreakpointId = u32;
/// One breakpoint: an unconditional halt at a `(container_idx, offset)`
/// bytecode position, checked *before* that instruction executes.
///
/// v1 breakpoints are position-only (no source expression condition) —
/// scoped this way deliberately: an ink-expression *conditional*
/// breakpoint would need to evaluate an expression inside the paused
/// frame, which is exactly the "evaluate expression in frame" facility
/// [`crate::Speculation`] exists for for a *later* slice of this seam, not
/// re-derived here. A `run`/`debug_run` that never reaches an armed
/// breakpoint is still bounded — that's what [`DEFAULT_DEBUG_BUDGET`] is
/// for.
/// A caller-owned collection of breakpoints, checked by position. Not tied
/// to any particular [`crate::Story`] — the same set can be handed to
/// consecutive `debug_run` calls, or across flows compiled from the same
/// [`crate::Program`].
/// How a `debug_step` call derives its "run until" target from call-stack
/// depth deltas (`docs/debugger-spec.md` §4). See
/// [`Story::debug_step`](crate::Story::debug_step) for the exact
/// per-variant depth comparison.
/// Why a `debug_run`/`debug_step` call stopped.
/// The result of a `debug_run`/`debug_step*` call: why it stopped, the
/// resulting position (mirrors [`DebugPosition`] semantics — `None` for a
/// frame with an empty container stack, e.g. after a terminal step, or a
/// parked/`External`-frame position; see `debug.rs`'s own doc), and the
/// resulting call-stack depth (the innermost thread's frame count) at the
/// moment execution stopped.
/// One recorded watchpoint hit: a watched global slot was written to.
/// A [`WriteObserver`] that watches a fixed set of global slot indices and
/// records a hit whenever one is written — the entire watchpoint
/// implementation. Reuses the existing production `WriteObserver`/
/// `ObservedContext` seam (`state.rs`) rather than a second observer
/// mechanism, per the issue's own instruction: this struct is the only
/// piece of new plumbing watchpoints need.
///
/// Composes with [`crate::Story::debug_run_watching`] for pausing
/// mid-step-loop on a hit, or with the existing
/// `Story::continue_single_observed` (unaffected by this feature) for
/// production-path logging without pausing — the observer doesn't care
/// which loop drives it.