@zx-vibes/ula 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 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,38 @@
1
+ # @zx-vibes/ula
2
+
3
+ 48K ZX Spectrum ULA model for the zx-vibes toolchain.
4
+
5
+ Current package version in this repository: `0.1.0`.
6
+
7
+ A dependency-free JavaScript (ESM) model of the 48K ULA: frame/interrupt
8
+ timing constants, memory contention, screen address/attribute arithmetic,
9
+ `.scr` format helpers, floating-bus behavior, and Kempston joystick port
10
+ decoding. It is regenerated from the project DNA (`dna/domain/`) and decided
11
+ by the timing conformance fixtures.
12
+
13
+ ## Usage
14
+
15
+ ```js
16
+ import {
17
+ FRAME_T_STATES,
18
+ interruptActive,
19
+ isContendedAddress,
20
+ contentionDelay,
21
+ } from "@zx-vibes/ula";
22
+ ```
23
+
24
+ `@zx-vibes/ula/timing` exposes the timing module directly. Type declarations
25
+ are generated from the source JSDoc (`.d.mts` alongside each module).
26
+
27
+ Most consumers want `@zx-vibes/machine`, which joins this model with the
28
+ `@zx-vibes/cpu` core.
29
+
30
+ ## Testing
31
+
32
+ Tests are repo-only: the package is exercised by the `dna/conformance` fixture
33
+ suites in the [zx-vibes monorepo](https://github.com/Alvaromah/zx-vibes)
34
+ (`npm test` inside a published tarball is unsupported).
35
+
36
+ ## License
37
+
38
+ MIT. See `LICENSE`.
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@zx-vibes/ula",
3
+ "version": "0.1.0",
4
+ "description": "Regenerated 48K ZX Spectrum ULA timing model for zx-vibes, derived from the DNA and decided by the timing 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/ula"
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
+ "ula",
19
+ "emulator",
20
+ "emulation",
21
+ "timing"
22
+ ],
23
+ "type": "module",
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "main": "src/index.mjs",
28
+ "types": "src/index.d.mts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./src/index.d.mts",
32
+ "default": "./src/index.mjs"
33
+ },
34
+ "./timing": {
35
+ "types": "./src/ula-timing.d.mts",
36
+ "default": "./src/ula-timing.mjs"
37
+ },
38
+ "./package.json": "./package.json"
39
+ },
40
+ "files": [
41
+ "src",
42
+ "README.md"
43
+ ],
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "scripts": {
48
+ "test": "node ../../dna/conformance/timing/run-timing-fixtures.mjs",
49
+ "lint": "eslint src",
50
+ "types:gen": "tsc src/index.mjs --allowJs --declaration --emitDeclarationOnly --outDir src"
51
+ }
52
+ }
@@ -0,0 +1,7 @@
1
+ export function portFloats(port: any): boolean;
2
+ export function activeDisplayFetch(t: any): boolean;
3
+ export function floatingBusByte(t: any): {
4
+ value: number;
5
+ modeled: boolean;
6
+ };
7
+ export const FLOATING_BUS_IDLE: 255;
@@ -0,0 +1,58 @@
1
+ // Regenerated 48K ZX Spectrum floating-bus read model, authored from the project
2
+ // DNA (dna/domain/ula-timing.md "Floating bus", ULA-FLOATBUS-*) and decided by the
3
+ // floating-bus conformance fixtures (dna/conformance/timing/floating-bus.json).
4
+ //
5
+ // Scope (decision:ADR-0026, refining ADR-0021's E2 assumption): the port-decode
6
+ // precondition and the idle-bus value are pinned hardware facts; the timing-exact
7
+ // IN-window byte depends on the deferred active-area pixel timing (A5, ADR-0021)
8
+ // and is reported as UNMODELLED — never fabricated. The active-fetch window is the
9
+ // contended display window of ula-timing.mjs (anchor 14335), NOT the legacy 14384.
10
+ import {
11
+ FRAME_T_STATES,
12
+ T_STATES_PER_LINE,
13
+ CONTENTION_START_T,
14
+ DISPLAY_LINES,
15
+ CONTENDED_T_PER_LINE,
16
+ } from "./ula-timing.mjs";
17
+
18
+ // The value an undriven (floating) bus reads outside the display fetch: the data
19
+ // lines float high (ULA-FLOATBUS-IDLE-001).
20
+ export const FLOATING_BUS_IDLE = 0xff;
21
+
22
+ // ULA-FLOATBUS-PORT-001: the ULA decodes I/O on A0. It drives every EVEN port
23
+ // (A0 = 0) — that is the keyboard / EAR read surface (host-io-port-fe.md). An IN
24
+ // from an ODD port (A0 = 1) that no other device decodes is undriven and reads the
25
+ // floating bus. At the 48K base nothing decodes odd ports, so every odd port floats
26
+ // (the canonical floating-bus port is the odd 0xFF). A later peripheral that decodes
27
+ // an odd port (e.g. Kempston at 0x1F) carves out its own port and stops it floating.
28
+ export function portFloats(port) {
29
+ return (port & 0x0001) === 1;
30
+ }
31
+
32
+ // True iff frame T-state `t` is inside the active display-fetch window — exactly the
33
+ // contended window of ULA-TIME-CONTENTION-WINDOW-001 (192 lines, 224 T apart, each
34
+ // starting at frame T 14335 for the first 128 T). This is the ULA display-fetch
35
+ // activity that both contends memory and drives the floating bus.
36
+ export function activeDisplayFetch(t) {
37
+ const f = ((t % FRAME_T_STATES) + FRAME_T_STATES) % FRAME_T_STATES;
38
+ const offset = f - CONTENTION_START_T;
39
+ if (offset < 0) return false; // before the first display line
40
+ const line = Math.floor(offset / T_STATES_PER_LINE);
41
+ if (line >= DISPLAY_LINES) return false; // after the last display line
42
+ const column = offset % T_STATES_PER_LINE;
43
+ return column < CONTENDED_T_PER_LINE; // border + retrace are not fetches
44
+ }
45
+
46
+ // The value an IN from a floating (odd, undriven) port reads at frame T-state `t`:
47
+ // outside the display-fetch window -> { value: 0xFF, modeled: true }
48
+ // the idle bus floats high (ULA-FLOATBUS-IDLE-001).
49
+ // inside the display-fetch window -> { value: null, modeled: false }
50
+ // the byte is the ULA-fetched display/attribute byte (ULA-FLOATBUS-FETCH-001),
51
+ // but the exact T->byte phase mapping is the deferred A5 timing
52
+ // (ULA-FLOATBUS-DEFER-001) and is NOT pinned — reported unmodelled, not faked.
53
+ export function floatingBusByte(t) {
54
+ if (activeDisplayFetch(t)) {
55
+ return { value: null, modeled: false };
56
+ }
57
+ return { value: FLOATING_BUS_IDLE, modeled: true };
58
+ }
@@ -0,0 +1,6 @@
1
+ export * from "./ula-timing.mjs";
2
+ export * from "./screen-address.mjs";
3
+ export * from "./screen-attribute.mjs";
4
+ export * from "./scr-format.mjs";
5
+ export * from "./floating-bus.mjs";
6
+ export * from "./kempston.mjs";
package/src/index.mjs ADDED
@@ -0,0 +1,8 @@
1
+ // @zx-vibes/ula — regenerated 48K ZX Spectrum ULA timing model + screen addressing
2
+ // + attribute/colour decode.
3
+ export * from "./ula-timing.mjs";
4
+ export * from "./screen-address.mjs";
5
+ export * from "./screen-attribute.mjs";
6
+ export * from "./scr-format.mjs";
7
+ export * from "./floating-bus.mjs";
8
+ export * from "./kempston.mjs";
@@ -0,0 +1,8 @@
1
+ export function kempstonDecodes(port: any): boolean;
2
+ export function kempstonByte(state?: {}): number;
3
+ export const KEMPSTON_PORT: 31;
4
+ export const KEMPSTON_RIGHT: 1;
5
+ export const KEMPSTON_LEFT: 2;
6
+ export const KEMPSTON_DOWN: 4;
7
+ export const KEMPSTON_UP: 8;
8
+ export const KEMPSTON_FIRE: 16;
@@ -0,0 +1,48 @@
1
+ // @zx-vibes/ula — regenerated 48K ZX Spectrum Kempston joystick interface read model,
2
+ // authored from the project DNA (dna/domain/peripherals.md "Kempston joystick",
3
+ // JOY-KEMPSTON-*) and decided by the peripherals conformance fixtures
4
+ // (dna/conformance/peripherals/kempston.json).
5
+ //
6
+ // The Kempston interface is an EXTERNAL peripheral, not part of the ULA; it is modeled
7
+ // in @zx-vibes/ula beside the floating-bus model because both resolve an `IN` from the
8
+ // I/O bus, and the Kempston port decode is precisely what carves port 0x1F out of the
9
+ // floating odd-port set (ula-timing.mjs portFloats / ula-timing.md ULA-FLOATBUS-PORT-001).
10
+ //
11
+ // Scope (decision:ADR-0021, which ratified F1 as "port 0x1F active-high 000FUDLR"):
12
+ // the bit layout + active-high read at the canonical port are pinned hardware. The
13
+ // interface decodes the LOW address byte (the high byte — register B of `IN A,(C)` —
14
+ // is don't-care); finer incomplete decoding (clones that alias 0x1F across any A5=0
15
+ // port) is interface-specific and out of scope.
16
+
17
+ // The canonical Kempston joystick port (low byte). Reads return the button state.
18
+ export const KEMPSTON_PORT = 0x1f;
19
+
20
+ // Active-high button bit masks in the read byte `000FUDLR` (bits 7-5 are always 0).
21
+ export const KEMPSTON_RIGHT = 0x01; // bit 0
22
+ export const KEMPSTON_LEFT = 0x02; // bit 1
23
+ export const KEMPSTON_DOWN = 0x04; // bit 2
24
+ export const KEMPSTON_UP = 0x08; // bit 3
25
+ export const KEMPSTON_FIRE = 0x10; // bit 4
26
+
27
+ // JOY-KEMPSTON-PORT-001: true iff `port` addresses the Kempston interface. The
28
+ // canonical decode is low byte 0x1F; the high address byte is don't-care, so any port
29
+ // whose low 8 bits are 0x1F reads the joystick. Finer A5-only aliasing is out of scope.
30
+ export function kempstonDecodes(port) {
31
+ return (port & 0x00ff) === KEMPSTON_PORT;
32
+ }
33
+
34
+ // JOY-KEMPSTON-READ-001: map a joystick button state to the active-high read byte
35
+ // `000FUDLR`. Each pressed control sets its bit; the unused top three bits are always
36
+ // 0; idle (nothing pressed) reads 0x00. An absent/false field is "not pressed". The
37
+ // five controls are independent — the hardware imposes no interlock, so the model does
38
+ // not mask the physically-impossible Left+Right / Up+Down; it returns the OR of the
39
+ // pressed bits exactly.
40
+ export function kempstonByte(state = {}) {
41
+ let byte = 0;
42
+ if (state.right) byte |= KEMPSTON_RIGHT;
43
+ if (state.left) byte |= KEMPSTON_LEFT;
44
+ if (state.down) byte |= KEMPSTON_DOWN;
45
+ if (state.up) byte |= KEMPSTON_UP;
46
+ if (state.fire) byte |= KEMPSTON_FIRE;
47
+ return byte;
48
+ }
@@ -0,0 +1,4 @@
1
+ export function saveScr(memory: any): Uint8Array<ArrayBuffer>;
2
+ export function loadScr(memory: any, scr: any): void;
3
+ export const SCR_SIZE: number;
4
+ export const SCR_BASE: 16384;
@@ -0,0 +1,44 @@
1
+ // Regenerated `.scr` screen-dump load/save, authored from the project DNA
2
+ // (dna/domain/file-formats.md "`.scr` — raw screen dump") and decided by the format
3
+ // conformance fixtures (dna/conformance/formats/scr-format.json). A `.scr` file is a
4
+ // raw, headerless copy of the screen memory region 0x4000-0x5AFF (the 6144-byte
5
+ // display file followed by the 768-byte attribute file) — exactly the 6912-byte image
6
+ // the gallery framebuffer consumes (screen-render.md SCREEN-FRAMEBUFFER-001). Pure
7
+ // data copy: no header, no reordering, no compression.
8
+ import {
9
+ DISPLAY_FILE_BASE,
10
+ DISPLAY_FILE_SIZE,
11
+ ATTR_FILE_SIZE,
12
+ } from "./screen-address.mjs";
13
+
14
+ // FMT-SCR-SIZE-001: 6144 (display) + 768 (attribute).
15
+ export const SCR_SIZE = DISPLAY_FILE_SIZE + ATTR_FILE_SIZE; // 6912
16
+ // FMT-SCR-LAYOUT-001: file offset o is memory address 0x4000 + o.
17
+ export const SCR_BASE = DISPLAY_FILE_BASE; // 0x4000
18
+
19
+ // FMT-SCR-SAVE-001: read exactly 0x4000-0x5AFF into a 6912-byte file, in address
20
+ // order (file[o] = memory[0x4000 + o]). No header, no dropped attribute byte.
21
+ export function saveScr(memory) {
22
+ if (!memory || memory.length < SCR_BASE + SCR_SIZE) {
23
+ throw new Error(`saveScr: memory must hold at least 0x${(SCR_BASE + SCR_SIZE).toString(16)} bytes`);
24
+ }
25
+ const scr = new Uint8Array(SCR_SIZE);
26
+ for (let offset = 0; offset < SCR_SIZE; offset += 1) {
27
+ scr[offset] = memory[SCR_BASE + offset] & 0xff;
28
+ }
29
+ return scr;
30
+ }
31
+
32
+ // FMT-SCR-LOAD-001: write the 6912 bytes into memory at 0x4000 + offset and touch no
33
+ // address outside 0x4000-0x5AFF. FMT-SCR-SIZE-001: reject any other length.
34
+ export function loadScr(memory, scr) {
35
+ if (!scr || scr.length !== SCR_SIZE) {
36
+ throw new Error(`loadScr: a .scr file must be exactly ${SCR_SIZE} bytes, got ${scr ? scr.length : "none"}`);
37
+ }
38
+ if (!memory || memory.length < SCR_BASE + SCR_SIZE) {
39
+ throw new Error(`loadScr: memory must hold at least 0x${(SCR_BASE + SCR_SIZE).toString(16)} bytes`);
40
+ }
41
+ for (let offset = 0; offset < SCR_SIZE; offset += 1) {
42
+ memory[SCR_BASE + offset] = scr[offset] & 0xff;
43
+ }
44
+ }
@@ -0,0 +1,14 @@
1
+ export function displayByteAddress(x: any, y: any): number;
2
+ export function displayLineAddress(y: any): number;
3
+ export function attributeAddress(x: any, y: any): number;
4
+ export const DISPLAY_FILE_BASE: 16384;
5
+ export const DISPLAY_FILE_END: 22527;
6
+ export const DISPLAY_FILE_SIZE: 6144;
7
+ export const ATTR_FILE_BASE: 22528;
8
+ export const ATTR_FILE_END: 23295;
9
+ export const ATTR_FILE_SIZE: 768;
10
+ export const SCREEN_WIDTH: 256;
11
+ export const SCREEN_HEIGHT: 192;
12
+ export const CHAR_COLS: 32;
13
+ export const CHAR_ROWS: 24;
14
+ export const THIRD_SIZE: 2048;
@@ -0,0 +1,51 @@
1
+ // Regenerated 48K ZX Spectrum memory map & screen-address decode, authored from the
2
+ // project DNA (dna/domain/memory-map.md) and decided by the screen conformance
3
+ // fixtures (dna/conformance/screen/screen-address.json). Pure functions of a pixel
4
+ // coordinate: no machine state, no CPU coupling. This models the documented address
5
+ // arithmetic only — the bitmap/attribute byte *interpretation* (bit->pixel,
6
+ // INK/PAPER/BRIGHT/FLASH -> palette index) is a later slice and is not here.
7
+
8
+ // --- Region constants (MM-LAYOUT-001 / MM-SCREEN-DISPLAY-FILE-001 / MM-ATTR-FILE-001) ---
9
+ export const DISPLAY_FILE_BASE = 0x4000;
10
+ export const DISPLAY_FILE_END = 0x57ff;
11
+ export const DISPLAY_FILE_SIZE = 6144;
12
+
13
+ export const ATTR_FILE_BASE = 0x5800;
14
+ export const ATTR_FILE_END = 0x5aff;
15
+ export const ATTR_FILE_SIZE = 768;
16
+
17
+ // --- Geometry constants ---
18
+ export const SCREEN_WIDTH = 256;
19
+ export const SCREEN_HEIGHT = 192;
20
+ export const CHAR_COLS = 32;
21
+ export const CHAR_ROWS = 24;
22
+ export const THIRD_SIZE = 0x800;
23
+
24
+ // --- Display-file decode (MM-SCREEN-ADDR-001) -------------------------------------
25
+ // The display file is NOT linear. The 16-bit address of the byte holding pixel
26
+ // (x, y) (line y = y7 y6 y5 y4 y3 y2 y1 y0) has the bit layout:
27
+ // bit: 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0
28
+ // 0 1 0 y7 y6 y2 y1 y0 y5 y4 y3 x4 x3 x2 x1 x0
29
+ // i.e. 0x4000 + third*0x800 + pixelRow*0x100 + charRow*0x20 + col, where
30
+ // third = y>>6, pixelRow = y&7, charRow = (y>>3)&7, col = x>>3.
31
+ export function displayByteAddress(x, y) {
32
+ return (
33
+ 0x4000 +
34
+ ((y & 0xc0) << 5) + // y7 y6 -> bits 12-11 (third)
35
+ ((y & 0x07) << 8) + // y2 y1 y0 -> bits 10-8 (pixel row within cell)
36
+ ((y & 0x38) << 2) + // y5 y4 y3 -> bits 7-5 (char row within third)
37
+ (x >> 3) // x4..x0 -> bits 4-0 (byte column)
38
+ );
39
+ }
40
+
41
+ // First byte (column 0) of pixel line y; the 32 bytes of a line are contiguous.
42
+ // (MM-SCREEN-LINE-ADDR-001)
43
+ export function displayLineAddress(y) {
44
+ return displayByteAddress(0, y);
45
+ }
46
+
47
+ // --- Attribute decode (MM-ATTR-ADDR-001) ------------------------------------------
48
+ // Linear, row-major (no thirds interleave): 0x5800 + charRow*32 + charCol.
49
+ export function attributeAddress(x, y) {
50
+ return 0x5800 + (y >> 3) * 32 + (x >> 3);
51
+ }
@@ -0,0 +1,9 @@
1
+ export function attributeInk(byte: any): number;
2
+ export function attributePaper(byte: any): number;
3
+ export function attributeBright(byte: any): number;
4
+ export function attributeFlash(byte: any): number;
5
+ export function inkColorIndex(byte: any): number;
6
+ export function paperColorIndex(byte: any): number;
7
+ export function flashPhase(frame: any): number;
8
+ export function pixelColorIndex(byte: any, pixelOn: any, phase: any): number;
9
+ export const FLASH_FRAMES: 16;
@@ -0,0 +1,52 @@
1
+ // Regenerated 48K ZX Spectrum attribute / colour decode, authored from the project
2
+ // DNA (dna/domain/memory-map.md "Attribute & colour decode") and decided by the
3
+ // screen conformance fixtures (dna/conformance/screen/attr-decode.json). Pure
4
+ // functions of an attribute byte (and, for the per-pixel form, a bitmap bit and the
5
+ // FLASH phase): no machine state. This produces a palette INDEX 0..15 (hardware
6
+ // truth); the index -> RGB triple is gallery render policy (dna/product/palette.yaml).
7
+
8
+ // Attribute bit fields (MM-ATTR-BITS-001):
9
+ // bit 7 FLASH | bit 6 BRIGHT | bits 5..3 PAPER (0..7) | bits 2..0 INK (0..7)
10
+ export function attributeInk(byte) {
11
+ return byte & 0x07;
12
+ }
13
+ export function attributePaper(byte) {
14
+ return (byte >> 3) & 0x07;
15
+ }
16
+ export function attributeBright(byte) {
17
+ return (byte >> 6) & 1;
18
+ }
19
+ export function attributeFlash(byte) {
20
+ return (byte >> 7) & 1;
21
+ }
22
+
23
+ // Palette index 0..15 = base colour 0..7 + 8*BRIGHT (MM-ATTR-COLOUR-INDEX-001).
24
+ // BRIGHT raises the whole cell; it is not a separate hue.
25
+ export function inkColorIndex(byte) {
26
+ return attributeInk(byte) + attributeBright(byte) * 8;
27
+ }
28
+ export function paperColorIndex(byte) {
29
+ return attributePaper(byte) + attributeBright(byte) * 8;
30
+ }
31
+
32
+ // FLASH phase (MM-ATTR-FLASH-001): the ULA inverts every 16 frames. Phase 0 is the
33
+ // normal state, phase 1 the swapped (INK<->PAPER) state.
34
+ export const FLASH_FRAMES = 16;
35
+ export function flashPhase(frame) {
36
+ return Math.floor(frame / FLASH_FRAMES) & 1;
37
+ }
38
+
39
+ // Final palette index of a pixel (MM-PIXEL-COLOUR-001): pixelOn=1 shows INK, 0 shows
40
+ // PAPER. When the FLASH bit is set and the phase is odd, INK and PAPER swap first;
41
+ // BRIGHT applies after the swap (it raises whichever colour is shown).
42
+ export function pixelColorIndex(byte, pixelOn, phase) {
43
+ let ink = attributeInk(byte);
44
+ let paper = attributePaper(byte);
45
+ if (attributeFlash(byte) && (phase & 1)) {
46
+ const swap = ink;
47
+ ink = paper;
48
+ paper = swap;
49
+ }
50
+ const base = pixelOn ? ink : paper;
51
+ return base + attributeBright(byte) * 8;
52
+ }
@@ -0,0 +1,13 @@
1
+ export function interruptActive(t: any): boolean;
2
+ export function isContendedAddress(address: any): boolean;
3
+ export function contentionDelay(t: any): number;
4
+ export const SCAN_LINES: 312;
5
+ export const T_STATES_PER_LINE: 224;
6
+ export const FRAME_T_STATES: number;
7
+ export const INTERRUPT_T_STATES: 32;
8
+ export const CONTENDED_LOW: 16384;
9
+ export const CONTENDED_HIGH: 32767;
10
+ export const CONTENTION_PATTERN: number[];
11
+ export const CONTENTION_START_T: 14335;
12
+ export const DISPLAY_LINES: 192;
13
+ export const CONTENDED_T_PER_LINE: 128;
@@ -0,0 +1,52 @@
1
+ // Regenerated 48K ZX Spectrum ULA timing model, authored from the project DNA
2
+ // (dna/domain/ula-timing.md) and decided by the timing conformance fixtures
3
+ // (dna/conformance/timing/*.json). Pure functions of a frame T-state and a
4
+ // memory address: no machine state, no CPU coupling. Integrating these into the
5
+ // executed instruction stream (contention applied to each memory access) and
6
+ // interrupt acceptance is a later slice; this models the documented timing only.
7
+
8
+ // --- Frame and interrupt (ULA-TIME-FRAME-001 / ULA-TIME-INT-001) -------------
9
+ export const SCAN_LINES = 312;
10
+ export const T_STATES_PER_LINE = 224;
11
+ export const FRAME_T_STATES = SCAN_LINES * T_STATES_PER_LINE; // 69888
12
+ export const INTERRUPT_T_STATES = 32; // INT held LOW for the first 32 T of a frame
13
+
14
+ // True iff the ULA is asserting INT at frame-relative T-state `t`. Works for any
15
+ // integer t (negative or beyond one frame) by reducing modulo the frame length.
16
+ export function interruptActive(t) {
17
+ const f = ((t % FRAME_T_STATES) + FRAME_T_STATES) % FRAME_T_STATES;
18
+ return f < INTERRUPT_T_STATES;
19
+ }
20
+
21
+ // --- Memory contention (ULA-TIME-CONTENDED-ADDR-001 / -PATTERN / -WINDOW) -----
22
+ export const CONTENDED_LOW = 0x4000;
23
+ export const CONTENDED_HIGH = 0x7fff;
24
+
25
+ // Only the lower 16K RAM is contended on a 48K machine.
26
+ export function isContendedAddress(address) {
27
+ const a = address & 0xffff;
28
+ return a >= CONTENDED_LOW && a <= CONTENDED_HIGH;
29
+ }
30
+
31
+ // The repeating period-8 delay pattern applied during the display fetch window.
32
+ export const CONTENTION_PATTERN = [6, 5, 4, 3, 2, 1, 0, 0];
33
+
34
+ // Canonical 48K early-timing geometry (pinned per ADR-0010).
35
+ export const CONTENTION_START_T = 14335; // first contended frame T-state
36
+ export const DISPLAY_LINES = 192; // contended display lines
37
+ export const CONTENDED_T_PER_LINE = 128; // contended T-states at the start of each line
38
+
39
+ // Extra T-states the ULA adds to a CONTENDED-RAM access that begins at frame
40
+ // T-state `t`. Returns 0 outside the display fetch window (border, retrace, and
41
+ // any access to uncontended memory must use isContendedAddress() first). The
42
+ // caller is responsible for only applying this to contended addresses.
43
+ export function contentionDelay(t) {
44
+ const f = ((t % FRAME_T_STATES) + FRAME_T_STATES) % FRAME_T_STATES;
45
+ const offset = f - CONTENTION_START_T;
46
+ if (offset < 0) return 0; // before the first display line
47
+ const line = Math.floor(offset / T_STATES_PER_LINE);
48
+ if (line >= DISPLAY_LINES) return 0; // after the last display line
49
+ const column = offset % T_STATES_PER_LINE;
50
+ if (column >= CONTENDED_T_PER_LINE) return 0; // border + horizontal retrace
51
+ return CONTENTION_PATTERN[column % CONTENTION_PATTERN.length];
52
+ }