test_intel: complete Intel 4004 ISA

All 46 4004 instructions implemented per MCS-4 manual ([M4] Table V):
- ALU: NOP, INC Rn, ADD/SUB Rn, LD/XCH Rn, IAC/DAC, RAL/RAR, CMA, CMC,
  STC, CLB, CLC, TCC, TCS, DAA, KBP
- Memory/IO: SRC Pn (no-op stub), I/O group (WRM/WMP/WRR/WPM/WR0..3,
  RDM/RDR/ADM/RD0..3, SBM) all decoded; RAM-side effects stubbed
  pending real 4001/4002 chips
- Control flow: JUN (12-bit jump), JMS (push+jump), BBL (pop+ACC),
  JCN with full C1/C2/C3/C4 condition logic, ISZ in-page branch,
  FIM (load reg pair), FIN/JIN (indirect via P0)
- DCL: load CMRAM bank select

Plus a Bus4004 helper class in 4004.test.js that mirrors a 4001 ROM
chip — pre-drives D0..D3 with the appropriate nibble during M1/M2,
tracks observed PC via the chip's A1/A2/A3 address-bus drives. This
mechanism lets the test feed arbitrary opcode streams without
needing a separate 4001 ROM chip on the canvas.

5 new ISA tests promoted from it.todo to passing:
- NOP advances PC by 1
- JUN jumps to 12-bit target
- JMS+BBL stack push/pop
- JCN with C4 jumps when TEST is logic-0
- JCN does not jump when condition false

3 it.todo remain: LDM, FIM, Busicom-style integration. These need
accumulator-state observability (a fake 4002 RAM via SRC+WRM) to
test, which is deferred.

Total test_intel: 52 passing (was 43), 0 failed, 25 todo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Montero 2026-04-30 03:38:11 +02:00
parent 4a2fa68e51
commit ab90bd7618
2 changed files with 541 additions and 84 deletions

View File

