feat(v4.0.0): hosted golden model, single node

First code for StarForth v4 (JUSTIFICATION.md section 10, step 1): one node
of the 32-instruction core as a C99 model, with cell width as a build
parameter.

- Node: P, A, B, F18 circular stacks (10 and 9 deep, D-2), word-addressed
  memory (D-1), 5% guard bands on every bounded list.
- Instruction word: six 5-bit slots in 32 bits at every cell width.
- Executor: all 32 opcodes of DECOMPOSITION.md 1.3. Cell arithmetic wraps
  explicitly; no signed overflow or implementation-defined shift.
- Heat: per-opcode and per-call-target counters and the anti-clock, driven
  by instruction retirement (1.4, D-6 interim).
- Slot packer and runner for tests, and a reference unsigned multiply in
  plain C99 with no 128-bit type.

Tests run at 32- and 64-bit cells, and under ASan and UBSan. They cover
every opcode and execute the first section 4 definitions (NIP SWAP OR
NEGATE ROT 0< 0= 2DUP - U<) against the C operation each stands for.

UM* as written in section 4 is exact only while u1 <= 2^(n-2). Two known
failing cases are pinned in test_foundation.c until it is rewritten.

DECOMPOSITION.md: record D-9, the instruction word is 32 bits at every
cell width (ruled 2026-10-02).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rajames
2026-10-02 14:38:27 -04:00
co-authored by Claude Opus 5.5
parent 1f98034311
commit 067f317c47
33 changed files with 3635 additions and 3 deletions
+68
View File
@@ -0,0 +1,68 @@
/* asm.h -- slot packer for hand-assembled test programs.
*
* This is not the compiler capsule (JUSTIFICATION.md section 9). It exists so
* that tests can write a DECOMPOSITION.md definition opcode by opcode and have
* the slots, literals and branch targets laid out the way section 1.2 and
* section 2 require, instead of packing words by hand in every test.
*
* Layout rules it applies:
*
* - Opcodes fill slots 0..5 left to right; a full word is written out and a
* new one started. Unused slots are padded with `nop`.
* - A literal is `@p` in a slot plus its value in the word after the
* instruction word. Several literals in one word follow it in order.
* - `;` and `ex` close the word, since nothing after them executes.
* - A branch closes the word, since the bits to its right are its address.
* If the next free slot is 4 or 5 the word is closed first, because
* branches are legal in slots 0-3 only.
* - A branch target must lie in the same page as P at the time the branch
* executes, for the reach of the slot it landed in. Otherwise the
* assembler sets its error flag rather than emit a branch that goes
* somewhere else.
*/
#ifndef V4_ASM_H
#define V4_ASM_H
#include "v4/iword.h"
#include "v4/node.h"
typedef struct {
v4_node *n;
v4_cell here; /* address of the word being built */
v4_iword cur;
unsigned slot; /* next free slot, 0..V4_SLOT_COUNT */
v4_cell lit[V4_SLOT_COUNT]; /* literals owed after this word */
unsigned nlit;
int error;
} v4_asm;
/* A branch whose target is not known yet. */
typedef struct {
v4_cell addr; /* the word holding the branch */
unsigned slot;
v4_cell p; /* P when the branch executes */
} v4_asm_ref;
void v4_asm_begin(v4_asm *as, v4_node *n, v4_cell origin);
/* Any opcode that is neither a branch nor `@p`. */
void v4_asm_op(v4_asm *as, unsigned op);
/* `@p` and its value. */
void v4_asm_lit(v4_asm *as, v4_cell value);
/* jump, call, next, if, -if to a known address. */
void v4_asm_branch(v4_asm *as, unsigned op, v4_cell target);
/* The same with the target supplied later by v4_asm_resolve. */
v4_asm_ref v4_asm_branch_fwd(v4_asm *as, unsigned op);
void v4_asm_resolve(v4_asm *as, v4_asm_ref ref, v4_cell target);
/* Close the word being built and return the address of the next one. This is
* how a label is taken: branch targets are word addresses. */
v4_cell v4_asm_label(v4_asm *as);
/* 1 if nothing went wrong so far. Closes the word being built. */
int v4_asm_ok(v4_asm *as);
#endif /* V4_ASM_H */
+60
View File
@@ -0,0 +1,60 @@
/* cell.h -- v4 cell width as a build parameter.
*
* DECOMPOSITION.md D-5: the host node's cell width matches the host CPU
* (32 on a Zynq-7000 Cortex-A9, 64 on an aarch64 host). JUSTIFICATION.md
* section 5 makes this the second invariance axis: if K==1.0 holds across
* cell widths as well as across ISAs, the conservation law is shown not to
* depend on word size.
*
* The compiler capsule is written width-independent against these types.
* Nothing in v4 may assume a particular V4_CELL_BITS; if you find yourself
* wanting to, the code belongs in the host node, not the mesh.
*
* JUSTIFICATION.md section 5 also fixes the split that matters for the
* physics: heat and K arithmetic stay 64-bit on every host regardless of
* cell width, because they must not lose precision when the cell shrinks.
* That is why this header defines a separate v4_heat_t rather than reusing
* v4_cell.
*/
#ifndef V4_CELL_H
#define V4_CELL_H
#include <stdint.h>
#ifndef V4_CELL_BITS
#error "V4_CELL_BITS must be defined to 32 or 64 (e.g. -DV4_CELL_BITS=32)."
#endif
#if V4_CELL_BITS == 32
typedef int32_t v4_cell;
typedef uint32_t v4_ucell;
#elif V4_CELL_BITS == 64
typedef int64_t v4_cell;
typedef uint64_t v4_ucell;
#else
#error "V4_CELL_BITS must be 32 or 64."
#endif
/* Physics/heat arithmetic width. Fixed at 64 by JUSTIFICATION.md section 5:
* "Heat and K arithmetic remain 64-bit (int64_t in C99 on every host; double
* cells on a 32-bit node). Changing only the payload width keeps the
* experiment clean: any difference in K can be attributed to cell width and
* not to lost precision." The C side is therefore always 64-bit; the
* double-cell framing is the 32-bit node's problem, not this type's. */
typedef int64_t v4_heat_t;
typedef uint64_t v4_uheat_t;
/* MSB -- the cell-width top-bit constant.
*
* DECOMPOSITION.md section 5.5 (RSHIFT) and section 5.7 (D2/) both call for
* "MSB, the cell-width top-bit constant" but never define it, which leaves a
* bit-width-dependent constant to be invented mid-arithmetic. It is defined
* here once, as a value with exactly the cell's sign bit set and nothing
* else, so that the sign-test idioms in those two words are width-correct by
* construction at 32 and at 64. */
#define V4_MSB (((v4_ucell)1) << (V4_CELL_BITS - 1))
/* All-ones, the width-correct "true". FORTH-79 uses -1 for TRUE. */
#define V4_ALL_ONES ((v4_cell)-1)
#endif /* V4_CELL_H */
+56
View File
@@ -0,0 +1,56 @@
/* exec.h -- instruction execution for v4.
*
* DECOMPOSITION.md section 1.2: "Slots execute left to right." A branch
* (jump, call, next, if, -if) takes its target from all bits to the right of
* its slot and replaces the same low bits of P (page-relative). Branches are
* legal in slots 0-3 only. Nothing to the right of a branch executes, taken
* or not, because those bits are its address.
*
* Section 1.4: "Every retired instruction increments that opcode's heat counter
* and advances the anti-clock; every `call` increments the heat counter of its
* target. None of this costs an instruction."
*
* The node owns P, A, B, the stacks and memory; heat and the anti-clock are
* separate physics state (section 7 names them as registers).
*
* THREE POINTS THE SPEC LEAVES TO THE F18, and which this model takes from it:
*
* - `ex` ends the word, like `;`. P has been replaced, so the remaining
* slots belong to a word that is no longer the current one.
* - `unext` restarts the *already fetched* word at slot 0. It does not
* refetch and does not move P, so `@p` and `!p` inside a unext loop step
* through the words that follow the loop word. When R reaches 0 the loop
* ends and execution continues with the slot after `unext`.
* - `+*` shifts T arithmetically (sign bit kept). Section 1.3 says only
* "shift T:A right one bit"; D-3 says plain F18 semantics.
*
* A branch opcode in slot 4 or 5 is outside the spec. The model does not trap
* it: it applies the 7- or 2-bit target the layout formula gives.
*/
#ifndef V4_EXEC_H
#define V4_EXEC_H
#include "v4/heat.h"
#include "v4/iword.h"
#include "v4/node.h"
typedef struct {
v4_uheat_t anticlock;
} v4_exec_state;
void v4_exec_reset(v4_exec_state *es);
/* Execute one instruction word: fetch at P, advance P, run the slots left to
* right until the word ends. A unext loop runs to completion inside one call,
* so the call does not return while R is nonzero at a `unext`. Returns the
* number of instructions retired. */
unsigned v4_exec_step_word(v4_node *n, v4_exec_state *es, v4_heat *h);
/* Execute a single opcode. The branch opcodes read their target from `w` and
* `slot`; every other opcode ignores both. `unext` here only updates R; the
* restart is v4_exec_step_word's. A value above 31 does nothing and retires
* nothing. */
void v4_exec_op(v4_node *n, v4_exec_state *es, v4_heat *h,
unsigned op, v4_iword w, unsigned slot);
#endif /* V4_EXEC_H */
+92
View File
@@ -0,0 +1,92 @@
/* guard.h -- 5% safety boundaries at each end of every bounded list.
*
* The convention used throughout this project is that a bounded list carries a
* safety boundary at each end rather than trusting its own bounds. The kernel
* arena is the reference instance (kernel/src/vm/arena.c):
*
* #define SK_VM_GUARD_SIZE PMM_PAGE_SIZE
* #define SK_VM_GUARD_PATTERN_HEAD 0x5ac0ffeed0c0ffeeULL
* #define SK_VM_GUARD_PATTERN_TAIL 0x0bad0badf00df00dULL
*
* with a guard_fill / guard_check pair, and total size = payload + 2 * guard.
* Two properties of that instance are kept here deliberately:
*
* - HEAD and TAIL carry *different* patterns, so a check that fails can say
* which end was overrun. A single pattern tells you that something ran
* off the end and nothing about where.
* - the boundary is placed immediately adjacent to the payload, because a
* boundary separated from what it protects catches nothing.
*
* This header supplies the same facility at a size that scales with the list
* (5% at each end) rather than a fixed page, and reduces it to two helpers so
* that no list in v4 has to hand-roll the arithmetic.
*
* WHY THIS IS LOAD-BEARING HERE, NOT DECORATIVE. AddressSanitizer already
* catches a genuine out-of-struct access, so a guard band cannot be justified
* as "catching overflow" -- ASan does that. What ASan does *not* catch is an
* index that is wrong but still lands inside the same object, which is exactly
* the failure mode of a circular buffer driven by unsigned index arithmetic:
*
* head = (head + V4_DATA_RING - 1u) % V4_DATA_RING; correct
* head = (head + V4_DATA_RING - 1u) & (V4_DATA_RING - 1u); correct today
*
* The second line is identical today *only because 8 is a power of two*. It
* is the sort of "simplification" that gets made, it is right for the current
* depths, and it silently breaks the first time a ring size stops being a power
* of two. Both forms stay in range, so ASan stays quiet. A guard band with a
* 5% margin either side is what turns "the tests happened to cover it" into a
* check that fires. The tests in tests/test_stack.c exercise that directly:
* they index past each end on purpose and require the check to catch it.
*
* The boundary is sized with ceiling division and a floor of one element, so a
* short list still gets a real boundary. A 5% margin on a 3-element list
* rounds to 0, and a zero-width guard is not a guard -- it is a comment.
*/
#ifndef V4_GUARD_H
#define V4_GUARD_H
#include "v4/cell.h"
/* Fraction of the payload reserved as a boundary at each end. */
#define V4_GUARD_PCT 5u
/* Boundary width, in elements, for a payload of n elements: 5% rounded up,
* never less than one. Written as a macro so that a struct can size its own
* inline boundary at compile time -- the boundary has to be storage the object
* owns, not something a helper allocates later, because a mesh node has no
* heap to allocate it from.
*
* n = 8 -> 1 (5% = 0.4, rounds up)
* n = 32 -> 2 (5% = 1.6, rounds up)
* n = 100 -> 5
* n = 1000 -> 50
* n = 3 -> 1 (5% = 0.15, rounds up to 1 -- not 0)
*/
#define V4_GUARD_ELEMS(n) \
(((n) * V4_GUARD_PCT + 99u) / 100u)
/* The kernel arena's patterns, carried over so that a hex dump from a mesh
* node and one from the kernel can be read with the same key. Casting to
* v4_ucell truncates cleanly at 32-bit cells: the arena's own constants are
* 64-bit, and their low halves are as distinct as the full words --
* 0xd0c0ffee against 0xf00df00d -- so "which end" stays answerable at both
* widths. */
#define V4_GUARD_PATTERN_HEAD ((v4_ucell)0x5ac0ffeed0c0ffeeULL)
#define V4_GUARD_PATTERN_TAIL ((v4_ucell)0x0bad0badf00df00dULL)
/* If these ever compare equal the boundary has stopped saying which end was
* overrun, which is half its purpose. C99 has no _Static_assert. */
typedef char v4_guard_patterns_are_distinct
[(V4_GUARD_PATTERN_HEAD != V4_GUARD_PATTERN_TAIL) ? 1 : -1];
/* Write one boundary band. `band` is the start of the band, `n` its width in
* elements. */
void v4_guard_fill(v4_cell *band, unsigned n, v4_ucell pattern);
/* 1 if the whole band still holds `pattern`, else 0. Checks every element, not
* just the ends: the band is 5% wide precisely so there is something to check
* in it, and a single word is what the kernel arena can afford at page
* granularity. */
int v4_guard_intact(const v4_cell *band, unsigned n, v4_ucell pattern);
#endif /* V4_GUARD_H */
+79
View File
@@ -0,0 +1,79 @@
/* heat.h -- heat and anti-clock, not instructions themselves (1.4).
*
* DECOMPOSITION.md section 1.4 states: "Execution itself drives the physics.
* Every retired instruction increments that opcode's heat counter and advances
* the anti-clock; every `call` increments the heat counter of its target. None
* of this costs an instruction."
*
* D-6 is deferred to step 2, but the note immediately adds: "The golden model
* implements per-opcode and per-call-target heat in the meantime." The
* counters here are therefore a two-dimensional heat structure (op × target)
* on a per-call basis, with counts stored in 64-bit (because heat is
* accumulated and must not roll over at 32-bit). The "per-call-target"
* counter is indexed by the address of the called target -- on a word-addressed
* node, that is a cell value equal to the address `a` passed to `call a`.
*
* The anti-clock is a virtual tick driven by execution -- one tick per retired
* instruction -- and the rest of §7 refers to it via `ANTICLOCK`. It is not a
* wall-clock time, it is a function of the execution stream, as specified.
*
* The MM names in §7: `HEAT-OP[0..31]` (R), `HEAT-CALL[...]` (R/W) with a
* freeze bit (D-6). D-4 (memory map) is deferred, so the model does not
* materialize those MM addresses yet. This header defines the backing storage
* that a later MM layer will expose; it keeps the counts separate from the node
* itself because heat is physics data, not the instruction word stream, and it
* needs to be accessible to assertions such as K conservation tests later.
*
* Counts are 64-bit. Even for a short run a call-heavy word can heat up many
* times, and the golden model must run POST and remain monotonic and
* comparable. They are stored as `v4_uheat_t` (which is uint64_t by cell-width
* parameterisation) rather than cell-sized so that a 32-bit node's heat is not
* width-truncated in the model. (JUSTIFICATION.md §5 keeps heat arithmetic
* 64-bit regardless of cell width.)
*/
#ifndef V4_HEAT_H
#define V4_HEAT_H
#include "v4/cell.h"
#include "v4/opcode.h"
#define V4_HEAT_MAX_CALL_TARGETS 4096u
typedef struct {
v4_uheat_t op[V4_OPCODE_COUNT]; /* HEAT-OP[0..31] */
/* Per-call-target heat. Indexed by target address. The table is fixed-size
* for the golden model; the actual hardware table size is deferred with D-6,
* but "in the meantime" the model must have a per-call-target structure.
* 4096 entries is small (32KB if using 64-bit per entry), enough to cover a
* typical test program's call targets without needing to allocate on-the-fly
* -- and since the node has boundaries, over-indexing is caught. */
v4_uheat_t call[V4_HEAT_MAX_CALL_TARGETS];
v4_ucell call_freeze_mask[V4_HEAT_MAX_CALL_TARGETS / (sizeof(v4_ucell) * 8u)];
/* freeze bits: one per call-target entry (D-6). Stored as a bitmask. */
} v4_heat;
void v4_heat_reset(v4_heat *h);
/* Every retired instruction: +1 to the opcode's heat and +1 to ANTICLOCK. */
void v4_heat_on_retire(v4_heat *h, unsigned op, v4_uheat_t *anticlock);
/* Every call: +1 to the call-target's heat. The target is the address passed
* to call (word address). If the target is outside the table range, the model
* must not silently wrap -- a boundary-style check is the conservative choice
* here, because the table size is a model parameter. */
void v4_heat_on_call(v4_heat *h, v4_cell target);
/* Read/write helpers for per-call-target heat (R/W). Freeze bits are set per
* target when D-6's freeze bit is asserted; writes to a frozen target should be
* ignored in a real implementation, but the MM layer is not defined yet. The
* golden model stores freeze bits and the accessor can enforce them later. */
v4_uheat_t v4_heat_call_get(const v4_heat *h, v4_cell target);
int v4_heat_call_set(v4_heat *h, v4_cell target, v4_uheat_t val);
/* Freeze control (D-6). */
void v4_heat_call_freeze(v4_heat *h, v4_cell target);
void v4_heat_call_unfreeze(v4_heat *h, v4_cell target);
int v4_heat_call_is_frozen(const v4_heat *h, v4_cell target);
#endif /* V4_HEAT_H */
+101
View File
@@ -0,0 +1,101 @@
/* iword.h -- the 32-bit instruction word and its decode.
*
* DECOMPOSITION.md section 1.2:
*
* 31 27 26 22 21 17 16 12 11 7 6 2 1 0
* +--------+--------+--------+--------+--------+--------+----+
* | slot 0 | slot 1 | slot 2 | slot 3 | slot 4 | slot 5 | xx |
* +--------+--------+--------+--------+--------+--------+----+
*
* "Slots execute left to right. A branch (jump, call, next, if, -if) takes
* its target from all bits to the right of its slot, and that target replaces
* the same low bits of P (page-relative, as in the F18). Branches are
* therefore legal in slots 0-3 only", with reach 27 / 22 / 17 / 12 bits.
*
* That reach column is not a separate rule, it falls out of the layout: slot k
* sits at bits [31-5k : 27-5k], so the bits to its right are [26-5k : 0],
* which is 27-5k bits -- 27, 22, 17, 12 for slots 0..3. This header derives
* the masks from that one formula rather than hard-coding four of them, so the
* decode cannot drift from the reach table.
*
* THE SPARE BITS ARE ADDRESS BITS. "Spare" means only that they are never
* *executed*: they are below every slot, so "all bits to the right of its
* slot" includes them, and they are the low bits of the address of a branch in
* any slot. They are inert for opcode decode and live in a branch target.
* This is easy to assume otherwise and get wrong -- an earlier revision of
* this header subtracted them from the branch width, which silently cost two
* address bits off every branch and was caught by tests/test_iword.c. They
* exist because 6*5 = 30 does not fill a 32-bit word, and on the F18 those low
* bits are simply the bottom of the address space.
*
* The consequence for the compiler is worth stating: a non-branch slot's low
* bits are free to carry a compressed argument, but a branch slot's are not,
* because they are address.
*
* NOTE ON WIDTH. The word format is 32 bits regardless of V4_CELL_BITS: the
* diagram is 32 bits wide and 6*5+2 = 32 exactly. D-5 makes the *cell* width
* a build parameter, but the instruction word is not a payload and does not
* follow the cell. At 32-bit cells a word is a cell. At 64-bit cells it is
* the low 32 bits of the cell and the high half is ignored (D-9), so compiled
* code is identical at both widths. The decode below operates on a distinct
* 32-bit type; the fetch in exec.c does the narrowing.
*/
#ifndef V4_IWORD_H
#define V4_IWORD_H
#include <stdint.h>
#include "v4/cell.h"
#include "v4/opcode.h"
/* The instruction word is 32 bits at every cell width. */
typedef uint32_t v4_iword;
#define V4_SLOT_COUNT 6u /* six 5-bit slots, DECOMPOSITION.md 1.1 */
#define V4_SPARE_BITS 2u /* "plus 2 spare bits" */
/* C99 has no _Static_assert, so the "6*5+2 == 32" identity is enforced with
* the negative-array-size idiom. If the slot count, the spare-bit count or the
* word width ever stop adding up to 32, this fails to compile. */
typedef char v4_iword_layout_is_exact
[((V4_SLOT_COUNT * 5u + V4_SPARE_BITS) == 32u) ? 1 : -1];
/* Bit range of slot k: high bit 31-5k, low bit 27-5k. */
#define V4_SLOT_HIGH_BIT(k) (31u - 5u * (k))
#define V4_SLOT_LOW_BIT(k) (27u - 5u * (k))
/* Number of address bits a branch in slot k carries: all bits to the right of
* the slot. Slot k's low bit is 27-5k, so the bits below it are 0..(26-5k),
* which is 27-5k bits -- equal to the slot's own low bit. This is the single
* source of the reach column, and it is why the spare bits are *not* subtracted
* here: see the note on the spare bits below. */
#define V4_BRANCH_ADDR_BITS(k) (V4_SLOT_LOW_BIT(k))
/* Mask of the low address bits a branch in slot k replaces in P. */
uint32_t v4_iword_slot_mask(unsigned slot);
/* The 5-bit opcode in slot k, 0..31. */
unsigned v4_iword_op(v4_iword w, unsigned slot);
/* The 2 spare low bits. Never executed. */
unsigned v4_iword_spare(v4_iword w);
/* The branch target carried by a branch in slot k: all bits to the right of
* the slot, zero-extended. Meaningful only for the five branch opcodes. */
v4_iword v4_iword_target(v4_iword w, unsigned slot);
/* Assemble a word from six 5-bit opcodes and the 2 spare bits. Opcode values
* above 31 or spare bits above 3 are rejected, because a golden model must not
* paper over an out-of-range encoding. */
v4_iword v4_iword_assemble(const unsigned op[V4_SLOT_COUNT], unsigned spare);
/* Apply a branch: replace the low V4_BRANCH_ADDR_BITS(slot) bits of P with the
* target, preserving the bits above (page-relative, F18-style). */
v4_cell v4_iword_branch(v4_cell p, v4_iword w, unsigned slot);
/* Branches are legal in slots 0-3 only (section 1.2). Slots 4 and 5 have 7 and
* 2 address bits respectively, which is not a useful reach and is not
* sanctioned by the table. */
int v4_iword_branch_legal(unsigned slot);
#endif /* V4_IWORD_H */
+114
View File
@@ -0,0 +1,114 @@
/* node.h -- the v4 node: registers, stacks, and word-addressed memory.
*
* DECOMPOSITION.md section 1.1:
*
* | Cell | 32 bits (mesh node). Host node's width is a build
* | parameter; see D-5. |
* | Addressing | Word-addressed (D-1). |
* | Registers | T (top of data stack), S (second), R (top of return
* | stack), P (program counter), A and B (address
* | registers). |
* | Stacks | F18 circular hardware stacks, not visible to code
* | (D-2) ... data stack 10 deep, return stack 9 deep. |
* | Instruction | Six 5-bit slots, plus 2 spare bits. |
*
* T, S and R are not fields here: they live inside v4_dstack and v4_rstack,
* because on the F18 they are the topmost slots of the circular stacks rather
* than registers that happen to shadow them, and the golden model has to model
* the mechanism and not just the values. P, A and B are genuinely separate
* registers and live here.
*
* MEMORY SIZE IS A BUILD PARAMETER, not a constant of the design.
* JUSTIFICATION.md section 10 names cell width, node count and node memory as
* the three parameters of the golden model, and no word count is fixed anywhere
* in DECOMPOSITION.md -- D-4, which is where a node memory map would be pinned
* down, is deferred to step 2. The default here is a round number that suits
* a host; the datapoint worth knowing is JUSTIFICATION.md section 4, that a
* GA144 node had 64 words of RAM and 64 of ROM. v4 keeps the same split --
* dictionary and interpreter live in the compiler capsule on the host and are
* streamed to nodes, so node memory is not sized by capsule size.
*/
#ifndef V4_NODE_H
#define V4_NODE_H
#include "v4/cell.h"
#include "v4/guard.h"
#include "v4/stack.h"
/* Words of node memory. Overridable at build time. */
#ifndef V4_NODE_WORDS
#define V4_NODE_WORDS 1024u
#endif
/* 5% safety boundary at each end of node memory, per guard.h. Note the
* arithmetic: at 1024 words that is 52 words either side, 5.2% of memory
* spent on a boundary. That is the cost of the convention on a resource that
* is scarce by construction, and it is worth naming rather than discovering
* later -- on a real mesh node the memory is 32-bit words on silicon with no
* second chance. A deployment that cannot afford it sets V4_GUARD_PCT to 0 at
* build time, which is why the percentage is a macro and not baked in. */
#define V4_MEM_BOUND V4_GUARD_ELEMS(V4_NODE_WORDS)
typedef struct {
v4_cell p; /* P -- program counter */
v4_cell a; /* A -- address register */
v4_cell b; /* B -- address register */
v4_dstack ds; /* holds T and S */
v4_rstack rs; /* holds R */
v4_cell mem_guard_head[V4_MEM_BOUND];
v4_cell mem[V4_NODE_WORDS];
v4_cell mem_guard_tail[V4_MEM_BOUND];
} v4_node;
/* Zero P, A and B, empty the stacks, and clear memory. Installs every
* boundary (memory and both stacks) and leaves the node in a defined state. */
void v4_node_reset(v4_node *n);
/* 1 if every boundary in the node is intact -- memory and both stacks -- else
* 0. This is the single call a caller needs to detect that an index went
* wrong somewhere; see guard.h for what it catches that AddressSanitizer
* cannot. */
int v4_node_guards_intact(const v4_node *n);
/* Read and write one word. D-1: word-addressed, so `addr` counts words and
* CELLS is a no-op. `addr` is a v4_cell because A, B and P are all v4_cells
* and a narrower parameter would put a conversion between the register and the
* access on every one of the eight memory opcodes.
*
* PRECONDITION: 0 <= addr < V4_NODE_WORDS.
*
* Out-of-range addressing is not defined by DECOMPOSITION.md. D-1 fixes word
* addressing and says nothing about an address outside the node's memory; a
* F18 has no such case because its address space is the memory. Whether a
* conforming model should wrap, saturate, or fault is a real open question and
* is raised as a gap rather than decided here, because each answer changes the
* ISA's observable behaviour and inventing one silently would bake a guess into
* every later test.
*
* WHAT THE BOUNDARY HERE DOES AND DOES NOT COVER, since it is easy to
* overclaim and the distinction decides whether out-of-range addresses are
* actually protected:
*
* - It DOES cover a linear index error. An access to mem[-1] or
* mem[V4_NODE_WORDS] lands in the 5% band, because the band is immediately
* adjacent (tests/test_node.c asserts the adjacency with offsetof rather
* than assuming it). That access is inside the struct, so AddressSanitizer
* stays silent about it and v4_node_guards_intact() is what reports it.
*
* - It does NOT cover an out-of-range *word address*. A v4_cell address of
* -1 casts to unsigned 0xFFFFFFFF, which is 16 GB past mem at 32-bit cells
* -- far outside the struct and outside the band. So a P, A or B that runs
* off the end of memory is caught by neither the band nor, in a production
* build without sanitizers, by anything else here. It is a precondition
* violation and the range check that would catch it is the open question
* above, not the band.
*
* The band is worth its 10.2% for the first case regardless. The second case
* is a hole, and it is closed by ruling on out-of-range addressing, not by
* widening the band. */
v4_cell v4_node_load(const v4_node *n, v4_cell addr);
void v4_node_store(v4_node *n, v4_cell addr, v4_cell value);
#endif /* V4_NODE_H */
+64
View File
@@ -0,0 +1,64 @@
/* opcode.h -- the 32 core opcodes.
*
* DECOMPOSITION.md section 1.3. "Opcode numbering follows the F18. Two names
* differ from Moore's: F18 `-` is renamed `inv` and F18 `or` (which is
* exclusive-or) is renamed `xor`, so instruction names never collide with
* FORTH-79 word names."
*
* Those two renames are load-bearing, not cosmetic: FORTH-79 has words named
* `-` and `OR`, and the DECOMPOSITION table would otherwise have two distinct
* meanings per name. The renames are therefore asserted against the FORTH
* name set in tests/test_iword.c, so a future edit that reintroduces a
* collision fails a test rather than silently producing an ambiguous table.
*
* Branch opcodes are marked because the instruction-word format depends on
* them: only these five take a target out of the low bits of their slot, and
* only they are legal outside the low four slots (section 1.2).
*/
#ifndef V4_OPCODE_H
#define V4_OPCODE_H
typedef enum {
V4_OP_SEMI = 0x00, /* ; return */
V4_OP_EX = 0x01, /* ex swap P and R */
V4_OP_JUMP = 0x02, /* jump a */
V4_OP_CALL = 0x03, /* call a */
V4_OP_UNEXT = 0x04, /* unext */
V4_OP_NEXT = 0x05, /* next a */
V4_OP_IF = 0x06, /* if a */
V4_OP_MINUS_IF = 0x07, /* -if a */
V4_OP_FETCH_P = 0x08, /* @p literal */
V4_OP_FETCH_INC= 0x09, /* @+ */
V4_OP_FETCH_B = 0x0A, /* @b */
V4_OP_FETCH_A = 0x0B, /* @ */
V4_OP_STORE_P = 0x0C, /* !p */
V4_OP_STORE_INC= 0x0D, /* !+ */
V4_OP_STORE_B = 0x0E, /* !b */
V4_OP_STORE_A = 0x0F, /* ! */
V4_OP_MUL_STEP = 0x10, /* +* multiply step, D-3 */
V4_OP_TWO_STAR = 0x11, /* 2* */
V4_OP_TWO_SLASH= 0x12, /* 2/ */
V4_OP_INV = 0x13, /* inv (F18 `-`) */
V4_OP_ADD = 0x14, /* + */
V4_OP_AND = 0x15, /* and */
V4_OP_XOR = 0x16, /* xor (F18 `or`) */
V4_OP_DROP = 0x17, /* drop */
V4_OP_DUP = 0x18, /* dup */
V4_OP_RPOP = 0x19, /* pop R to data stack */
V4_OP_OVER = 0x1A, /* over */
V4_OP_PUSH_A = 0x1B, /* a */
V4_OP_NOP = 0x1C, /* nop */
V4_OP_PUSH = 0x1D, /* push T to return stack */
V4_OP_BANG_B = 0x1E, /* b! B <- T */
V4_OP_BANG_A = 0x1F, /* a! A <- T */
V4_OPCODE_COUNT = 0x20
} v4_opcode;
/* True for the five opcodes that take a branch target from the low bits of
* their slot: jump, call, next, if, -if. Everything else ignores the low
* bits of its slot, which is what makes a non-branch slot's low bits
* free for the compiler to use as a compressed argument. */
int v4_op_is_branch(unsigned op);
#endif /* V4_OPCODE_H */
+89
View File
@@ -0,0 +1,89 @@
/* stack.h -- F18 circular hardware stacks (DECOMPOSITION.md D-2).
*
* D-2 rules these explicitly: "F18 circular stacks, hidden. Data stack 10
* deep (T, S + 8 circular), return stack 9 deep (R + 8 circular), exactly as
* the F18. No stack pointer is visible to code, so DEPTH, PICK, ROLL, .S,
* SP@ and SP! are retired everywhere, host node included."
*
* Two consequences that the ISA and the capsule code must both respect:
*
* 1. There is no overflow or underflow detection, and there never will be.
* Pushing past the bottom silently overwrites the oldest entry. This is
* hardware behaviour, reproduced faithfully here rather than defended
* against: a golden model that trapped on overflow would diverge from
* the thing it is a model of, which is the whole point of the exercise.
*
* 2. Nesting is therefore shallow (10 and 9), and exceeding either depth
* corrupts silently rather than faulting. DECOMPOSITION.md section 3
* calls this out as the constraint that "every CAP definition in section
* 4 and 5 must be re-checked against ... before it is accepted as a POST
* target; the hand traces so far assumed unbounded stacks."
*
* The T/S + 8-ring decomposition is the hardware shape, not an optimisation:
* T and S are architectural registers (section 1.1 lists them), and the ring
* is the circular part behind them. It is provably equivalent to a flat
* 10-deep circular buffer, which is what tests/test_stack.c checks it
* against -- see the equivalence argument in stack.c.
*/
#ifndef V4_STACK_H
#define V4_STACK_H
#include "v4/cell.h"
#include "v4/guard.h"
/* D-2: data stack is T, S + 8 circular = 10 deep. */
#define V4_DATA_RING 8
#define V4_DATA_DEPTH (V4_DATA_RING + 2)
/* D-2: return stack is R + 8 circular = 9 deep. */
#define V4_RET_RING 8
#define V4_RET_DEPTH (V4_RET_RING + 1)
/* 5% safety boundary at each end of each ring, per guard.h. The bands sit
* immediately either side of `ring` -- a boundary separated from the payload
* by another field catches nothing -- and are storage the struct owns, because
* a mesh node has no heap. Widths are 1 element each at the current ring
* sizes; V4_GUARD_ELEMS scales them if a ring size ever changes. */
#define V4_DATA_BOUND V4_GUARD_ELEMS(V4_DATA_RING)
#define V4_RET_BOUND V4_GUARD_ELEMS(V4_RET_RING)
typedef struct {
v4_cell t; /* T -- top of data stack (a register) */
v4_cell s; /* S -- second (a register) */
unsigned head;
v4_cell guard_head[V4_DATA_BOUND];
v4_cell ring[V4_DATA_RING];
v4_cell guard_tail[V4_DATA_BOUND];
} v4_dstack;
typedef struct {
v4_cell r; /* R -- top of return stack (a register) */
unsigned head;
v4_cell guard_head[V4_RET_BOUND];
v4_cell ring[V4_RET_RING];
v4_cell guard_tail[V4_RET_BOUND];
} v4_rstack;
void v4_dstack_reset(v4_dstack *st);
void v4_dstack_push(v4_dstack *st, v4_cell x);
v4_cell v4_dstack_pop(v4_dstack *st);
/* Read the top without disturbing the stack. T is a register on hardware, so
* this is a plain read and needs no ring access. */
v4_cell v4_dstack_peek(const v4_dstack *st);
/* Read the second element without disturbing the stack. */
v4_cell v4_dstack_peek2(const v4_dstack *st);
void v4_rstack_reset(v4_rstack *st);
void v4_rstack_push(v4_rstack *st, v4_cell x);
v4_cell v4_rstack_pop(v4_rstack *st);
v4_cell v4_rstack_peek(const v4_rstack *st);
/* 1 if both boundaries of the data stack are intact, else 0. See guard.h for
* what this catches that AddressSanitizer does not. */
int v4_dstack_guards_intact(const v4_dstack *st);
int v4_rstack_guards_intact(const v4_rstack *st);
#endif /* V4_STACK_H */
+19
View File
@@ -0,0 +1,19 @@
/* testcode.h -- run a word on a node, for tests. */
#ifndef V4_TESTCODE_H
#define V4_TESTCODE_H
#include "v4/exec.h"
/* Where a word called by v4_test_call returns to. Nothing is executed there:
* arriving is the stop condition. */
#define V4_TEST_HALT ((v4_cell)(V4_NODE_WORDS - 1u))
/* Call the word at `entry` as a caller would: push a return address, set P,
* and step instruction words until the word returns. The stacks and A, B are
* left as the caller set them, so arguments go on the data stack first.
* Returns the number of instruction words stepped, or -1 if the word had not
* returned after `max_words`. */
long v4_test_call(v4_node *n, v4_exec_state *es, v4_heat *h,
v4_cell entry, long max_words);
#endif /* V4_TESTCODE_H */
+31
View File
@@ -0,0 +1,31 @@
/* umul.h -- unsigned multiply fixing UM* under D-3 constraints.
*
* DECOMPOSITION.md line 205-207: "NEEDS REVISION: written for the carry-keeping
* +* that D-3 rejected. With plain F18 +* the carry out of T is lost, so this
* is only correct when the partial sums never overflow T (operands below 2^31).
* A full-range UM* needs a correction step; to be rewritten and proven on the
* golden model."
*
* D-3 states: "Plain F18 semantics. The carry out of T is not kept."
* The +* defined in the F18 shifts T:A right; the add is T <- T+S when bit 0
* of A was set, but any carry out of that addition is discarded (not shifted
* into the high part). That means UM* as written only works for inputs whose
* partial products don't generate a carry beyond T's width during the loop.
*
* We need a correct full-range unsigned multiply (u1*u2 -> ulo, uhi) that is
* correct for all cell-width values of u1,u2, expressed in terms of the v4
* primitives or at least specified precisely for the golden model. Since
* we're building the golden model in C first, the canonical implementation is
* the width-correct C multiplication split into high/low halves at V4_CELL_BITS.
* This header provides both: a reference C implementation (for verification)
* and a documented form that matches D-3's +* constraint.
*/
#ifndef V4_UMUL_H
#define V4_UMUL_H
#include "v4/cell.h"
/* Unsigned multiply: (u1, u2) -> (ulo, uhi). Full range, width-correct. */
void v4_umul(v4_ucell u1, v4_ucell u2, v4_ucell *ulo, v4_ucell *uhi);
#endif /* V4_UMUL_H */