@zx-vibes/machine 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/package.json +61 -0
- package/src/index.d.mts +8 -0
- package/src/index.mjs +79 -0
- package/src/interrupt.d.mts +26 -0
- package/src/interrupt.mjs +111 -0
- package/src/machine.d.mts +82 -0
- package/src/machine.mjs +242 -0
- package/src/snapshot-z80.d.mts +39 -0
- package/src/snapshot-z80.mjs +164 -0
- package/src/tap-format.d.mts +7 -0
- package/src/tap-format.mjs +74 -0
- package/src/tape-edge-load.d.mts +43 -0
- package/src/tape-edge-load.mjs +221 -0
- package/src/tape-pulses.d.mts +9 -0
- package/src/tape-pulses.mjs +60 -0
- package/src/tzx-format.d.mts +13 -0
- package/src/tzx-format.mjs +268 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alvaro Mateos
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# @zx-vibes/machine
|
|
2
|
+
|
|
3
|
+
48K ZX Spectrum machine layer for the zx-vibes toolchain.
|
|
4
|
+
|
|
5
|
+
Current package version in this repository: `0.1.0`.
|
|
6
|
+
|
|
7
|
+
Joins `@zx-vibes/cpu` and `@zx-vibes/ula` into a runnable 48K machine with
|
|
8
|
+
interrupt acceptance, per-access memory contention, `.z80` snapshot
|
|
9
|
+
read/write, `.tap`/`.tzx` parsing and serialization, and tape loading (edge
|
|
10
|
+
and instant). It is regenerated from the project DNA and decided by the
|
|
11
|
+
machine conformance fixtures. This is the core the `@zx-vibes/toolkit` CLI
|
|
12
|
+
and MCP server run on.
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```js
|
|
17
|
+
import { createMachine, readZ80, writeZ80, parseTap } from "@zx-vibes/machine";
|
|
18
|
+
|
|
19
|
+
// memory is a caller-provided 64 KB address space with the 16 KB 48K ROM
|
|
20
|
+
// mapped at 0x0000 (the ROM is not bundled with this package)
|
|
21
|
+
const machine = createMachine({ memory });
|
|
22
|
+
machine.runFrame();
|
|
23
|
+
machine.stepInstruction();
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`@zx-vibes/machine/interrupt` and `@zx-vibes/machine/machine` expose those
|
|
27
|
+
modules directly. Type declarations are generated from the source JSDoc
|
|
28
|
+
(`.d.mts` alongside each module).
|
|
29
|
+
|
|
30
|
+
The package does not ship the ZX Spectrum ROM; supply your own copy (the
|
|
31
|
+
toolkit ships one under its Amstrad redistribution notice).
|
|
32
|
+
|
|
33
|
+
## Testing
|
|
34
|
+
|
|
35
|
+
Tests are repo-only: the package is exercised by the `dna/conformance` fixture
|
|
36
|
+
suites in the [zx-vibes monorepo](https://github.com/Alvaromah/zx-vibes)
|
|
37
|
+
(`npm test` inside a published tarball is unsupported).
|
|
38
|
+
|
|
39
|
+
## License
|
|
40
|
+
|
|
41
|
+
MIT. See `LICENSE`.
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zx-vibes/machine",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Regenerated 48K ZX Spectrum machine layer for zx-vibes: joins @zx-vibes/cpu and @zx-vibes/ula with interrupt acceptance and per-access memory contention, derived from the DNA and decided by the machine conformance fixtures.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Alvaro Mateos",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Alvaromah/zx-vibes.git",
|
|
10
|
+
"directory": "packages/machine"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/Alvaromah/zx-vibes#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/Alvaromah/zx-vibes/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"zx-spectrum",
|
|
18
|
+
"z80",
|
|
19
|
+
"emulator",
|
|
20
|
+
"emulation",
|
|
21
|
+
"snapshot",
|
|
22
|
+
"tape"
|
|
23
|
+
],
|
|
24
|
+
"type": "module",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
},
|
|
28
|
+
"main": "src/index.mjs",
|
|
29
|
+
"types": "src/index.d.mts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./src/index.d.mts",
|
|
33
|
+
"default": "./src/index.mjs"
|
|
34
|
+
},
|
|
35
|
+
"./interrupt": {
|
|
36
|
+
"types": "./src/interrupt.d.mts",
|
|
37
|
+
"default": "./src/interrupt.mjs"
|
|
38
|
+
},
|
|
39
|
+
"./machine": {
|
|
40
|
+
"types": "./src/machine.d.mts",
|
|
41
|
+
"default": "./src/machine.mjs"
|
|
42
|
+
},
|
|
43
|
+
"./package.json": "./package.json"
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"src",
|
|
47
|
+
"README.md"
|
|
48
|
+
],
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@zx-vibes/cpu": "0.1.0",
|
|
51
|
+
"@zx-vibes/ula": "0.1.0"
|
|
52
|
+
},
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"test": "node ../../dna/conformance/machine/run-machine-fixtures.mjs",
|
|
58
|
+
"lint": "eslint src",
|
|
59
|
+
"types:gen": "tsc src/index.mjs --allowJs --declaration --emitDeclarationOnly --outDir src"
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/index.d.mts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { Machine, createMachine, RESET_REGISTERS } from "./machine.mjs";
|
|
2
|
+
export { acceptInterrupt, acceptNmi, INT_DATA_BUS, IM01_T_STATES, IM2_T_STATES, NMI_VECTOR, NMI_T_STATES } from "./interrupt.mjs";
|
|
3
|
+
export { readZ80, writeZ80, compressZ80, decompressZ80 } from "./snapshot-z80.mjs";
|
|
4
|
+
export { tapChecksum, parseTap, serializeTap } from "./tap-format.mjs";
|
|
5
|
+
export { parseTzx, serializeTzx, TZX_SIGNATURE, TZX_VERSION } from "./tzx-format.mjs";
|
|
6
|
+
export { blockToPulses, bytePulses, PILOT_PULSE_T, PILOT_PULSES_HEADER, PILOT_PULSES_DATA, SYNC1_T, SYNC2_T, BIT0_PULSE_T, BIT1_PULSE_T } from "./tape-pulses.mjs";
|
|
7
|
+
export { createTapeDeck, edgeLoad, edgeLoadWithDeck, instantLoad, LD_BYTES_ENTRY } from "./tape-edge-load.mjs";
|
|
8
|
+
export { displayByteAddress, displayLineAddress, attributeAddress, DISPLAY_FILE_BASE, DISPLAY_FILE_END, DISPLAY_FILE_SIZE, ATTR_FILE_BASE, ATTR_FILE_END, ATTR_FILE_SIZE, portFloats, floatingBusByte, activeDisplayFetch, FLOATING_BUS_IDLE, kempstonByte, kempstonDecodes, KEMPSTON_PORT, KEMPSTON_RIGHT, KEMPSTON_LEFT, KEMPSTON_DOWN, KEMPSTON_UP, KEMPSTON_FIRE } from "@zx-vibes/ula";
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// @zx-vibes/machine — regenerated 48K ZX Spectrum machine layer.
|
|
2
|
+
//
|
|
3
|
+
// Joins the regenerated CPU (@zx-vibes/cpu) and ULA timing model (@zx-vibes/ula)
|
|
4
|
+
// into a running machine: interrupt acceptance at instruction boundaries and
|
|
5
|
+
// per-access memory contention threaded onto the executed stream. Authored from
|
|
6
|
+
// the project DNA (dna/domain/machine-execution.md) and decided by the machine
|
|
7
|
+
// conformance fixtures (dna/conformance/machine/*.json).
|
|
8
|
+
export { Machine, createMachine, RESET_REGISTERS } from "./machine.mjs";
|
|
9
|
+
export { acceptInterrupt, acceptNmi, INT_DATA_BUS, IM01_T_STATES, IM2_T_STATES, NMI_VECTOR, NMI_T_STATES } from "./interrupt.mjs";
|
|
10
|
+
export { readZ80, writeZ80, compressZ80, decompressZ80 } from "./snapshot-z80.mjs";
|
|
11
|
+
// `.tap` tape-image codec (dna/domain/file-formats.md FMT-TAP-*) — tape, like a
|
|
12
|
+
// snapshot, is a file the machine loads, so it lives beside the .z80 codec.
|
|
13
|
+
export { tapChecksum, parseTap, serializeTap } from "./tap-format.mjs";
|
|
14
|
+
// `.tzx` tape-image codec (dna/domain/file-formats.md FMT-TZX-*) — the versioned,
|
|
15
|
+
// pulse-level tape archive, beside the .tap codec.
|
|
16
|
+
export { parseTzx, serializeTzx, TZX_SIGNATURE, TZX_VERSION } from "./tzx-format.mjs";
|
|
17
|
+
// ROM tape encoding (dna/domain/tape-loading.md TAPE-PULSE-*) — a block body becomes
|
|
18
|
+
// the EAR pulse stream (pilot/sync/bit pulses) the real ROM LD-BYTES reads.
|
|
19
|
+
export {
|
|
20
|
+
blockToPulses,
|
|
21
|
+
bytePulses,
|
|
22
|
+
PILOT_PULSE_T,
|
|
23
|
+
PILOT_PULSES_HEADER,
|
|
24
|
+
PILOT_PULSES_DATA,
|
|
25
|
+
SYNC1_T,
|
|
26
|
+
SYNC2_T,
|
|
27
|
+
BIT0_PULSE_T,
|
|
28
|
+
BIT1_PULSE_T,
|
|
29
|
+
} from "./tape-pulses.mjs";
|
|
30
|
+
// ROM tape edge-loading (dna/domain/tape-loading.md "Edge loading" TAPE-EDGE-*) — the
|
|
31
|
+
// opaque ROM LD-BYTES (0x0556) consumes the EAR pulse stream on port 0xFE b6 and loads a
|
|
32
|
+
// block byte-for-byte into RAM. The tape deck implements the machine io contract.
|
|
33
|
+
// `instantLoad` is the instant/trap counterpart: it reproduces the same observable result
|
|
34
|
+
// without running the ROM (its correctness is `instant == edge` for the same block).
|
|
35
|
+
export {
|
|
36
|
+
createTapeDeck,
|
|
37
|
+
edgeLoad,
|
|
38
|
+
edgeLoadWithDeck,
|
|
39
|
+
instantLoad,
|
|
40
|
+
LD_BYTES_ENTRY,
|
|
41
|
+
} from "./tape-edge-load.mjs";
|
|
42
|
+
// Screen-address decode lives in @zx-vibes/ula; re-export it from the integrated
|
|
43
|
+
// machine entry so a machine consumer can map a screen pixel to its display/attribute
|
|
44
|
+
// byte (dna/domain/memory-map.md, MM-SCREEN-ADDR-001 / MM-ATTR-ADDR-001).
|
|
45
|
+
export {
|
|
46
|
+
displayByteAddress,
|
|
47
|
+
displayLineAddress,
|
|
48
|
+
attributeAddress,
|
|
49
|
+
DISPLAY_FILE_BASE,
|
|
50
|
+
DISPLAY_FILE_END,
|
|
51
|
+
DISPLAY_FILE_SIZE,
|
|
52
|
+
ATTR_FILE_BASE,
|
|
53
|
+
ATTR_FILE_END,
|
|
54
|
+
ATTR_FILE_SIZE,
|
|
55
|
+
} from "@zx-vibes/ula";
|
|
56
|
+
// Floating-bus read model also lives in @zx-vibes/ula; re-export it from the
|
|
57
|
+
// integrated machine entry so a machine consumer can resolve an IN from an
|
|
58
|
+
// undriven (odd) port (dna/domain/ula-timing.md "Floating bus", ULA-FLOATBUS-*;
|
|
59
|
+
// scoped per ADR-0026 — the in-window byte is deferred with A5, never fabricated).
|
|
60
|
+
export {
|
|
61
|
+
portFloats,
|
|
62
|
+
floatingBusByte,
|
|
63
|
+
activeDisplayFetch,
|
|
64
|
+
FLOATING_BUS_IDLE,
|
|
65
|
+
} from "@zx-vibes/ula";
|
|
66
|
+
// Kempston joystick read model also lives in @zx-vibes/ula (beside the floating-bus
|
|
67
|
+
// model — the Kempston is what carves port 0x1F out of the floating odd-port set);
|
|
68
|
+
// re-export it from the integrated machine entry so a machine consumer can resolve an
|
|
69
|
+
// IN from the joystick port (dna/domain/peripherals.md, JOY-KEMPSTON-*; ADR-0021).
|
|
70
|
+
export {
|
|
71
|
+
kempstonByte,
|
|
72
|
+
kempstonDecodes,
|
|
73
|
+
KEMPSTON_PORT,
|
|
74
|
+
KEMPSTON_RIGHT,
|
|
75
|
+
KEMPSTON_LEFT,
|
|
76
|
+
KEMPSTON_DOWN,
|
|
77
|
+
KEMPSTON_UP,
|
|
78
|
+
KEMPSTON_FIRE,
|
|
79
|
+
} from "@zx-vibes/ula";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function acceptInterrupt({ registers, memory, halted, dataBus }: {
|
|
2
|
+
registers: any;
|
|
3
|
+
memory: any;
|
|
4
|
+
halted?: boolean;
|
|
5
|
+
dataBus?: number;
|
|
6
|
+
}): {
|
|
7
|
+
registers: any;
|
|
8
|
+
tStates: number;
|
|
9
|
+
accepted: boolean;
|
|
10
|
+
halted: boolean;
|
|
11
|
+
};
|
|
12
|
+
export function acceptNmi({ registers, memory, halted }: {
|
|
13
|
+
registers: any;
|
|
14
|
+
memory: any;
|
|
15
|
+
halted?: boolean;
|
|
16
|
+
}): {
|
|
17
|
+
registers: any;
|
|
18
|
+
tStates: number;
|
|
19
|
+
accepted: boolean;
|
|
20
|
+
halted: boolean;
|
|
21
|
+
};
|
|
22
|
+
export const INT_DATA_BUS: 255;
|
|
23
|
+
export const IM01_T_STATES: 13;
|
|
24
|
+
export const IM2_T_STATES: 19;
|
|
25
|
+
export const NMI_VECTOR: 102;
|
|
26
|
+
export const NMI_T_STATES: 11;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Maskable interrupt acceptance for the 48K ZX Spectrum machine layer, authored
|
|
2
|
+
// from the project DNA (dna/domain/machine-execution.md) and decided by the
|
|
3
|
+
// machine conformance fixtures (dna/conformance/machine/interrupt-accept.json).
|
|
4
|
+
//
|
|
5
|
+
// This is the CPU's response to the ULA's once-per-frame INT, the part that lives
|
|
6
|
+
// at an instruction boundary and so is not expressible by the single-step CPU
|
|
7
|
+
// contract. The caller (the Machine frame loop) decides *whether* to accept
|
|
8
|
+
// (IFF1 set, INT asserted, not in the post-EI delay); this function performs the
|
|
9
|
+
// documented sequence once that decision is made.
|
|
10
|
+
|
|
11
|
+
// 48K interrupt-acknowledge data-bus value (floats high). Pins IM 0 to RST 38h
|
|
12
|
+
// and the IM 2 vector low byte to 0xFF. (MACHINE-INT-DATABUS-001, ADR-0011.)
|
|
13
|
+
export const INT_DATA_BUS = 0xff;
|
|
14
|
+
|
|
15
|
+
// Total instruction-acknowledge cost: IM 0 / IM 1 take 13 T, IM 2 takes 19 T.
|
|
16
|
+
export const IM01_T_STATES = 13;
|
|
17
|
+
export const IM2_T_STATES = 19;
|
|
18
|
+
|
|
19
|
+
// Non-maskable interrupt: fixed restart vector 0x0066, 11 T-states (a 5 T
|
|
20
|
+
// acknowledge M1 cycle plus the two 3 T stack writes). (MACHINE-NMI-ACCEPT-001.)
|
|
21
|
+
export const NMI_VECTOR = 0x0066;
|
|
22
|
+
export const NMI_T_STATES = 11;
|
|
23
|
+
|
|
24
|
+
// Accept a maskable interrupt against the given CPU state.
|
|
25
|
+
//
|
|
26
|
+
// acceptInterrupt({ registers, memory, halted?, dataBus? })
|
|
27
|
+
// -> { registers, tStates, accepted, halted }
|
|
28
|
+
//
|
|
29
|
+
// registers: the same plain register object the CPU step() uses (a,f,...,pc,sp,
|
|
30
|
+
// i,r,iff1,iff2,im,...). memory: Uint8Array(0x10000). halted: whether the CPU
|
|
31
|
+
// was in the HALT state at the boundary (so the return address skips the HALT).
|
|
32
|
+
// dataBus: the byte on the data bus during acknowledge (default 0xFF on 48K).
|
|
33
|
+
//
|
|
34
|
+
// Mutates and returns `registers` (clearing IFF1/IFF2, bumping R, moving SP/PC)
|
|
35
|
+
// and writes the pushed return address into `memory`. If IFF1 is clear the
|
|
36
|
+
// interrupt is masked: nothing changes, accepted=false, tStates=0.
|
|
37
|
+
export function acceptInterrupt({ registers, memory, halted = false, dataBus = INT_DATA_BUS }) {
|
|
38
|
+
const reg = registers;
|
|
39
|
+
if (!reg.iff1) {
|
|
40
|
+
return { registers: reg, tStates: 0, accepted: false, halted };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Leaving HALT: the frozen PC sits on the HALT opcode, so the return address is
|
|
44
|
+
// the instruction after it. (MACHINE-INT-ACCEPT-001 step 1.)
|
|
45
|
+
let pc = reg.pc & 0xffff;
|
|
46
|
+
if (halted) pc = (pc + 1) & 0xffff;
|
|
47
|
+
|
|
48
|
+
// Disable further interrupts; the acknowledge cycle bumps R (low 7 bits).
|
|
49
|
+
reg.iff1 = 0;
|
|
50
|
+
reg.iff2 = 0;
|
|
51
|
+
reg.r = (reg.r & 0x80) | ((reg.r + 1) & 0x7f);
|
|
52
|
+
|
|
53
|
+
// Push the return address, high byte first.
|
|
54
|
+
const sp = (reg.sp - 2) & 0xffff;
|
|
55
|
+
memory[sp] = pc & 0xff;
|
|
56
|
+
memory[(sp + 1) & 0xffff] = (pc >> 8) & 0xff;
|
|
57
|
+
reg.sp = sp;
|
|
58
|
+
|
|
59
|
+
let tStates;
|
|
60
|
+
if (reg.im === 2) {
|
|
61
|
+
const vector = ((reg.i & 0xff) << 8) | (dataBus & 0xff);
|
|
62
|
+
reg.pc = (memory[vector & 0xffff] | (memory[(vector + 1) & 0xffff] << 8)) & 0xffff;
|
|
63
|
+
tStates = IM2_T_STATES;
|
|
64
|
+
} else {
|
|
65
|
+
// IM 0 (data bus = 0xFF = RST 38h on 48K) and IM 1 both vector to 0x0038.
|
|
66
|
+
reg.pc = 0x0038;
|
|
67
|
+
tStates = IM01_T_STATES;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { registers: reg, tStates, accepted: true, halted: false };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Accept a non-maskable interrupt against the given CPU state.
|
|
74
|
+
//
|
|
75
|
+
// acceptNmi({ registers, memory, halted? })
|
|
76
|
+
// -> { registers, tStates, accepted, halted }
|
|
77
|
+
//
|
|
78
|
+
// NMI is edge-triggered and NON-maskable: unlike acceptInterrupt it ignores
|
|
79
|
+
// IFF1, so it always accepts (there is no masked no-accept case). It clears IFF1
|
|
80
|
+
// but PRESERVES IFF2 (which holds the pre-NMI IFF1, so a later RETN restores the
|
|
81
|
+
// maskable-interrupt enable state), bumps R, leaves HALT with the post-HALT
|
|
82
|
+
// return address, pushes PC high-byte first, and vectors to 0x0066 in 11 T.
|
|
83
|
+
// (dna/domain/machine-execution.md MACHINE-NMI-SAMPLE-001 / -ACCEPT-001 /
|
|
84
|
+
// -RETN-001; decided by dna/conformance/machine/nmi-accept.json.)
|
|
85
|
+
//
|
|
86
|
+
// Mutates and returns `registers` (clearing IFF1, bumping R, moving SP/PC) and
|
|
87
|
+
// writes the pushed return address into `memory`; IFF2 and IM are untouched.
|
|
88
|
+
export function acceptNmi({ registers, memory, halted = false }) {
|
|
89
|
+
const reg = registers;
|
|
90
|
+
|
|
91
|
+
// Leaving HALT: the frozen PC sits on the HALT opcode, so the return address is
|
|
92
|
+
// the instruction after it. (MACHINE-NMI-ACCEPT-001 step 1.)
|
|
93
|
+
let pc = reg.pc & 0xffff;
|
|
94
|
+
if (halted) pc = (pc + 1) & 0xffff;
|
|
95
|
+
|
|
96
|
+
// Disable maskable interrupts but PRESERVE IFF2 (so RETN can restore IFF1);
|
|
97
|
+
// the acknowledge cycle bumps R (low 7 bits).
|
|
98
|
+
reg.iff1 = 0;
|
|
99
|
+
reg.r = (reg.r & 0x80) | ((reg.r + 1) & 0x7f);
|
|
100
|
+
|
|
101
|
+
// Push the return address, high byte first.
|
|
102
|
+
const sp = (reg.sp - 2) & 0xffff;
|
|
103
|
+
memory[sp] = pc & 0xff;
|
|
104
|
+
memory[(sp + 1) & 0xffff] = (pc >> 8) & 0xff;
|
|
105
|
+
reg.sp = sp;
|
|
106
|
+
|
|
107
|
+
// Fixed NMI restart vector.
|
|
108
|
+
reg.pc = NMI_VECTOR;
|
|
109
|
+
|
|
110
|
+
return { registers: reg, tStates: NMI_T_STATES, accepted: true, halted: false };
|
|
111
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export function createMachine(options?: {}): Machine;
|
|
2
|
+
export const RESET_REGISTERS: Readonly<{
|
|
3
|
+
a: 255;
|
|
4
|
+
f: 255;
|
|
5
|
+
b: 255;
|
|
6
|
+
c: 255;
|
|
7
|
+
d: 255;
|
|
8
|
+
e: 255;
|
|
9
|
+
h: 255;
|
|
10
|
+
l: 255;
|
|
11
|
+
a_: 255;
|
|
12
|
+
f_: 255;
|
|
13
|
+
b_: 255;
|
|
14
|
+
c_: 255;
|
|
15
|
+
d_: 255;
|
|
16
|
+
e_: 255;
|
|
17
|
+
h_: 255;
|
|
18
|
+
l_: 255;
|
|
19
|
+
pc: 0;
|
|
20
|
+
sp: 65535;
|
|
21
|
+
i: 0;
|
|
22
|
+
r: 0;
|
|
23
|
+
iff1: 0;
|
|
24
|
+
iff2: 0;
|
|
25
|
+
im: 0;
|
|
26
|
+
memptr: 0;
|
|
27
|
+
ixh: 255;
|
|
28
|
+
ixl: 255;
|
|
29
|
+
iyh: 255;
|
|
30
|
+
iyl: 255;
|
|
31
|
+
}>;
|
|
32
|
+
export class Machine {
|
|
33
|
+
constructor({ registers, memory, io, clock, exactContention }?: {
|
|
34
|
+
clock?: number;
|
|
35
|
+
exactContention?: boolean;
|
|
36
|
+
});
|
|
37
|
+
registers: {};
|
|
38
|
+
memory: Uint8Array<ArrayBuffer>;
|
|
39
|
+
io: any;
|
|
40
|
+
clock: number;
|
|
41
|
+
exactContention: boolean;
|
|
42
|
+
halted: boolean;
|
|
43
|
+
eiDelay: number;
|
|
44
|
+
tStatesTotal: number;
|
|
45
|
+
frames: number;
|
|
46
|
+
reset(): this;
|
|
47
|
+
_contentionClock(instructionStart: any): {
|
|
48
|
+
base: any;
|
|
49
|
+
extra: number;
|
|
50
|
+
access(address: any): void;
|
|
51
|
+
};
|
|
52
|
+
_exactClock(instructionStart: any): {
|
|
53
|
+
base: any;
|
|
54
|
+
runT: number;
|
|
55
|
+
extra: number;
|
|
56
|
+
perAccessExtra: number;
|
|
57
|
+
incomplete: boolean;
|
|
58
|
+
access(address: any): void;
|
|
59
|
+
mcycle(address: any, tStates: any): void;
|
|
60
|
+
internal(address: any, n: any): void;
|
|
61
|
+
inexact(): void;
|
|
62
|
+
total(): number;
|
|
63
|
+
};
|
|
64
|
+
stepInstruction(): {
|
|
65
|
+
tStates: any;
|
|
66
|
+
contention: any;
|
|
67
|
+
halted: boolean;
|
|
68
|
+
};
|
|
69
|
+
_acceptInterrupt(dataBus?: number): {
|
|
70
|
+
registers: any;
|
|
71
|
+
tStates: number;
|
|
72
|
+
accepted: boolean;
|
|
73
|
+
halted: boolean;
|
|
74
|
+
};
|
|
75
|
+
_interruptArmed(): boolean;
|
|
76
|
+
runFrame({ dataBus }?: {
|
|
77
|
+
dataBus?: number;
|
|
78
|
+
}): {
|
|
79
|
+
tStates: number;
|
|
80
|
+
accepted: number;
|
|
81
|
+
};
|
|
82
|
+
}
|
package/src/machine.mjs
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// 48K ZX Spectrum machine layer, authored from the project DNA
|
|
2
|
+
// (dna/domain/machine-execution.md) and decided by the machine conformance
|
|
3
|
+
// fixtures (dna/conformance/machine/*.json). It joins the regenerated CPU
|
|
4
|
+
// (@zx-vibes/cpu step()) and ULA timing model (@zx-vibes/ula) into a running
|
|
5
|
+
// machine: a register file + 64 KB memory + I/O ports + a frame T-state clock,
|
|
6
|
+
// with per-access memory contention threaded onto the executed instruction
|
|
7
|
+
// stream and the once-per-frame maskable interrupt accepted at instruction
|
|
8
|
+
// boundaries.
|
|
9
|
+
import { step } from "@zx-vibes/cpu";
|
|
10
|
+
import { FRAME_T_STATES, contentionDelay, interruptActive, isContendedAddress } from "@zx-vibes/ula";
|
|
11
|
+
import { INT_DATA_BUS, acceptInterrupt } from "./interrupt.mjs";
|
|
12
|
+
|
|
13
|
+
// The register fields the CPU step() contract recognizes, all defaulting to 0.
|
|
14
|
+
const REGISTER_NAMES = [
|
|
15
|
+
"a", "f", "b", "c", "d", "e", "h", "l",
|
|
16
|
+
"a_", "f_", "b_", "c_", "d_", "e_", "h_", "l_",
|
|
17
|
+
"pc", "sp", "i", "r", "iff1", "iff2", "im", "memptr",
|
|
18
|
+
"ixh", "ixl", "iyh", "iyl",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const HALT_OPCODE = 0x76;
|
|
22
|
+
const EI_OPCODE = 0xfb;
|
|
23
|
+
|
|
24
|
+
// Power-on / reset register state (dna/domain/machine-execution.md
|
|
25
|
+
// MACHINE-RESET-CONTROL-001 + MACHINE-RESET-REGISTERS-001). The Z80 RESET defines
|
|
26
|
+
// only PC/I/R = 0, IM 0, IFF1 = IFF2 = 0 (z80-spec); SP/AF and the GP/alternate/index
|
|
27
|
+
// registers are undefined and modeled as all-bits-set (0xFF halves -> 0xFFFF pairs)
|
|
28
|
+
// per decision:ADR-0021. Stored in the same 8-bit-half representation as the register
|
|
29
|
+
// file. MEMPTR is not part of the reset contract.
|
|
30
|
+
export const RESET_REGISTERS = Object.freeze({
|
|
31
|
+
a: 0xff, f: 0xff, b: 0xff, c: 0xff, d: 0xff, e: 0xff, h: 0xff, l: 0xff,
|
|
32
|
+
a_: 0xff, f_: 0xff, b_: 0xff, c_: 0xff, d_: 0xff, e_: 0xff, h_: 0xff, l_: 0xff,
|
|
33
|
+
pc: 0x0000, sp: 0xffff, i: 0x00, r: 0x00, iff1: 0, iff2: 0, im: 0, memptr: 0,
|
|
34
|
+
ixh: 0xff, ixl: 0xff, iyh: 0xff, iyl: 0xff,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function buildRegisters(initial = {}) {
|
|
38
|
+
const reg = {};
|
|
39
|
+
for (const name of REGISTER_NAMES) reg[name] = 0;
|
|
40
|
+
for (const [name, value] of Object.entries(initial)) {
|
|
41
|
+
if (REGISTER_NAMES.includes(name)) reg[name] = value | 0;
|
|
42
|
+
}
|
|
43
|
+
return reg;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function buildMemory(initial) {
|
|
47
|
+
if (initial instanceof Uint8Array) return initial;
|
|
48
|
+
const memory = new Uint8Array(0x10000);
|
|
49
|
+
if (initial && typeof initial === "object") {
|
|
50
|
+
for (const [address, bytes] of Object.entries(initial)) {
|
|
51
|
+
let pointer = Number(address) & 0xffff;
|
|
52
|
+
const data = bytes instanceof Uint8Array ? bytes : Uint8Array.from(bytes);
|
|
53
|
+
for (const byte of data) { memory[pointer & 0xffff] = byte & 0xff; pointer += 1; }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return memory;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A default I/O interface: reads float high (0xFF), writes are dropped. The
|
|
60
|
+
// caller can supply its own { read(port), write(port, value) }.
|
|
61
|
+
function defaultIo() {
|
|
62
|
+
return { read: () => 0xff, write: () => {} };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createMachine(options = {}) {
|
|
66
|
+
return new Machine(options);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class Machine {
|
|
70
|
+
constructor({ registers, memory, io, clock = 0, exactContention = false } = {}) {
|
|
71
|
+
this.registers = buildRegisters(registers);
|
|
72
|
+
this.memory = buildMemory(memory);
|
|
73
|
+
this.io = io ?? defaultIo();
|
|
74
|
+
this.clock = ((clock % FRAME_T_STATES) + FRAME_T_STATES) % FRAME_T_STATES;
|
|
75
|
+
// Contention model: the conformed per-access model (default,
|
|
76
|
+
// MACHINE-CONTENTION-CLOCK-001) or the M-cycle-exact model
|
|
77
|
+
// (MACHINE-CONTENTION-MCYCLE-001), which also charges internal no-MREQ cycles
|
|
78
|
+
// and samples each access at its true in-instruction T-offset.
|
|
79
|
+
this.exactContention = Boolean(exactContention);
|
|
80
|
+
this.halted = false;
|
|
81
|
+
// Boundaries to skip interrupt sampling for (the post-EI one-instruction
|
|
82
|
+
// delay, MACHINE-INT-EI-DELAY-001).
|
|
83
|
+
this.eiDelay = 0;
|
|
84
|
+
// Running totals (across frames), useful for drivers and assertions.
|
|
85
|
+
this.tStatesTotal = 0;
|
|
86
|
+
this.frames = 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Power-on / reset: restore the documented reset state (MACHINE-RESET-001) —
|
|
90
|
+
// the Z80 control registers cleared, the rest of the register file all-bits-set,
|
|
91
|
+
// RAM all-zero, the frame clock at 0, and not halted. Run totals are cleared too.
|
|
92
|
+
reset() {
|
|
93
|
+
this.registers = { ...RESET_REGISTERS };
|
|
94
|
+
this.memory = new Uint8Array(0x10000);
|
|
95
|
+
this.clock = 0;
|
|
96
|
+
this.halted = false;
|
|
97
|
+
this.eiDelay = 0;
|
|
98
|
+
this.tStatesTotal = 0;
|
|
99
|
+
this.frames = 0;
|
|
100
|
+
return this;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The contention observer handed to step(): it accumulates the ULA delay for
|
|
104
|
+
// each contended memory access, sampling at (instructionStart + accumulated)
|
|
105
|
+
// per MACHINE-CONTENTION-CLOCK-001.
|
|
106
|
+
_contentionClock(instructionStart) {
|
|
107
|
+
const clock = {
|
|
108
|
+
base: instructionStart,
|
|
109
|
+
extra: 0,
|
|
110
|
+
access(address) {
|
|
111
|
+
if (isContendedAddress(address)) {
|
|
112
|
+
const t = (this.base + this.extra) % FRAME_T_STATES;
|
|
113
|
+
this.extra += contentionDelay(t);
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
return clock;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The M-cycle-exact contention observer (MACHINE-CONTENTION-MCYCLE-001). It
|
|
121
|
+
// threads a running uncontended T-offset through the instruction's bus cycles
|
|
122
|
+
// (memory M-cycles via mcycle(), internal no-MREQ cycles via internal()),
|
|
123
|
+
// sampling contentionDelay at (instructionStart + runT + accumulated) for each
|
|
124
|
+
// contended cycle. It also keeps the per-access tally and an `incomplete` flag:
|
|
125
|
+
// when the CPU signals inexact() (an instruction whose internal cycles this
|
|
126
|
+
// slice does not yet model exactly), the machine falls back to the conformed
|
|
127
|
+
// per-access value, so no instruction is ever silently mis-timed (C5).
|
|
128
|
+
_exactClock(instructionStart) {
|
|
129
|
+
return {
|
|
130
|
+
base: instructionStart,
|
|
131
|
+
runT: 0,
|
|
132
|
+
extra: 0,
|
|
133
|
+
perAccessExtra: 0,
|
|
134
|
+
incomplete: false,
|
|
135
|
+
access(address) {
|
|
136
|
+
if (isContendedAddress(address)) {
|
|
137
|
+
const t = (this.base + this.perAccessExtra) % FRAME_T_STATES;
|
|
138
|
+
this.perAccessExtra += contentionDelay(t);
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
mcycle(address, tStates) {
|
|
142
|
+
if (isContendedAddress(address)) {
|
|
143
|
+
const t = (this.base + this.runT + this.extra) % FRAME_T_STATES;
|
|
144
|
+
this.extra += contentionDelay(t);
|
|
145
|
+
}
|
|
146
|
+
this.runT += tStates;
|
|
147
|
+
},
|
|
148
|
+
internal(address, n) {
|
|
149
|
+
for (let i = 0; i < n; i += 1) {
|
|
150
|
+
if (isContendedAddress(address)) {
|
|
151
|
+
const t = (this.base + this.runT + this.extra) % FRAME_T_STATES;
|
|
152
|
+
this.extra += contentionDelay(t);
|
|
153
|
+
}
|
|
154
|
+
this.runT += 1;
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
inexact() { this.incomplete = true; },
|
|
158
|
+
total() { return this.incomplete ? this.perAccessExtra : this.extra; },
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Execute exactly one instruction with contention threaded. Advances the clock
|
|
163
|
+
// by the real (uncontended + contention) duration and tracks the HALT state.
|
|
164
|
+
// Does NOT sample interrupts — that is the frame loop's job
|
|
165
|
+
// (MACHINE-INT-SAMPLE-001 fixes interrupt sampling to boundaries it controls).
|
|
166
|
+
stepInstruction() {
|
|
167
|
+
const reg = this.registers;
|
|
168
|
+
const pcBefore = reg.pc & 0xffff;
|
|
169
|
+
const opcode = this.memory[pcBefore];
|
|
170
|
+
const clock = this.exactContention
|
|
171
|
+
? this._exactClock(this.clock)
|
|
172
|
+
: this._contentionClock(this.clock);
|
|
173
|
+
|
|
174
|
+
const result = step({ registers: reg, memory: this.memory, io: this.io, clock });
|
|
175
|
+
this.registers = result.registers;
|
|
176
|
+
|
|
177
|
+
const contention = this.exactContention ? clock.total() : clock.extra;
|
|
178
|
+
const tStates = result.tStates + contention;
|
|
179
|
+
this.clock = (this.clock + tStates) % FRAME_T_STATES;
|
|
180
|
+
this.tStatesTotal += tStates;
|
|
181
|
+
|
|
182
|
+
// HALT is the only instruction that does not advance PC; the CPU leaves PC on
|
|
183
|
+
// the HALT opcode and reports 4 T per refetch.
|
|
184
|
+
this.halted = opcode === HALT_OPCODE && (this.registers.pc & 0xffff) === pcBefore;
|
|
185
|
+
|
|
186
|
+
return { tStates, contention, halted: this.halted };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Accept the pending maskable interrupt (caller has verified the conditions).
|
|
190
|
+
_acceptInterrupt(dataBus = INT_DATA_BUS) {
|
|
191
|
+
const result = acceptInterrupt({
|
|
192
|
+
registers: this.registers,
|
|
193
|
+
memory: this.memory,
|
|
194
|
+
halted: this.halted,
|
|
195
|
+
dataBus,
|
|
196
|
+
});
|
|
197
|
+
this.registers = result.registers;
|
|
198
|
+
this.halted = false;
|
|
199
|
+
this.eiDelay = 0;
|
|
200
|
+
this.clock = (this.clock + result.tStates) % FRAME_T_STATES;
|
|
201
|
+
this.tStatesTotal += result.tStates;
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// True iff the machine may accept a maskable interrupt at the current boundary:
|
|
206
|
+
// interrupts enabled and not inside the post-EI one-instruction delay.
|
|
207
|
+
_interruptArmed() {
|
|
208
|
+
return Boolean(this.registers.iff1) && this.eiDelay === 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Run one whole frame: execute instructions until the clock crosses the frame
|
|
212
|
+
// length, sampling INT at each boundary and accepting at most once
|
|
213
|
+
// (MACHINE-FRAME-LOOP-001). Returns { tStates, accepted } for the frame.
|
|
214
|
+
runFrame({ dataBus = INT_DATA_BUS } = {}) {
|
|
215
|
+
const start = this.tStatesTotal;
|
|
216
|
+
let accepted = 0;
|
|
217
|
+
let intTaken = false;
|
|
218
|
+
|
|
219
|
+
// The frame ends when the running clock would wrap. Because the clock is kept
|
|
220
|
+
// modulo the frame length, we track elapsed T-states for this frame directly.
|
|
221
|
+
let elapsed = 0;
|
|
222
|
+
while (elapsed < FRAME_T_STATES) {
|
|
223
|
+
if (!intTaken && this._interruptArmed() && interruptActive(this.clock)) {
|
|
224
|
+
const before = this.tStatesTotal;
|
|
225
|
+
this._acceptInterrupt(dataBus);
|
|
226
|
+
elapsed += this.tStatesTotal - before;
|
|
227
|
+
accepted += 1;
|
|
228
|
+
intTaken = true;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const wasEi = this.memory[this.registers.pc & 0xffff] === EI_OPCODE;
|
|
232
|
+
if (this.eiDelay > 0) this.eiDelay -= 1;
|
|
233
|
+
const before = this.tStatesTotal;
|
|
234
|
+
this.stepInstruction();
|
|
235
|
+
elapsed += this.tStatesTotal - before;
|
|
236
|
+
if (wasEi) this.eiDelay = 1;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
this.frames += 1;
|
|
240
|
+
return { tStates: this.tStatesTotal - start, accepted };
|
|
241
|
+
}
|
|
242
|
+
}
|