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.
@@ -0,0 +1,796 @@
1
+ """Conformance kit: run a manifest on the reference core, or diff a foreign trace against it.
2
+
3
+ A **manifest** is a small JSON document that fully determines a run: what is
4
+ in memory, the initial processor state, how the host answers the interrupt
5
+ acknowledge cycle, where the bus asserts BERR, which reads come from a
6
+ recorded device stream, when the interrupt level changes, and when to stop.
7
+ Two cores given the same manifest see the same machine, so any difference
8
+ between their traces is a difference between the CPUs. The trace format
9
+ itself is docs/trace-schema.md; this module is the part that makes traces
10
+ comparable (docs/conformance.md).
11
+
12
+ Two entry points, also exposed as ``python -m m68000_python.conformance``:
13
+
14
+ * :func:`trace_manifest` runs the manifest on :class:`M68000CPU` and yields
15
+ one :class:`StepRecord` per boundary, bus accesses included: the
16
+ reference trace.
17
+ * :func:`diff_manifest` runs the same manifest in lockstep against an
18
+ external trace and returns the first :class:`TraceDivergence`, or ``None``.
19
+
20
+ The module depends only on the standard library and the rest of this package.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import json
27
+ import sys
28
+ from collections.abc import Callable, Iterable, Iterator
29
+ from dataclasses import dataclass, field, fields, replace
30
+ from pathlib import Path
31
+ from typing import TextIO
32
+
33
+ from m68000_python._core import AUTOVECTOR, SPURIOUS, BusError
34
+ from m68000_python.cpu import M68000CPU
35
+ from m68000_python.debug import BoundaryKind, DebugSession, StepRecord, next_boundary
36
+ from m68000_python.state import CPUState
37
+ from m68000_python.trace import TraceDivergence, first_trace_divergence, read_trace, write_trace
38
+
39
+ __all__ = [
40
+ "MANIFEST_SCHEMA_VERSION",
41
+ "MEMORY_SIZE",
42
+ "AddressRange",
43
+ "ConformanceHost",
44
+ "Event",
45
+ "Manifest",
46
+ "MemorySegment",
47
+ "Replay",
48
+ "StopRule",
49
+ "TraceRun",
50
+ "diff_manifest",
51
+ "load_manifest",
52
+ "main",
53
+ "manifest_from_dict",
54
+ "manifest_to_dict",
55
+ "trace_manifest",
56
+ ]
57
+
58
+ MANIFEST_SCHEMA_VERSION = 1
59
+
60
+ #: The flat host's memory: the whole 24-bit address space the 68000 drives (UM 3.1).
61
+ MEMORY_SIZE = 0x1000000
62
+
63
+ #: Host profiles. ``flat``: 16 MiB of RAM. ``replay``: the same, plus device
64
+ #: windows whose reads come from a recorded stream, see :class:`ConformanceHost`.
65
+ HOST_PROFILES = ("flat", "replay")
66
+ EVENT_KINDS = ("ipl", "reset")
67
+ TAS_WRITES = ("write", "drop")
68
+ #: The acknowledge answers a manifest may name, and the core's value for each.
69
+ ACKNOWLEDGE_NAMES = {"autovector": AUTOVECTOR, "spurious": SPURIOUS}
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class MemorySegment:
74
+ """Bytes to place at ``address`` before the run starts."""
75
+
76
+ address: int
77
+ data: bytes
78
+
79
+ def __post_init__(self) -> None:
80
+ if type(self.address) is not int or not 0 <= self.address < MEMORY_SIZE:
81
+ raise ValueError("segment address must be an integer in range 0..0xFFFFFF")
82
+ if type(self.data) is not bytes or not self.data:
83
+ raise ValueError("segment data must be non-empty bytes")
84
+ if self.address + len(self.data) > MEMORY_SIZE:
85
+ raise ValueError("segment does not fit below 0x1000000")
86
+
87
+
88
+ @dataclass(frozen=True, slots=True)
89
+ class AddressRange:
90
+ """``length`` bytes from ``address``: a BERR range or a device window."""
91
+
92
+ address: int
93
+ length: int
94
+
95
+ def __post_init__(self) -> None:
96
+ if type(self.address) is not int or not 0 <= self.address < MEMORY_SIZE:
97
+ raise ValueError("range address must be an integer in range 0..0xFFFFFF")
98
+ if type(self.length) is not int or self.length <= 0:
99
+ raise ValueError("range length must be a positive integer")
100
+ if self.address + self.length > MEMORY_SIZE:
101
+ raise ValueError("range does not fit below 0x1000000")
102
+
103
+ def __contains__(self, address: object) -> bool:
104
+ return type(address) is int and self.address <= address < self.address + self.length
105
+
106
+
107
+ @dataclass(frozen=True, slots=True)
108
+ class Replay:
109
+ """The ``replay`` host's device windows and the recorded values their reads return.
110
+
111
+ ``reads`` is a sequence of big-endian 16-bit values, consumed one per
112
+ read inside a window, in the order the CPU makes the reads.
113
+ """
114
+
115
+ devices: tuple[AddressRange, ...]
116
+ reads: bytes
117
+
118
+ def __post_init__(self) -> None:
119
+ if type(self.devices) is not tuple or not all(
120
+ type(window) is AddressRange for window in self.devices
121
+ ):
122
+ raise ValueError("replay devices must be a tuple of AddressRange values")
123
+ if type(self.reads) is not bytes or len(self.reads) % 2:
124
+ raise ValueError("replay reads must be bytes holding whole 16-bit values")
125
+
126
+
127
+ @dataclass(frozen=True, slots=True)
128
+ class Event:
129
+ """A host action applied immediately before boundary ``at_step``.
130
+
131
+ Steps count every record (instruction, trace, interrupt, idle) from 0.
132
+ ``ipl`` sets the interrupt level through ``set_ipl(level)``, so the
133
+ boundary at ``at_step`` is the first that can observe it; ``reset`` calls
134
+ ``reset()``, which produces no record (its clocks land in ``clock``).
135
+ """
136
+
137
+ at_step: int
138
+ kind: str
139
+ level: int | None = None
140
+
141
+ def __post_init__(self) -> None:
142
+ if type(self.at_step) is not int or self.at_step < 0:
143
+ raise ValueError("event at_step must be a non-negative integer")
144
+ if self.kind not in EVENT_KINDS:
145
+ raise ValueError(f"event kind must be one of {EVENT_KINDS}, got {self.kind!r}")
146
+ if self.kind == "ipl":
147
+ if type(self.level) is not int or not 0 <= self.level <= 7:
148
+ raise ValueError("an ipl event's level must be an integer in range 0..7")
149
+ elif self.level is not None:
150
+ raise ValueError("a reset event has no level")
151
+
152
+
153
+ @dataclass(frozen=True, slots=True)
154
+ class StopRule:
155
+ """When the run ends. ``max_steps`` is mandatory so every run is finite.
156
+
157
+ The run stops *before* a boundary when the state's ``pc`` is one of
158
+ ``at_pc``; when the next boundary would be an idle one (halted, or inside
159
+ STOP with nothing able to end it) and no event remains (``on_idle``); or
160
+ when the step budget is spent. A stopped run's trace ends; the boundary
161
+ that would have followed is not recorded.
162
+ """
163
+
164
+ max_steps: int
165
+ on_idle: bool = True
166
+ at_pc: tuple[int, ...] = ()
167
+
168
+ def __post_init__(self) -> None:
169
+ if type(self.max_steps) is not int or self.max_steps <= 0:
170
+ raise ValueError("stop.max_steps must be a positive integer")
171
+ if type(self.on_idle) is not bool:
172
+ raise ValueError("stop.on_idle must be a bool")
173
+ if type(self.at_pc) is not tuple or not all(
174
+ type(pc) is int and 0 <= pc <= 0xFFFFFFFF for pc in self.at_pc
175
+ ):
176
+ raise ValueError("stop.at_pc must be a tuple of 32-bit addresses")
177
+
178
+
179
+ @dataclass(frozen=True, slots=True)
180
+ class Manifest:
181
+ """A complete, deterministic description of one conformance run.
182
+
183
+ ``initial`` is the state restored before the first boundary, its queue
184
+ already filled (see :func:`manifest_from_dict`); with ``reset`` the host
185
+ calls ``reset()`` instead and ``initial`` is unused. ``acknowledge`` is
186
+ ``(level, answer)`` pairs, the answer a vector number 0-255 or the core's
187
+ ``AUTOVECTOR`` or ``SPURIOUS``; unlisted levels autovector.
188
+ """
189
+
190
+ name: str
191
+ memory: tuple[MemorySegment, ...]
192
+ initial: CPUState
193
+ stop: StopRule
194
+ host: str = "flat"
195
+ reset: bool = False
196
+ acknowledge: tuple[tuple[int, int], ...] = ()
197
+ bus_error: tuple[AddressRange, ...] = ()
198
+ tas_write: str = "write"
199
+ replay: Replay | None = None
200
+ events: tuple[Event, ...] = field(default_factory=tuple)
201
+
202
+ def __post_init__(self) -> None:
203
+ if type(self.name) is not str or not self.name:
204
+ raise ValueError("manifest name must be a non-empty string")
205
+ if type(self.memory) is not tuple or not all(
206
+ type(segment) is MemorySegment for segment in self.memory
207
+ ):
208
+ raise ValueError("manifest memory must be a tuple of MemorySegment values")
209
+ if type(self.initial) is not CPUState:
210
+ raise ValueError("manifest initial must be a CPUState")
211
+ if type(self.stop) is not StopRule:
212
+ raise ValueError("manifest stop must be a StopRule")
213
+ if self.host not in HOST_PROFILES:
214
+ raise ValueError(f"manifest host must be one of {HOST_PROFILES}, got {self.host!r}")
215
+ if type(self.reset) is not bool:
216
+ raise ValueError("manifest reset must be a bool")
217
+ if type(self.acknowledge) is not tuple or not all(
218
+ type(pair) is tuple
219
+ and len(pair) == 2
220
+ and type(pair[0]) is int
221
+ and 1 <= pair[0] <= 7
222
+ and type(pair[1]) is int
223
+ and (0 <= pair[1] <= 0xFF or pair[1] in (AUTOVECTOR, SPURIOUS))
224
+ for pair in self.acknowledge
225
+ ):
226
+ raise ValueError("manifest acknowledge must be (level 1-7, answer) pairs")
227
+ levels = [level for level, _ in self.acknowledge]
228
+ if levels != sorted(set(levels)):
229
+ raise ValueError("manifest acknowledge levels must be distinct and ordered")
230
+ if type(self.bus_error) is not tuple or not all(
231
+ type(item) is AddressRange for item in self.bus_error
232
+ ):
233
+ raise ValueError("manifest bus_error must be a tuple of AddressRange values")
234
+ if self.tas_write not in TAS_WRITES:
235
+ raise ValueError(f"manifest tas_write must be one of {TAS_WRITES}")
236
+ if (self.host == "replay") != (self.replay is not None):
237
+ raise ValueError("a replay host needs replay, and only a replay host has it")
238
+ if self.replay is not None and type(self.replay) is not Replay:
239
+ raise ValueError("manifest replay must be a Replay")
240
+ if type(self.events) is not tuple or not all(type(event) is Event for event in self.events):
241
+ raise ValueError("manifest events must be a tuple of Event values")
242
+ steps = [event.at_step for event in self.events]
243
+ if steps != sorted(steps):
244
+ raise ValueError("manifest events must be ordered by at_step")
245
+
246
+
247
+ class ConformanceHost:
248
+ """The one host every conformance run uses, so hosts cannot differ between cores.
249
+
250
+ 16 MiB of RAM over the 24-bit address space, zero but for the manifest's
251
+ segments; reads return its bytes (words big-endian) and writes store
252
+ them. On every access, in this order:
253
+
254
+ * an address inside a ``bus_error`` range raises :class:`BusError` (a word
255
+ access is checked at its address only, which is even);
256
+ * with the ``replay`` profile, a read inside a device window takes the
257
+ next 16-bit value of the reads stream (a byte read its low 8 bits), or
258
+ 0xFFFF (0xFF) once the stream is exhausted, and a write inside one is
259
+ discarded;
260
+ * otherwise memory.
261
+
262
+ The interrupt acknowledge answers with the manifest's ``acknowledge``
263
+ map, autovectoring an unlisted level; ``tas_write: "drop"`` discards
264
+ TAS's write cycle (after the BERR check), as the Genesis bus does.
265
+ A port must implement exactly this to be comparable.
266
+ """
267
+
268
+ def __init__(self, manifest: Manifest) -> None:
269
+ self.memory = memory = bytearray(MEMORY_SIZE)
270
+ for segment in manifest.memory:
271
+ memory[segment.address : segment.address + len(segment.data)] = segment.data
272
+ self._reads = manifest.replay.reads if manifest.replay is not None else b""
273
+ self.reads_consumed = 0
274
+ read_byte, read_word, write_byte, write_word = self._bus(manifest)
275
+ answers = dict(manifest.acknowledge)
276
+ acknowledge: Callable[[int], int] | None = None
277
+ if answers:
278
+
279
+ def acknowledge(level: int) -> int:
280
+ return answers.get(level, AUTOVECTOR)
281
+
282
+ tas_write = None
283
+ if manifest.tas_write == "drop":
284
+ faults = self._faults(manifest.bus_error)
285
+
286
+ def tas_write(address: int, value: int) -> None:
287
+ if faults(address):
288
+ raise BusError
289
+ # The write cycle never completes: nothing reaches memory.
290
+
291
+ self.cpu = M68000CPU(
292
+ read_byte,
293
+ read_word,
294
+ write_byte,
295
+ write_word,
296
+ acknowledge=acknowledge,
297
+ tas_write=tas_write,
298
+ )
299
+ if manifest.reset:
300
+ self.cpu.reset()
301
+ else:
302
+ self.cpu.restore_state(manifest.initial)
303
+
304
+ def peek_word(self, address: int) -> int:
305
+ """Side-effect-free word read for disassembly: memory, never the reads stream."""
306
+ address &= 0xFFFFFF
307
+ return (self.memory[address] << 8) | self.memory[(address + 1) & 0xFFFFFF]
308
+
309
+ @staticmethod
310
+ def _faults(ranges: tuple[AddressRange, ...]) -> Callable[[int], bool]:
311
+ spans = tuple((item.address, item.address + item.length) for item in ranges)
312
+ return lambda address: any(start <= address < end for start, end in spans)
313
+
314
+ def _next_read(self) -> int:
315
+ index = self.reads_consumed * 2
316
+ if index >= len(self._reads):
317
+ return 0xFFFF
318
+ self.reads_consumed += 1
319
+ return (self._reads[index] << 8) | self._reads[index + 1]
320
+
321
+ def _bus(self, manifest: Manifest) -> tuple[Callable, Callable, Callable, Callable]:
322
+ memory = self.memory
323
+ if not manifest.bus_error and manifest.replay is None:
324
+ # The common case pays for nothing it does not use.
325
+ def read_word(address: int) -> int:
326
+ return (memory[address] << 8) | memory[address + 1]
327
+
328
+ def write_word(address: int, value: int) -> None:
329
+ memory[address] = value >> 8
330
+ memory[address + 1] = value & 0xFF
331
+
332
+ return memory.__getitem__, read_word, memory.__setitem__, write_word
333
+
334
+ faults = self._faults(manifest.bus_error)
335
+ devices = self._faults(manifest.replay.devices if manifest.replay else ())
336
+ next_read = self._next_read
337
+
338
+ def read_byte(address: int) -> int:
339
+ if faults(address):
340
+ raise BusError
341
+ if devices(address):
342
+ return next_read() & 0xFF
343
+ return memory[address]
344
+
345
+ def read_word(address: int) -> int:
346
+ if faults(address):
347
+ raise BusError
348
+ if devices(address):
349
+ return next_read()
350
+ return (memory[address] << 8) | memory[address + 1]
351
+
352
+ def write_byte(address: int, value: int) -> None:
353
+ if faults(address):
354
+ raise BusError
355
+ if not devices(address):
356
+ memory[address] = value
357
+
358
+ def write_word(address: int, value: int) -> None:
359
+ if faults(address):
360
+ raise BusError
361
+ if not devices(address):
362
+ memory[address] = value >> 8
363
+ memory[address + 1] = value & 0xFF
364
+
365
+ return read_byte, read_word, write_byte, write_word
366
+
367
+
368
+ @dataclass(frozen=True, slots=True)
369
+ class TraceRun:
370
+ """Why a reference run ended: records, the clocks they spent, and the reason.
371
+
372
+ ``clocks`` is the sum of the records' ``cycles``; a ``reset()`` (the
373
+ manifest's or an event's) is not a record and is not in it.
374
+ """
375
+
376
+ steps: int
377
+ clocks: int
378
+ reason: str
379
+
380
+
381
+ class _Stop:
382
+ """Why :func:`_boundaries` stopped; filled in when the generator ends."""
383
+
384
+ reason = "max_steps"
385
+
386
+
387
+ _IDLE = (BoundaryKind.HALTED_IDLE, BoundaryKind.STOPPED_IDLE)
388
+
389
+
390
+ def _boundaries(manifest: Manifest, host: ConformanceHost, stopped: _Stop) -> Iterator[int]:
391
+ """Yield the index of every boundary the run executes, in order.
392
+
393
+ Before each index the events due at it are applied and the stop checks
394
+ run in the reference order: events, ``at_pc``, ``on_idle``, then the
395
+ step budget. The caller performs the boundary itself.
396
+ """
397
+ events = list(manifest.events)
398
+ stop = manifest.stop
399
+ cpu = host.cpu
400
+ steps = 0
401
+ while steps < stop.max_steps:
402
+ while events and events[0].at_step == steps:
403
+ _apply_event(cpu, events.pop(0))
404
+ state = cpu.capture_state()
405
+ if state.pc in stop.at_pc:
406
+ stopped.reason = "at_pc"
407
+ return
408
+ if stop.on_idle and not events and next_boundary(state) in _IDLE:
409
+ stopped.reason = "idle"
410
+ return
411
+ yield steps
412
+ steps += 1
413
+ stopped.reason = "max_steps"
414
+
415
+
416
+ def _apply_event(cpu: M68000CPU, event: Event) -> None:
417
+ if event.kind == "ipl":
418
+ cpu.set_ipl(event.level)
419
+ else:
420
+ cpu.reset()
421
+
422
+
423
+ def trace_manifest(
424
+ manifest: Manifest, *, result: list[TraceRun] | None = None
425
+ ) -> Iterator[StepRecord]:
426
+ """Run ``manifest`` on the reference core, yielding one record per boundary.
427
+
428
+ Every record carries its bus accesses (a session with
429
+ ``track_accesses=True``). Records are produced lazily so a long run can
430
+ be written or compared without buffering. When the iterator is
431
+ exhausted, a :class:`TraceRun` is appended to ``result`` if one is given.
432
+ """
433
+ host = ConformanceHost(manifest)
434
+ session = DebugSession(host.cpu, peek_word=host.peek_word, history_limit=0, track_accesses=True)
435
+ stopped = _Stop()
436
+ steps = 0
437
+ for index in _boundaries(manifest, host, stopped):
438
+ yield session.step()
439
+ steps = index + 1
440
+ if result is not None:
441
+ result.append(TraceRun(steps, session.total_cycles, stopped.reason))
442
+
443
+
444
+ def diff_manifest(manifest: Manifest, external: Iterable[StepRecord]) -> TraceDivergence | None:
445
+ """Run the reference in lockstep against ``external`` and return the first divergence."""
446
+ return first_trace_divergence(trace_manifest(manifest), external)
447
+
448
+
449
+ # --- manifest serialization --------------------------------------------------
450
+
451
+ _STATE_FIELD_NAMES = tuple(item.name for item in fields(CPUState))
452
+ _MANIFEST_KEYS = {
453
+ *("version", "name", "host", "memory", "reset", "initial", "acknowledge"),
454
+ *("bus_error", "tas_write", "replay", "events", "stop"),
455
+ }
456
+
457
+
458
+ def manifest_to_dict(manifest: Manifest) -> dict[str, object]:
459
+ """Return the versioned JSON-compatible form of a manifest (data inline, as hex)."""
460
+ if type(manifest) is not Manifest:
461
+ raise TypeError("manifest must be a Manifest")
462
+ out: dict[str, object] = {
463
+ "version": MANIFEST_SCHEMA_VERSION,
464
+ "name": manifest.name,
465
+ "host": manifest.host,
466
+ "memory": [
467
+ {"address": segment.address, "data": segment.data.hex()} for segment in manifest.memory
468
+ ],
469
+ }
470
+ if manifest.reset:
471
+ out["reset"] = True
472
+ else:
473
+ out["initial"] = {
474
+ name: list(value) if isinstance(value, tuple) else value
475
+ for name in _STATE_FIELD_NAMES
476
+ for value in (getattr(manifest.initial, name),)
477
+ }
478
+ names = {value: name for name, value in ACKNOWLEDGE_NAMES.items()}
479
+ out["acknowledge"] = {
480
+ str(level): names.get(answer, answer) for level, answer in manifest.acknowledge
481
+ }
482
+ out["bus_error"] = [_range_to_dict(item) for item in manifest.bus_error]
483
+ out["tas_write"] = manifest.tas_write
484
+ if manifest.replay is not None:
485
+ out["replay"] = {
486
+ "devices": [_range_to_dict(item) for item in manifest.replay.devices],
487
+ "reads": {"data": manifest.replay.reads.hex()},
488
+ }
489
+ out["events"] = [
490
+ {"at_step": event.at_step, "kind": event.kind}
491
+ | ({"level": event.level} if event.kind == "ipl" else {})
492
+ for event in manifest.events
493
+ ]
494
+ out["stop"] = {
495
+ "max_steps": manifest.stop.max_steps,
496
+ "on_idle": manifest.stop.on_idle,
497
+ "at_pc": list(manifest.stop.at_pc),
498
+ }
499
+ return out
500
+
501
+
502
+ def manifest_from_dict(value: object, *, base_dir: Path | None = None) -> Manifest:
503
+ """Build a validated manifest from its JSON form.
504
+
505
+ ``initial`` may list any subset of CPUState fields; the rest take
506
+ CPUState's defaults. When it gives neither ``ir`` nor ``irc``, they are
507
+ read from the loaded memory at ``pc`` and ``pc + 2`` (not through the
508
+ bus: no BERR, no device read, no clocks); giving one without the other
509
+ is an error. ``reset: true`` and ``initial`` exclude each other. A
510
+ memory segment, and the replay reads, carry either ``data`` (hex) or
511
+ ``file`` (a path relative to ``base_dir``, with optional ``offset`` and
512
+ ``length``).
513
+ """
514
+ root = _object(value, "manifest")
515
+ _allowed(
516
+ root,
517
+ "manifest",
518
+ _MANIFEST_KEYS,
519
+ required={"version", "name", "memory", "stop"},
520
+ )
521
+ if root["version"] != MANIFEST_SCHEMA_VERSION:
522
+ raise ValueError(f"unsupported manifest version: {root['version']!r}")
523
+ memory = tuple(
524
+ _segment(_object(item, f"memory[{index}]"), index, base_dir)
525
+ for index, item in enumerate(_list(root["memory"], "memory"))
526
+ )
527
+ reset = root.get("reset", False)
528
+ if reset is True and "initial" in root:
529
+ raise ValueError("manifest reset and initial exclude each other")
530
+ initial = _initial(_object(root.get("initial", {}), "initial"), memory)
531
+ acknowledge = _acknowledge(_object(root.get("acknowledge", {}), "acknowledge"))
532
+ bus_error = tuple(
533
+ _range(_object(item, f"bus_error[{index}]"), f"bus_error[{index}]")
534
+ for index, item in enumerate(_list(root.get("bus_error", []), "bus_error"))
535
+ )
536
+ replay = None
537
+ if "replay" in root:
538
+ replay = _replay(_object(root["replay"], "replay"), base_dir)
539
+ stop_dict = _object(root["stop"], "stop")
540
+ _allowed(stop_dict, "stop", {"max_steps", "on_idle", "at_pc"}, required={"max_steps"})
541
+ at_pc = _list(stop_dict.get("at_pc", []), "stop.at_pc")
542
+ events = tuple(
543
+ _event(_object(item, f"events[{index}]"), index)
544
+ for index, item in enumerate(_list(root.get("events", []), "events"))
545
+ )
546
+ try:
547
+ return Manifest(
548
+ name=root["name"],
549
+ memory=memory,
550
+ initial=initial,
551
+ stop=StopRule(
552
+ max_steps=stop_dict["max_steps"],
553
+ on_idle=stop_dict.get("on_idle", True),
554
+ at_pc=tuple(at_pc),
555
+ ),
556
+ host=root.get("host", "flat"),
557
+ reset=reset,
558
+ acknowledge=acknowledge,
559
+ bus_error=bus_error,
560
+ tas_write=root.get("tas_write", "write"),
561
+ replay=replay,
562
+ events=events,
563
+ )
564
+ except (TypeError, ValueError) as exc:
565
+ raise ValueError(f"invalid manifest: {exc}") from exc
566
+
567
+
568
+ def load_manifest(path: str | Path) -> Manifest:
569
+ """Read and validate a manifest file; ``file`` entries resolve beside it."""
570
+ path = Path(path)
571
+ with path.open(encoding="utf-8") as handle:
572
+ try:
573
+ value = json.load(handle)
574
+ except json.JSONDecodeError as exc:
575
+ raise ValueError(f"{path}: not valid JSON: {exc}") from exc
576
+ return manifest_from_dict(value, base_dir=path.parent)
577
+
578
+
579
+ def _initial(item: dict[str, object], memory: tuple[MemorySegment, ...]) -> CPUState:
580
+ _allowed(item, "initial", set(_STATE_FIELD_NAMES), required=set())
581
+ if ("ir" in item) != ("irc" in item):
582
+ raise ValueError("initial must give both ir and irc, or neither")
583
+ values = {key: tuple(value) if type(value) is list else value for key, value in item.items()}
584
+ try:
585
+ state = CPUState(**values)
586
+ except (TypeError, ValueError) as exc:
587
+ raise ValueError(f"invalid initial state: {exc}") from exc
588
+ if "ir" in item:
589
+ return state
590
+
591
+ def byte(address: int) -> int:
592
+ value = 0 # a later segment overwrites an earlier one, as loading does
593
+ for segment in memory:
594
+ if segment.address <= address < segment.address + len(segment.data):
595
+ value = segment.data[address - segment.address]
596
+ return value
597
+
598
+ def word(address: int) -> int:
599
+ address &= 0xFFFFFF
600
+ return (byte(address) << 8) | byte((address + 1) & 0xFFFFFF)
601
+
602
+ return replace(state, ir=word(state.pc), irc=word(state.pc + 2))
603
+
604
+
605
+ def _acknowledge(item: dict[str, object]) -> tuple[tuple[int, int], ...]:
606
+ pairs = []
607
+ for key, answer in item.items():
608
+ if key not in tuple("1234567"):
609
+ raise ValueError(f"acknowledge keys are the levels '1'..'7', got {key!r}")
610
+ if type(answer) is str:
611
+ if answer not in ACKNOWLEDGE_NAMES:
612
+ raise ValueError(f"acknowledge answers are 0-255, {sorted(ACKNOWLEDGE_NAMES)}")
613
+ answer = ACKNOWLEDGE_NAMES[answer]
614
+ elif type(answer) is not int or not 0 <= answer <= 0xFF:
615
+ raise ValueError(f"acknowledge answers are 0-255, {sorted(ACKNOWLEDGE_NAMES)}")
616
+ pairs.append((int(key), answer))
617
+ return tuple(sorted(pairs))
618
+
619
+
620
+ def _range(item: dict[str, object], name: str) -> AddressRange:
621
+ _allowed(item, name, {"address", "length"}, required={"address", "length"})
622
+ try:
623
+ return AddressRange(item["address"], item["length"])
624
+ except (TypeError, ValueError) as exc:
625
+ raise ValueError(f"{name}: {exc}") from exc
626
+
627
+
628
+ def _range_to_dict(item: AddressRange) -> dict[str, int]:
629
+ return {"address": item.address, "length": item.length}
630
+
631
+
632
+ def _replay(item: dict[str, object], base_dir: Path | None) -> Replay:
633
+ _allowed(item, "replay", {"devices", "reads"}, required={"devices", "reads"})
634
+ devices = tuple(
635
+ _range(_object(window, f"replay.devices[{index}]"), f"replay.devices[{index}]")
636
+ for index, window in enumerate(_list(item["devices"], "replay.devices"))
637
+ )
638
+ reads_dict = _object(item["reads"], "replay.reads")
639
+ reads = _bytes(reads_dict, "replay.reads", base_dir, allow_empty=True)
640
+ try:
641
+ return Replay(devices, reads)
642
+ except (TypeError, ValueError) as exc:
643
+ raise ValueError(f"replay: {exc}") from exc
644
+
645
+
646
+ def _segment(item: dict[str, object], index: int, base_dir: Path | None) -> MemorySegment:
647
+ name = f"memory[{index}]"
648
+ if "address" not in item:
649
+ raise ValueError(f"{name} fields do not match schema (missing=['address'])")
650
+ rest = {key: value for key, value in item.items() if key != "address"}
651
+ data = _bytes(rest, name, base_dir, allow_empty=False)
652
+ try:
653
+ return MemorySegment(item["address"], data)
654
+ except (TypeError, ValueError) as exc:
655
+ raise ValueError(f"{name}: {exc}") from exc
656
+
657
+
658
+ def _bytes(
659
+ item: dict[str, object], name: str, base_dir: Path | None, *, allow_empty: bool
660
+ ) -> bytes:
661
+ """The bytes of a ``data`` (hex) or ``file`` (+ ``offset``, ``length``) entry."""
662
+ _allowed(item, name, {"data", "file", "offset", "length"}, required=set())
663
+ if ("data" in item) == ("file" in item):
664
+ raise ValueError(f"{name} must have exactly one of 'data' or 'file'")
665
+ if "data" in item:
666
+ if type(item["data"]) is not str:
667
+ raise ValueError(f"{name}.data must be a hexadecimal string")
668
+ try:
669
+ data = bytes.fromhex(item["data"])
670
+ except ValueError as exc:
671
+ raise ValueError(f"{name}.data must be a hexadecimal string") from exc
672
+ if "offset" in item or "length" in item:
673
+ raise ValueError(f"{name}: offset and length go with file")
674
+ else:
675
+ if type(item["file"]) is not str:
676
+ raise ValueError(f"{name}.file must be a path string")
677
+ file_path = Path(item["file"])
678
+ if not file_path.is_absolute():
679
+ file_path = (base_dir or Path.cwd()) / file_path
680
+ data = file_path.read_bytes()
681
+ offset = item.get("offset", 0)
682
+ length = item.get("length", len(data) - offset)
683
+ if type(offset) is not int or type(length) is not int or offset < 0 or length < 0:
684
+ raise ValueError(f"{name}.offset/length must be non-negative integers")
685
+ data = data[offset : offset + length]
686
+ if not data and not allow_empty:
687
+ raise ValueError(f"{name}: segment data must be non-empty bytes")
688
+ return data
689
+
690
+
691
+ def _event(item: dict[str, object], index: int) -> Event:
692
+ name = f"events[{index}]"
693
+ _allowed(item, name, {"at_step", "kind", "level"}, required={"at_step", "kind"})
694
+ try:
695
+ return Event(item["at_step"], item["kind"], item.get("level"))
696
+ except (TypeError, ValueError) as exc:
697
+ raise ValueError(f"{name}: {exc}") from exc
698
+
699
+
700
+ def _object(value: object, name: str) -> dict[str, object]:
701
+ if type(value) is not dict or not all(type(key) is str for key in value):
702
+ raise ValueError(f"{name} must be an object with string keys")
703
+ return value
704
+
705
+
706
+ def _list(value: object, name: str) -> list[object]:
707
+ if type(value) is not list:
708
+ raise ValueError(f"{name} must be a list")
709
+ return value
710
+
711
+
712
+ def _allowed(value: dict[str, object], name: str, keys: set[str], *, required: set[str]) -> None:
713
+ unknown = sorted(set(value) - keys)
714
+ missing = sorted(required - set(value))
715
+ if unknown or missing:
716
+ details = []
717
+ if missing:
718
+ details.append(f"missing={missing}")
719
+ if unknown:
720
+ details.append(f"unknown={unknown}")
721
+ raise ValueError(f"{name} fields do not match schema ({', '.join(details)})")
722
+
723
+
724
+ # --- command line --------------------------------------------------------------
725
+
726
+
727
+ def main(argv: list[str] | None = None, *, stdout: TextIO | None = None) -> int:
728
+ """``trace`` writes the reference trace; ``diff`` reports the first divergence.
729
+
730
+ Exit status: 0 on success or equal traces, 1 on divergence, 2 on bad input.
731
+ """
732
+ out = stdout or sys.stdout
733
+ parser = argparse.ArgumentParser(
734
+ prog="python -m m68000_python.conformance",
735
+ description="Run a conformance manifest on the reference core or diff a trace against it.",
736
+ )
737
+ commands = parser.add_subparsers(dest="command", required=True)
738
+ trace_cmd = commands.add_parser("trace", help="write the reference trace for a manifest")
739
+ trace_cmd.add_argument("manifest")
740
+ trace_cmd.add_argument("--out", help="JSON Lines output path (default: stdout)")
741
+ diff_cmd = commands.add_parser("diff", help="compare an external trace against the reference")
742
+ diff_cmd.add_argument("manifest")
743
+ diff_cmd.add_argument("trace", help="JSON Lines trace path, or '-' for stdin")
744
+ args = parser.parse_args(argv)
745
+
746
+ try:
747
+ manifest = load_manifest(args.manifest)
748
+ except (OSError, ValueError) as exc:
749
+ print(f"error: {exc}", file=out)
750
+ return 2
751
+
752
+ if args.command == "trace":
753
+ result: list[TraceRun] = []
754
+ records = trace_manifest(manifest, result=result)
755
+ if args.out:
756
+ with open(args.out, "w", encoding="utf-8") as handle:
757
+ count = write_trace(records, handle)
758
+ else:
759
+ count = write_trace(records, out)
760
+ run = result[0]
761
+ print(
762
+ f"{manifest.name}: {count} records, {run.clocks} clocks, stopped on {run.reason}",
763
+ file=sys.stderr if not args.out else out,
764
+ )
765
+ return 0
766
+
767
+ try:
768
+ if args.trace == "-":
769
+ divergence = diff_manifest(manifest, read_trace(sys.stdin))
770
+ else:
771
+ with open(args.trace, encoding="utf-8") as handle:
772
+ divergence = diff_manifest(manifest, read_trace(handle))
773
+ except (OSError, ValueError) as exc:
774
+ print(f"error: {exc}", file=out)
775
+ return 2
776
+ if divergence is None:
777
+ print(f"{manifest.name}: traces are identical", file=out)
778
+ return 0
779
+ record = divergence.left or divergence.right
780
+ where = "(end of trace)"
781
+ if record is not None:
782
+ if record.instruction is not None:
783
+ where = f"{record.instruction.address:06X}: {record.instruction.text}"
784
+ else:
785
+ where = record.kind.value
786
+ print(f"{manifest.name}: divergence at position {divergence.position}, {where}", file=out)
787
+ for difference in divergence.differences:
788
+ print(
789
+ f" {difference.path}: reference={difference.left!r} external={difference.right!r}",
790
+ file=out,
791
+ )
792
+ return 1
793
+
794
+
795
+ if __name__ == "__main__":
796
+ sys.exit(main())