m68000-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
m68000_python/debug.py ADDED
@@ -0,0 +1,361 @@
1
+ """Dependency-free execution control and structured debugging records.
2
+
3
+ ``DebugSession`` drives an existing CPU -- any object with ``step()`` and
4
+ ``capture_state()`` -- one boundary at a time, with execute breakpoints,
5
+ bounded runs, a history ring and, optionally, memory-access tracking and
6
+ watchpoints. It never changes what the core does. See docs/debug-session.md.
7
+ """
8
+
9
+ from collections import deque
10
+ from collections.abc import Callable, Iterator
11
+ from dataclasses import dataclass
12
+ from enum import Enum
13
+ from typing import Protocol, runtime_checkable
14
+
15
+ from m68000_python.disasm import Instruction, WordReader, disassemble
16
+ from m68000_python.state import CPUState
17
+
18
+
19
+ @runtime_checkable
20
+ class DebugTarget(Protocol):
21
+ """The minimum a :class:`DebugSession` needs from a CPU."""
22
+
23
+ def step(self) -> int:
24
+ """Advance one instruction or exception boundary; return its clocks."""
25
+
26
+ def capture_state(self) -> CPUState:
27
+ """Capture the current CPU-owned state."""
28
+
29
+
30
+ class BoundaryKind(Enum):
31
+ """What one ``step()`` did."""
32
+
33
+ INSTRUCTION = "instruction"
34
+ TRACE = "trace" # the trace exception after a traced instruction
35
+ INTERRUPT = "interrupt" # an interrupt accepted
36
+ STOPPED_IDLE = "stopped_idle" # clocks spent inside STOP
37
+ HALTED_IDLE = "halted_idle" # clocks spent halted by a double bus fault
38
+
39
+
40
+ class StopReason(Enum):
41
+ """Why a bounded run returned control."""
42
+
43
+ BREAKPOINT = "breakpoint" # before the instruction at a breakpoint
44
+ WATCHPOINT = "watchpoint" # after the step that touched a watched address
45
+ STOPPED = "stopped" # inside STOP with no interrupt able to end it
46
+ HALTED = "halted" # double bus fault; only reset leaves it
47
+ STEP_LIMIT = "step_limit"
48
+ CYCLE_LIMIT = "cycle_limit"
49
+
50
+
51
+ #: One bus access: ("r" or "w", address, value, size in bytes).
52
+ Access = tuple[str, int, int, int]
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class StepRecord:
57
+ """Immutable before/after evidence for one boundary."""
58
+
59
+ sequence: int
60
+ kind: BoundaryKind
61
+ before: CPUState
62
+ after: CPUState
63
+ cycles: int
64
+ instruction: Instruction | None
65
+ #: Every bus access the step made, in order, when the session tracks
66
+ #: accesses; ``None`` otherwise.
67
+ accesses: tuple[Access, ...] | None = None
68
+
69
+ def __post_init__(self) -> None:
70
+ if type(self.sequence) is not int or self.sequence < 0:
71
+ raise ValueError("sequence must be a non-negative integer")
72
+ if type(self.kind) is not BoundaryKind:
73
+ raise ValueError("kind must be a BoundaryKind")
74
+ if type(self.before) is not CPUState or type(self.after) is not CPUState:
75
+ raise ValueError("before and after must be CPUState values")
76
+ if type(self.cycles) is not int or self.cycles <= 0:
77
+ raise ValueError("cycles must be a positive integer")
78
+ if self.kind is not BoundaryKind.INSTRUCTION and self.instruction is not None:
79
+ raise ValueError("only instruction boundaries carry an instruction")
80
+ if self.instruction is not None and type(self.instruction) is not Instruction:
81
+ raise ValueError("instruction must be an Instruction or None")
82
+ if self.accesses is not None and (
83
+ type(self.accesses) is not tuple or not all(_is_access(a) for a in self.accesses)
84
+ ):
85
+ raise ValueError(
86
+ 'accesses must be a tuple of ("r" or "w", address, value, size 1 or 2)'
87
+ )
88
+
89
+
90
+ @dataclass(frozen=True, slots=True)
91
+ class RunResult:
92
+ """Summary of one bounded run."""
93
+
94
+ reason: StopReason
95
+ steps: int
96
+ instructions: int
97
+ cycles: int
98
+ state: CPUState
99
+ last_record: StepRecord | None
100
+ #: For WATCHPOINT: the watched accesses that stopped the run.
101
+ hits: tuple[Access, ...] = ()
102
+
103
+
104
+ def next_boundary(state: CPUState) -> BoundaryKind:
105
+ """What the next ``step()`` will do, decided exactly as ``M68000CPU.step()`` decides it."""
106
+ if state.halted:
107
+ return BoundaryKind.HALTED_IDLE
108
+ if state.trace_pending:
109
+ return BoundaryKind.TRACE
110
+ if state.ipl and (state.ipl > (state.sr >> 8) & 7 or (state.ipl == 7 and state.nmi_edge)):
111
+ return BoundaryKind.INTERRUPT
112
+ if state.stopped:
113
+ return BoundaryKind.STOPPED_IDLE
114
+ return BoundaryKind.INSTRUCTION
115
+
116
+
117
+ class DebugSession:
118
+ """Control an existing CPU without modifying its execution core.
119
+
120
+ ``peek_word`` must be side-effect-free (the host's memory, not its bus);
121
+ without it stepping and breakpoints work but records carry no
122
+ disassembly. ``track_accesses=True`` re-attaches the CPU's four bus
123
+ callables through wrappers that record every access and enable
124
+ watchpoints; :meth:`close` puts the originals back.
125
+
126
+ The target may be a whole board rather than a bare CPU: an object whose
127
+ ``step()`` runs its devices around one CPU step and whose ``cpu``
128
+ attribute is the processor.
129
+ """
130
+
131
+ def __init__(
132
+ self,
133
+ target: DebugTarget,
134
+ *,
135
+ peek_word: WordReader | None = None,
136
+ history_limit: int = 256,
137
+ track_accesses: bool = False,
138
+ ) -> None:
139
+ if not isinstance(target, DebugTarget):
140
+ raise TypeError("target must provide step() and capture_state()")
141
+ if peek_word is not None and not callable(peek_word):
142
+ raise TypeError("peek_word must be callable or None")
143
+ if type(history_limit) is not int or history_limit < 0:
144
+ raise ValueError("history_limit must be a non-negative integer")
145
+ self.target = target
146
+ #: The processor: the target itself, or the ``cpu`` a board target carries.
147
+ self.cpu = getattr(target, "cpu", target)
148
+ self.peek_word = peek_word
149
+ self.history_limit = history_limit
150
+ self.breakpoints: set[int] = set()
151
+ self.watchpoints: dict[int, str] = {} # address -> "r", "w" or "rw"
152
+ self.total_steps = 0
153
+ self.total_instructions = 0
154
+ self.total_cycles = 0
155
+ self._history: deque[StepRecord] = deque(maxlen=history_limit or 1)
156
+ self._accesses: list[Access] | None = None
157
+ self._originals: tuple[Callable, ...] | None = None
158
+ if track_accesses:
159
+ self._wrap_bus()
160
+
161
+ # -- access tracking ---------------------------------------------------
162
+
163
+ @property
164
+ def tracking(self) -> bool:
165
+ return self._originals is not None
166
+
167
+ def _wrap_bus(self) -> None:
168
+ cpu = self.cpu
169
+ read_byte, read_word = cpu.read_byte, cpu.read_word
170
+ write_byte, write_word = cpu.write_byte, cpu.write_word
171
+ self._originals = (read_byte, read_word, write_byte, write_word)
172
+ self._accesses = []
173
+ log = self._accesses
174
+
175
+ def tracked_read_byte(address: int, **kw) -> int:
176
+ value = read_byte(address, **kw)
177
+ log.append(("r", address, value, 1))
178
+ return value
179
+
180
+ def tracked_read_word(address: int, **kw) -> int:
181
+ value = read_word(address, **kw)
182
+ log.append(("r", address, value, 2))
183
+ return value
184
+
185
+ def tracked_write_byte(address: int, value: int, **kw) -> None:
186
+ log.append(("w", address, value, 1))
187
+ write_byte(address, value, **kw)
188
+
189
+ def tracked_write_word(address: int, value: int, **kw) -> None:
190
+ log.append(("w", address, value, 2))
191
+ write_word(address, value, **kw)
192
+
193
+ cpu.attach_bus(tracked_read_byte, tracked_read_word, tracked_write_byte, tracked_write_word)
194
+
195
+ def close(self) -> None:
196
+ """Stop tracking accesses and give the CPU its own bus callables back."""
197
+ if self._originals is not None:
198
+ self.cpu.attach_bus(*self._originals)
199
+ self._originals = None
200
+ self._accesses = None
201
+
202
+ # -- breakpoints and watchpoints ---------------------------------------
203
+
204
+ def add_breakpoint(self, address: int) -> None:
205
+ """Stop before executing the instruction at ``address``."""
206
+ self.breakpoints.add(_address(address))
207
+
208
+ def remove_breakpoint(self, address: int) -> None:
209
+ self.breakpoints.discard(_address(address))
210
+
211
+ def add_watchpoint(self, address: int, kind: str = "rw") -> None:
212
+ """Stop after any step that reads (``"r"``), writes (``"w"``) or either (``"rw"``).
213
+
214
+ A word access watches both of its bytes.
215
+ """
216
+ if not self.tracking:
217
+ raise ValueError("watchpoints need a session created with track_accesses=True")
218
+ if kind not in ("r", "w", "rw"):
219
+ raise ValueError('kind must be "r", "w" or "rw"')
220
+ self.watchpoints[_address(address)] = kind
221
+
222
+ def remove_watchpoint(self, address: int) -> None:
223
+ self.watchpoints.pop(_address(address), None)
224
+
225
+ # -- history -----------------------------------------------------------
226
+
227
+ @property
228
+ def history(self) -> tuple[StepRecord, ...]:
229
+ """Bounded immutable view of the retained records, oldest first."""
230
+ return tuple(self._history)
231
+
232
+ def iter_history(self, *, newest_first: bool = False) -> Iterator[StepRecord]:
233
+ return reversed(self._history) if newest_first else iter(self._history)
234
+
235
+ def clear_history(self) -> None:
236
+ self._history.clear()
237
+
238
+ # -- execution ---------------------------------------------------------
239
+
240
+ def step(self) -> StepRecord:
241
+ """Advance exactly one boundary, ignoring breakpoints."""
242
+ before = self.target.capture_state()
243
+ kind = next_boundary(before)
244
+ instruction = None
245
+ if kind is BoundaryKind.INSTRUCTION and self.peek_word is not None:
246
+ instruction = disassemble(self.peek_word, before.pc)
247
+ if self._accesses is not None:
248
+ self._accesses.clear()
249
+ cycles = self.target.step()
250
+ if type(cycles) is not int or cycles <= 0:
251
+ raise ValueError("target step() must return a positive cycle count")
252
+ record = StepRecord(
253
+ sequence=self.total_steps,
254
+ kind=kind,
255
+ before=before,
256
+ after=self.target.capture_state(),
257
+ cycles=cycles,
258
+ instruction=instruction,
259
+ accesses=None if self._accesses is None else tuple(self._accesses),
260
+ )
261
+ self.total_steps += 1
262
+ self.total_cycles += cycles
263
+ if kind is BoundaryKind.INSTRUCTION:
264
+ self.total_instructions += 1
265
+ if self.history_limit:
266
+ self._history.append(record)
267
+ return record
268
+
269
+ def run(
270
+ self,
271
+ *,
272
+ max_steps: int,
273
+ max_cycles: int | None = None,
274
+ stop_on_stop: bool = True,
275
+ stop_on_halt: bool = True,
276
+ ) -> RunResult:
277
+ """Run until a stop condition or the mandatory step budget.
278
+
279
+ Breakpoints stop *before* the instruction; watchpoints stop *after*
280
+ the step that touched the address. A cycle limit is checked after each
281
+ atomic step and may be exceeded by that step's cost.
282
+ """
283
+ if type(max_steps) is not int or max_steps <= 0:
284
+ raise ValueError("max_steps must be a positive integer")
285
+ if max_cycles is not None and (type(max_cycles) is not int or max_cycles <= 0):
286
+ raise ValueError("max_cycles must be a positive integer or None")
287
+ steps = instructions = cycles = 0
288
+ last = None
289
+
290
+ def result(reason: StopReason, state: CPUState, **extra) -> RunResult:
291
+ return RunResult(reason, steps, instructions, cycles, state, last, **extra)
292
+
293
+ while steps < max_steps:
294
+ state = self.target.capture_state()
295
+ kind = next_boundary(state)
296
+ if (
297
+ kind is BoundaryKind.INSTRUCTION
298
+ and state.pc & 0xFFFFFF in self.breakpoints
299
+ and steps
300
+ ):
301
+ return result(StopReason.BREAKPOINT, state)
302
+ if stop_on_stop and kind is BoundaryKind.STOPPED_IDLE:
303
+ return result(StopReason.STOPPED, state)
304
+ if stop_on_halt and kind is BoundaryKind.HALTED_IDLE:
305
+ return result(StopReason.HALTED, state)
306
+ last = self.step()
307
+ steps += 1
308
+ cycles += last.cycles
309
+ if last.kind is BoundaryKind.INSTRUCTION:
310
+ instructions += 1
311
+ hits = self._watch_hits(last)
312
+ if hits:
313
+ return result(StopReason.WATCHPOINT, last.after, hits=hits)
314
+ if max_cycles is not None and cycles >= max_cycles:
315
+ return result(StopReason.CYCLE_LIMIT, last.after)
316
+ return result(StopReason.STEP_LIMIT, last.after if last else self.target.capture_state())
317
+
318
+ def _watch_hits(self, record: StepRecord) -> tuple[Access, ...]:
319
+ if not self.watchpoints or not record.accesses:
320
+ return ()
321
+ watched = self.watchpoints
322
+ return tuple(
323
+ access
324
+ for access in record.accesses
325
+ if any(
326
+ (access[1] + offset) in watched and access[0] in watched[access[1] + offset]
327
+ for offset in range(access[3])
328
+ )
329
+ )
330
+
331
+
332
+ def _is_access(access: object) -> bool:
333
+ if type(access) is not tuple or len(access) != 4:
334
+ return False
335
+ kind, address, value, size = access
336
+ return (
337
+ kind in ("r", "w")
338
+ and type(address) is int
339
+ and 0 <= address <= 0xFFFFFF
340
+ and type(value) is int
341
+ and value >= 0
342
+ and size in (1, 2)
343
+ )
344
+
345
+
346
+ def _address(address: int) -> int:
347
+ if type(address) is not int or not 0 <= address <= 0xFFFFFF:
348
+ raise ValueError("address must be an integer in range 0x000000..0xFFFFFF")
349
+ return address
350
+
351
+
352
+ __all__ = [
353
+ "Access",
354
+ "BoundaryKind",
355
+ "DebugSession",
356
+ "DebugTarget",
357
+ "RunResult",
358
+ "StepRecord",
359
+ "StopReason",
360
+ "next_boundary",
361
+ ]