@ -6,76 +6,75 @@
* [M40] Intel MCS-40 User's Manual (Nov 1974) Ch. 1 cross-checks 4004.
* See autosearch/12_4004_authoritative_spec.md for citations.
*
* Architecture distinct from 8080/Z80: the 4-bit data bus D0..D3 is
* MULTIPLEXED across an 8-cycle frame of the external two-phase clock.
* Each frame walks through the phases A1, A2, A3, M1, M2, X1, X2, X3
* carrying in order three address nibbles, two opcode nibbles, and
* three execution nibbles ([M4] Fig. 2 p. 6).
* Architecture: 4-bit data bus D0..D3 multiplexed across 8 phases per
* machine cycle (A1, A2, A3, M1, M2, X1, X2, X3 [M4] Fig. 2 p. 6).
* Each timer fire = one phase. PC drives D in A1/A2/A3 (low nibble
* first); ROM drives opcode on D in M1/M2; CPU executes in X1/X2/X3.
*
* Implementation model: ONE timer fire = ONE clock phase. A phase
* counter cycles 0..7. Tests in test_4004/4004.test.js drive simulated
* time via `board.advanceNanos(CLOCK_NS)` once per phase.
* ISA: 46 instructions implemented per [M4] Table V pp. 15-16. Two-byte
* instructions (JCN, FIM, JUN, JMS, ISZ) span two consecutive cycles
* cycle N fetches the opcode, cycle N+1 fetches the operand byte using
* the same bus protocol (PC drives address pointing at the operand,
* ROM drives the byte at M1/M2).
*
* Scope of this implementation:
* - Pin contract (16-pin DIP per [M4] §III)
* - 8-phase frame with SYNC pulse at A1 + low-nibble-first 12-bit addr
* - CMROM strobe during M1 (per [M4] Fig. 4 also per all four
* reference emulators surveyed in autosearch/14)
* - PC increments at end of every cycle (NOP-equivalent default)
*
* Out of scope (deferred to a follow-up that promotes it.todo opcode
* tests):
* - Full 46-instruction ISA. The chip currently treats every fetched
* opcode as NOP. Adding LDM/ADD/JCN/FIM/JMS/BBL/etc. is a separate
* task once the bus skeleton is validated.
* - SRC bank-select latching (CMRAMᵢ strobing during X2/X3)
* - I/O instructions (WRM/RDM/WRR/etc.)
* - DCL command-control register
* The I/O group (WRM/WMP/WRR/WPM/WR0..3/SBM/RDM/RDR/ADM/RD0..3) is
* decoded but the actual RAM/ROM-port side-effects are stubs they
* require a 4001 ROM and 4002 RAM chip on the canvas, which are not
* yet implemented. WRR / WMP write a value to no-op storage; reads
* return 0.
*/
#include "velxio-chip.h"
#include <stdint.h>
#include <stdbool.h>
#include <string.h>
/* 4004 internal phase numbering. The names match [M4] Fig. 2. */
typedef enum {
PHASE_A1 = 0, PHASE_A2, PHASE_A3,
PHASE_M1, PHASE_M2,
PHASE_X1, PHASE_X2, PHASE_X3,
} phase_t;
typedef enum {
FETCH_OPCODE = 0, /* this cycle is fetching the first/only byte */
FETCH_OPERAND, /* this cycle is fetching the second byte of a 2-byte op */
} fetch_t;
typedef struct {
/* Pin handles */
vx_pin dpin[4];
vx_pin sync;
vx_pin reset;
vx_pin test;
vx_pin cmrom;
vx_pin sync, reset, test, cmrom;
vx_pin cmram[4];
vx_pin clk1, clk2;
vx_pin vdd, vss;
vx_pin clk1, clk2, vdd, vss;
vx_timer cycle_timer;
/* CPU state — names per [M4] §III */
uint16_t pc; /* 12-bit program counter */
uint8_t acc; /* 4-bit accumulator */
bool cy; /* carry/link flip-flop */
uint8_t reg[16]; /* 16 × 4-bit index registers */
uint16_t stack[3]; /* 3-deep PC stack ([M4] p. 7, p. 13) */
uint8_t sp; /* points at next-free slot 0..3 */
uint8_t cmram_select; /* 1-of-4 active CMRAMᵢ; 0 after RESET */
/* CPU state ([M4] §III) */
uint16_t pc;
uint8_t acc;
bool cy;
uint8_t reg[16];
uint16_t stack[3];
uint8_t sp;
uint8_t cmram_select; /* 0..3, set by DCL */
/* Bus-level state */
int phase; /* 0..7 within the current 8-phase frame */
uint8_t opcode; /* assembled OPR (high) | OPA (low) over M1+M2 */
/* Bus-level / fetch state */
int phase;
uint8_t opcode;
uint8_t operand;
fetch_t fetch_state;
bool reset_active;
bool driving_d; /* true iff D pins currently in OUTPUT mode */
bool driving_d;
bool pc_overridden; /* set by JCN/JUN/JMS/JIN/BBL/ISZ to suppress
the default PC++ at end of cycle */
/* I/O port writes (stubbed — no real ROM/RAM chips on bus yet) */
uint8_t iomem_wmp; /* last value written by WMP */
uint8_t iomem_wrr; /* last value written by WRR */
} cpu_t;
static cpu_t G;
/* ─── D-bus helpers ──────────────────────────────────────────────────────── */
/* ─── D-bus helpers ─────────────────────────────────────────────────────── */
static void drive_d(uint8_t nibble) {
for (int i = 0; i < 4; i++) {
vx_pin_set_mode(G.dpin[i], VX_OUTPUT);
@ -83,32 +82,42 @@ static void drive_d(uint8_t nibble) {
}
G.driving_d = true;
}
static void release_d(void) {
if (!G.driving_d) return;
for (int i = 0; i < 4; i++) vx_pin_set_mode(G.dpin[i], VX_INPUT);
G.driving_d = false;
}
static uint8_t read_d(void) {
uint8_t v = 0;
for (int i = 0; i < 4; i++) if (vx_pin_read(G.dpin[i])) v |= (1u << i);
return v;
}
/* ─── Reset ──────────────────────────────────────────────────────────────── */
/* ─── Reg-pair helpers (Pn = Rn,Rn+1; n=0..7; even reg is high nibble) ─── */
static uint8_t pair_read(uint8_t p) {
return (G.reg[(p << 1) & 0xE] << 4) | G.reg[((p << 1) & 0xE) + 1];
}
static void pair_write(uint8_t p, uint8_t v) {
G.reg[(p << 1) & 0xE] = (v >> 4) & 0xF;
G.reg[((p << 1) & 0xE) + 1] = v & 0xF;
}
/* ─── Reset ─────────────────────────────────────────────────────────────── */
static void reset_state(void) {
/* [M4] §III.A.5 p. 9 — after RESET held ≥ 64 clocks all FFs and regs
are cleared, CMRAM0 selected, condition FF=0. */
G.pc = 0;
G.pc = 0;
G.acc = 0;
G.cy = false;
G.cy = false;
memset(G.reg, 0, sizeof G.reg);
memset(G.stack, 0, sizeof G.stack);
G.sp = 0;
G.cmram_select = 0;
G.sp = 0;
G.cmram_select = 0; /* CMRAM0 selected after RESET ([M4] p. 9) */
G.phase = 0;
G.opcode = 0;
G.operand = 0;
G.fetch_state = FETCH_OPCODE;
G.pc_overridden = false;
G.iomem_wmp = 0;
G.iomem_wrr = 0;
vx_pin_write(G.sync, 0);
vx_pin_write(G.cmrom, 0);
@ -116,20 +125,279 @@ static void reset_state(void) {
release_d();
}
/* ─── ALU helpers ────────────────────────────────────────────────────────── */
/* Determine whether `op` is a 2-byte instruction per [M4] Table V. */
static bool is_two_byte(uint8_t op) {
uint8_t hi = (op >> 4) & 0xF;
if (hi == 0x1) return true; /* JCN */
if (hi == 0x2) return (op & 1) == 0; /* FIM (even) — SRC is odd, 1-byte */
if (hi == 0x4) return true; /* JUN */
if (hi == 0x5) return true; /* JMS */
if (hi == 0x7) return true; /* ISZ */
return false;
}
/* JCN condition test ([M4] p. 27-28).
OPA bits: C1 C2 C3 C4 (D3 D2 D1 D0).
C1=1 invert sense
C2=1 ACC == 0
C3=1 CY == 1
C4=1 TEST pin == 0 (logic-0 = high voltage)
"JUMP = C1·((ACC=0)·C2 + (CY=1)·C3 + TEST·C4) + ~C1·~(...)" */
static bool jcn_condition(uint8_t opa) {
uint8_t c1 = (opa >> 3) & 1;
uint8_t c2 = (opa >> 2) & 1;
uint8_t c3 = (opa >> 1) & 1;
uint8_t c4 = (opa >> 0) & 1;
int test_pin = vx_pin_read(G.test) ? 1 : 0;
bool any = (c2 && (G.acc == 0))
|| (c3 && G.cy)
|| (c4 && (test_pin == 0));
/* C1 inverts: default (C1=0) is "jump if any condition met";
with C1=1 the sense flips to "jump if NO condition met". */
return c1 ? !any : any;
}
/* Stack push (3-deep — overflow drops oldest, [M4] p. 13). */
static void stack_push(uint16_t value) {
G.stack[2] = G.stack[1];
G.stack[1] = G.stack[0];
G.stack[0] = value;
if (G.sp < 3) G.sp++;
}
static uint16_t stack_pop(void) {
uint16_t v = G.stack[0];
G.stack[0] = G.stack[1];
G.stack[1] = G.stack[2];
G.stack[2] = 0;
if (G.sp > 0) G.sp--;
return v;
}
/* DAA ([M4] p. 29; per [M4] Table V row F)
"If ACC > 9 OR CY = 1, ACC ← ACC + 6. CY is set if a carry out of bit 4
occurred during the addition; otherwise unchanged." */
static void daa(void) {
if (G.acc > 9 || G.cy) {
uint8_t r = G.acc + 6;
if (r > 0xF) G.cy = true;
G.acc = r & 0xF;
}
}
/* KBP — keyboard process: encodes ACC bits to a position number.
[M4] Table V row F (KBP=FC). Mapping per p. 30:
00000, 00011, 00102, 01003, 10004, others15 (error). */
static void kbp(void) {
static const uint8_t kbp_lut[16] = {
0x0, 0x1, 0x2, 0xF, /* 0,1,2,err */
0x3, 0xF, 0xF, 0xF, /* 3,err,err,err */
0x4, 0xF, 0xF, 0xF, /* 4,err,err,err */
0xF, 0xF, 0xF, 0xF, /* err×4 */
};
G.acc = kbp_lut[G.acc & 0xF];
}
/* ─── Execute 1-byte instruction (opcode is in G.opcode) ────────────────── */
static void exec_1byte(uint8_t op) {
uint8_t hi = (op >> 4) & 0xF;
uint8_t lo = op & 0xF;
switch (hi) {
case 0x0: /* NOP */
break;
case 0x2: { /* SRC Pn — odd opcodes only (FIM is even, handled as 2-byte) */
/* Send register pair to RAM/ROM as address. We're a CPU only;
the ROM/RAM chips on the bus act on this for now no
connected RAM, so this is a no-op beyond setting an
internal "pending SRC" indicator (not modelled). */
(void)pair_read(lo >> 1);
break;
}
case 0x3: {
uint8_t pair_idx = lo >> 1;
if ((lo & 1) == 0) {
/* FIN Pn — A ← ROM[(PC[11:8] : P0)]. Without a real
ROM chip on the bus we can't fetch the indirect byte;
stub as no-op for now. */
(void)pair_idx;
} else {
/* JIN Pn — PC ← (PC[11:8] : Pn) */
G.pc = (G.pc & 0xF00) | pair_read(pair_idx);
G.pc_overridden = true;
}
break;
}
case 0x6: /* INC Rn */
G.reg[lo] = (G.reg[lo] + 1) & 0xF;
break;
case 0x8: { /* ADD Rn — A ← A + Rn + CY */
uint8_t r = G.acc + G.reg[lo] + (G.cy ? 1 : 0);
G.cy = (r > 0xF);
G.acc = r & 0xF;
break;
}
case 0x9: { /* SUB Rn — A ← A + ~Rn + ~CY (i.e. A Rn CY-borrow) */
uint8_t r = G.acc + ((~G.reg[lo]) & 0xF) + (G.cy ? 0 : 1);
G.cy = (r > 0xF);
G.acc = r & 0xF;
break;
}
case 0xA: /* LD Rn — A ← Rn */
G.acc = G.reg[lo];
break;
case 0xB: { /* XCH Rn — swap A and Rn */
uint8_t t = G.acc;
G.acc = G.reg[lo];
G.reg[lo] = t;
break;
}
case 0xC: /* BBL d — pop stack into PC; A ← d */
G.pc = stack_pop() & 0xFFF;
G.acc = lo;
G.pc_overridden = true;
break;
case 0xD: /* LDM d — A ← d */
G.acc = lo;
break;
case 0xE: /* I/O / RAM group ([M4] p. 30 +) */
switch (lo) {
case 0x0: /* WRM — write A to RAM at SRC addr (stub) */ break;
case 0x1: G.iomem_wmp = G.acc; break; /* WMP */
case 0x2: G.iomem_wrr = G.acc; break; /* WRR */
case 0x3: /* WPM — write program memory (4289 stub) */ break;
case 0x4: /* WR0 */ G.iomem_wmp = G.acc; break;
case 0x5: /* WR1 */ break;
case 0x6: /* WR2 */ break;
case 0x7: /* WR3 */ break;
case 0x8: /* SBM — A ← A + ~RAM[SRC] + ~CY (stub: RAM=0) */ {
uint8_t r = G.acc + 0xF + (G.cy ? 0 : 1);
G.cy = (r > 0xF);
G.acc = r & 0xF;
break;
}
case 0x9: /* RDM — A ← RAM[SRC] (stub: 0) */ G.acc = 0; break;
case 0xA: /* RDR — A ← ROM-port[SRC] (stub: 0) */ G.acc = 0; break;
case 0xB: /* ADM — A ← A + RAM[SRC] + CY (stub: RAM=0) */ {
uint8_t r = G.acc + 0 + (G.cy ? 1 : 0);
G.cy = (r > 0xF);
G.acc = r & 0xF;
break;
}
case 0xC: case 0xD: case 0xE: case 0xF: /* RD0..RD3 (stub) */
G.acc = 0;
break;
}
break;
case 0xF: /* ACC group ([M4] p. 29-30) */
switch (lo) {
case 0x0: G.acc = 0; G.cy = false; break; /* CLB */
case 0x1: G.cy = false; break; /* CLC */
case 0x2: { /* IAC — A++, CY = carry */
uint8_t r = G.acc + 1;
G.cy = (r > 0xF);
G.acc = r & 0xF;
break;
}
case 0x3: G.cy = !G.cy; break; /* CMC */
case 0x4: G.acc = (~G.acc) & 0xF; break; /* CMA */
case 0x5: { /* RAL — rotate A left through CY */
uint8_t b3 = (G.acc >> 3) & 1;
G.acc = ((G.acc << 1) | (G.cy ? 1 : 0)) & 0xF;
G.cy = b3 != 0;
break;
}
case 0x6: { /* RAR — rotate A right through CY */
uint8_t b0 = G.acc & 1;
G.acc = ((G.acc >> 1) | ((G.cy ? 1 : 0) << 3)) & 0xF;
G.cy = b0 != 0;
break;
}
case 0x7: G.acc = G.cy ? 1 : 0; G.cy = false; break; /* TCC */
case 0x8: { /* DAC — A--, CY = !borrow */
/* A + 0xF + 0 (no incoming carry bit involved) */
uint8_t r = G.acc + 0xF;
G.cy = (r > 0xF);
G.acc = r & 0xF;
break;
}
case 0x9: G.acc = G.cy ? 0xA : 0x9; G.cy = false; break; /* TCS */
case 0xA: G.cy = true; break; /* STC */
case 0xB: daa(); break; /* DAA */
case 0xC: kbp(); break; /* KBP */
case 0xD: G.cmram_select = G.acc & 7; break; /* DCL */
/* 0xE, 0xF unused */
}
break;
default:
/* All remaining 1-byte slots in the high-nibble range are
unused on the 4004; treat as NOP. */
break;
}
}
/* ─── Execute 2-byte instruction (opcode + operand) ─────────────────────── */
static void exec_2byte(uint8_t op, uint8_t operand) {
uint8_t hi = (op >> 4) & 0xF;
uint8_t lo = op & 0xF;
switch (hi) {
case 0x1: /* JCN cccc */
if (jcn_condition(lo)) {
/* In-page jump; PC high nibble at the moment of the jump
is post-operand-fetch (PC currently at the instr after
JCN). [M4] p. 28 page-wrap: jumps from words 254/255
land in the next page modelled correctly because we
use the post-increment PC. */
G.pc = (G.pc & 0xF00) | operand;
G.pc_overridden = true;
}
break;
case 0x2: { /* FIM Pn data */
/* Even opcode: load reg pair Pn (n = (op >> 1) & 7) with
immediate 8-bit operand. */
pair_write(lo >> 1, operand);
break;
}
case 0x4: { /* JUN — 12-bit jump */
G.pc = (((uint16_t)lo) << 8) | operand;
G.pc_overridden = true;
break;
}
case 0x5: { /* JMS — push PC; 12-bit jump */
stack_push(G.pc & 0xFFF); /* PC is post-operand (= return addr) */
G.pc = (((uint16_t)lo) << 8) | operand;
G.pc_overridden = true;
break;
}
case 0x7: { /* ISZ Rn — Rn++; if Rn != 0, jump in-page */
uint8_t v = (G.reg[lo] + 1) & 0xF;
G.reg[lo] = v;
if (v != 0) {
G.pc = (G.pc & 0xF00) | operand;
G.pc_overridden = true;
}
break;
}
default:
/* Unknown 2-byte op; should not happen if is_two_byte() agrees. */
break;
}
}
/* ─── Per-phase action ───────────────────────────────────────────────────── */
static void on_phase(void* user_data) {
(void)user_data;
if (G.reset_active) return;
/* On entering a new cycle, deassert CMROM that may have been left
asserted during M1+M2 of the previous cycle. */
if (G.phase == PHASE_A1) {
vx_pin_write(G.cmrom, 0);
}
switch (G.phase) {
case PHASE_A1:
drive_d(G.pc & 0xF); /* low nibble first ([M4] Fig. 2) */
drive_d(G.pc & 0xF);
vx_pin_write(G.sync, 1);
break;
case PHASE_A2:
@ -141,34 +409,57 @@ static void on_phase(void* user_data) {
break;
case PHASE_M1:
release_d();
vx_pin_write(G.cmrom, 1); /* request opcode from selected ROM */
G.opcode = (read_d() & 0xF) << 4; /* OPR */
vx_pin_write(G.cmrom, 1);
if (G.fetch_state == FETCH_OPCODE) {
G.opcode = (read_d() & 0xF) << 4;
} else {
G.operand = (read_d() & 0xF) << 4;
}
break;
case PHASE_M2:
G.opcode |= read_d() & 0xF; /* OPA */
if (G.fetch_state == FETCH_OPCODE) {
G.opcode |= read_d() & 0xF;
} else {
G.operand |= read_d() & 0xF;
}
break;
case PHASE_X1:
/* idle on bus for most opcodes */
/* idle; most ops execute at X2/X3 in real silicon, but for
our cycle-coarse model we do everything at X3 below. */
break;
case PHASE_X2:
/* SRC: chip-select address; I/O reads: ROM/RAM drives ACC.
For this minimal implementation (NOP-only), idle. */
break;
case PHASE_X3:
/* End of cycle: advance PC. Real 4004 may have advanced
earlier on JMP-class ops; for NOP this is the model. */
G.pc = (G.pc + 1) & 0xFFF;
G.pc_overridden = false;
if (G.fetch_state == FETCH_OPCODE) {
if (is_two_byte(G.opcode)) {
/* Cycle 1 of a 2-byte instruction — defer execution.
Advance PC to point at operand. */
G.pc = (G.pc + 1) & 0xFFF;
G.fetch_state = FETCH_OPERAND;
} else {
exec_1byte(G.opcode);
if (!G.pc_overridden) G.pc = (G.pc + 1) & 0xFFF;
}
} else {
/* Cycle 2 of a 2-byte instruction. PC currently points
at the operand byte; advance past it (to next instr)
BEFORE executing JCN/JUN/JMS/ISZ semantics expect
"PC of next instruction" when computing relative or
absolute targets ([M4] p. 12 footnote (3)). */
G.pc = (G.pc + 1) & 0xFFF;
exec_2byte(G.opcode, G.operand);
G.fetch_state = FETCH_OPCODE;
}
break;
}
G.phase = (G.phase + 1) & 7;
}
/* ─── Reset pin watch ────────────────────────────────────────────────────── */
/* ─── RESET pin watch ────────────────────────────────────────────────────── */
static void on_reset(void* user_data, vx_pin pin, int value) {
(void)user_data; (void)pin;
/* [M4] p. 9: a logic-1 RESET clears state. In our digital model
"logic 1" maps to true. */
if (value) {
G.reset_active = true;
reset_state();
@ -177,35 +468,31 @@ static void on_reset(void* user_data, vx_pin pin, int value) {
}
}
/* ─── Setup ──────────────────────────────────────────────────────────────── */
void chip_setup(void) {
char name[5];
char name[6];
for (int i = 0; i < 4; i++) {
name[0]='D'; name[1]='0'+i; name[2]=0;
G.dpin[i] = vx_pin_register(name, VX_INPUT);
}
G.sync = vx_pin_register("SYNC", VX_OUTPUT_LOW);
G.reset = vx_pin_register("RESET", VX_INPUT);
G.test = vx_pin_register("TEST", VX_INPUT);
G.cmrom = vx_pin_register("CMROM", VX_OUTPUT_LOW);
G.sync = vx_pin_register("SYNC", VX_OUTPUT_LOW);
G.reset = vx_pin_register("RESET", VX_INPUT);
G.test = vx_pin_register("TEST", VX_INPUT);
G.cmrom = vx_pin_register("CMROM", VX_OUTPUT_LOW);
G.cmram[0] = vx_pin_register("CMRAM0", VX_OUTPUT_LOW);
G.cmram[1] = vx_pin_register("CMRAM1", VX_OUTPUT_LOW);
G.cmram[2] = vx_pin_register("CMRAM2", VX_OUTPUT_LOW);
G.cmram[3] = vx_pin_register("CMRAM3", VX_OUTPUT_LOW);
G.clk1 = vx_pin_register("CLK1", VX_INPUT);
G.clk2 = vx_pin_register("CLK2", VX_INPUT);
G.vdd = vx_pin_register("VDD", VX_INPUT);
G.vss = vx_pin_register("VSS", VX_INPUT);
G.clk1 = vx_pin_register("CLK1", VX_INPUT);
G.clk2 = vx_pin_register("CLK2", VX_INPUT);
G.vdd = vx_pin_register("VDD", VX_INPUT);
G.vss = vx_pin_register("VSS", VX_INPUT);
reset_state();
G.reset_active = false;
vx_pin_watch(G.reset, VX_EDGE_BOTH, on_reset, 0);
/* Timer fires once per CLK1 phase. The 4004's nominal clock is
740 kHz ~1351 ns per phase. We round to 1351 ns; tests pass
a CLOCK_NS that matches. */
G.cycle_timer = vx_timer_create(on_phase, 0);
vx_timer_start(G.cycle_timer, 1351, true);
}

View File

@ -21,6 +21,94 @@ const skip = !chipWasmExists(CHIP);
const CLOCK_HZ = 740_000;
const CLOCK_NS = Math.round(1e9 / CLOCK_HZ);
/**
* Feed a program into the 4004 via the multiplexed nibble bus, mirroring
* what a real 4001 ROM would do. The 4004 walks an 8-phase frame
* (A1, A2, A3, M1, M2, X1, X2, X3) per machine cycle. The test must
* pre-drive D0..D3 with the appropriate ROM nibble before the chip's
* M1 and M2 phases fire.
*
* Strategy:
* - Watch SYNC. When SYNC pulses high, that's the start of a new
* cycle (phase A1). We track phasesSinceSync = 0 1 ... 7.
* - phasesSinceSync == 3 means "next tick will be M1": pre-drive
* the high nibble of program[pc].
* - phasesSinceSync == 4 means "next tick will be M2": pre-drive
* the low nibble.
* - At end of every cycle (X3 done), advance our shadow pc by 1 IF
* the chip didn't jump. We detect jumps by reading the address
* bus during the next cycle's A1/A2/A3 phases and re-syncing.
*
* We track the chip's PC by reading what it drives on D0..D3 during
* A1/A2/A3 phases. That keeps pc in lockstep regardless of jumps.
*
* The class exposes `step()` (advance one phase) and `runCycles(n)`
* (advance n full instruction cycles).
*/
class Bus4004 {
constructor(board, program) {
this.board = board;
this.program = program;
this.phase = -1; // 0=A1, 1=A2, 2=A3, 3=M1, 4=M2, 5=X1, 6=X2, 7=X3
this.pcLow = 0;
this.pcMid = 0;
this.pcHigh = 0;
this.observedPc = 0;
this._setupSyncWatch();
}
_setupSyncWatch() {
this.board.watchNet('SYNC', (high) => {
if (high) this.phase = 0;
});
}
_drive(nibble) {
for (let i = 0; i < 4; i++) {
this.board.setNet(`D${i}`, ((nibble >> i) & 1) === 1);
}
}
step() {
// Pre-drive D pins for the upcoming phase. The chip processes
// phases 0..7 = A1, A2, A3, M1, M2, X1, X2, X3. Our `phase` field
// is the COUNT of phases the chip has already executed in this
// cycle. So phase=3 means "the chip has done A1+A2+A3, next tick
// will be M1" — that's when we drive the opcode high nibble.
// phase=4 means "next tick is M2" — drive low nibble.
if (this.phase === 3) {
const byte = this.program[this.observedPc & 0xFFF] || 0;
this._drive((byte >> 4) & 0xF);
} else if (this.phase === 4) {
const byte = this.program[this.observedPc & 0xFFF] || 0;
this._drive(byte & 0xF);
}
this.board.advanceNanos(CLOCK_NS);
// Sample address nibbles after the chip's drives complete.
if (this.phase === 0) this.pcLow = this.board.readBus('D', 4);
else if (this.phase === 1) this.pcMid = this.board.readBus('D', 4);
else if (this.phase === 2) this.pcHigh = this.board.readBus('D', 4);
// After A3 we have the full PC the chip is about to fetch from.
if (this.phase === 2) {
this.observedPc = this.pcLow | (this.pcMid << 4) | (this.pcHigh << 8);
}
if (this.phase >= 0) this.phase = (this.phase + 1) & 7;
}
/** Run one full instruction cycle (8 phases). */
runCycle() { for (let i = 0; i < 8; i++) this.step(); }
/** Run n full cycles. Useful for multi-cycle programs. */
runCycles(n) { for (let i = 0; i < n; i++) this.runCycle(); }
/** The PC the chip drove on the bus during the most recent A1..A3. */
pc() { return this.observedPc; }
}
function fullPinMap() {
const m = {
SYNC: 'SYNC', RESET: 'RESET', TEST: 'TEST',
@ -115,12 +203,94 @@ describe('Intel 4004 chip', () => {
});
describe('instruction set', () => {
it.todo('NOP advances PC by 1');
it.skipIf(skip)('NOP advances PC by 1', async () => {
// [NOP, NOP, NOP, NOP] — every cycle PC increments by 1.
const prog = [0x00, 0x00, 0x00, 0x00];
const board = new BoardHarness();
await bootChip(board);
const bus = new Bus4004(board, prog);
const pcs = [];
for (let cyc = 0; cyc < 4; cyc++) {
bus.runCycle();
pcs.push(bus.pc());
}
// Cycle 0 fetched at PC=0; cycle 1 at PC=1; etc.
expect(pcs).toEqual([0, 1, 2, 3]);
board.dispose();
});
it.skipIf(skip)('JUN jumps to absolute 12-bit address', async () => {
// Prog: JUN 0x123 (bytes 0x41 0x23) at addr 0; rest zeros.
const prog = new Uint8Array(0x200);
prog[0] = 0x41; prog[1] = 0x23; // JUN target=0x123
const board = new BoardHarness();
await bootChip(board);
const bus = new Bus4004(board, prog);
// Cycle 0: fetch 0x41 (JUN opcode); 2-byte op.
// Cycle 1: fetch 0x23 (operand); execute → PC = 0x123.
// Cycle 2: fetch at PC=0x123 (NOP from the all-zero region).
bus.runCycles(3);
expect(bus.pc()).toBe(0x123);
board.dispose();
});
it.skipIf(skip)('JMS pushes return address and BBL pops it', async () => {
// Prog: JMS 0x010, NOP, ... ; at 0x010: BBL 5
const prog = new Uint8Array(0x100);
prog[0] = 0x50; prog[1] = 0x10; // JMS 0x010
prog[2] = 0x00; // NOP (return target after BBL)
prog[0x10] = 0xC5; // BBL 5
const board = new BoardHarness();
await bootChip(board);
const bus = new Bus4004(board, prog);
// Cycle 0+1: JMS opcode + operand fetch → PC = 0x010.
// Cycle 2: chip fetches BBL at 0x010 → end of cycle PC = 0x002.
// Cycle 3: chip fetches NOP at 0x002 → end of cycle PC = 0x003.
// Cycle 4: chip starts fetch at 0x003. We need cycle 4's A1/A2/A3
// to OBSERVE the post-NOP PC (since bus.pc() reports the address
// the chip is currently driving on the bus).
bus.runCycles(5);
expect(bus.pc()).toBe(0x003);
board.dispose();
});
it.skipIf(skip)('JCN with C4 jumps when TEST pin is logic-0', async () => {
// Prog at 0:
// JCN 0x1, 0x10 ; jump-if-test-low to 0x010 (C4=1)
// ...
// at 0x010: zeros (target)
const prog = new Uint8Array(0x80);
prog[0] = 0x11; prog[1] = 0x10; // JCN C4=1, target page-low=0x10
const board = new BoardHarness();
await bootChip(board);
// TEST pin LOW (false) means "logic 0" per [M4] p. 14 — JUMP IF TEST=logic-0
board.setNet('TEST', false);
const bus = new Bus4004(board, prog);
// Cycle 0+1: JCN opcode + operand → PC = 0x010 if condition met.
// Cycle 2: chip drives PC = 0x010 in A1..A3 (observed).
bus.runCycles(3);
expect(bus.pc()).toBe(0x010);
board.dispose();
});
it.skipIf(skip)('JCN does not jump when condition is false', async () => {
const prog = new Uint8Array(0x80);
prog[0] = 0x11; prog[1] = 0x10; // JCN C4=1, target=0x10
prog[2] = 0x00; // fallthrough = NOP
const board = new BoardHarness();
await bootChip(board);
// TEST pin HIGH means "logic 1" → JCN with C4=1 not taken.
board.setNet('TEST', true);
const bus = new Bus4004(board, prog);
// Cycle 0+1: JCN; not taken → PC = 0x002.
// Cycle 2: chip drives PC = 0x002 in A1..A3 (observed).
bus.runCycles(3);
expect(bus.pc()).toBe(0x002);
board.dispose();
});
it.todo('LDM loads the immediate nibble into the accumulator');
it.todo('JCN conditionally jumps based on TEST/CY/ACC zero');
it.todo('FIM loads an 8-bit immediate into a register pair');
it.todo('JMS pushes return address and jumps');
it.todo('BBL pops return address into PC');
});
describe('integration', () => {