Skip to content

fix(golem): asm and run refuse a Source that assembles to zero words#222

Merged
andraderaul merged 1 commit into
feat/golem-v2-unit-2from
fix/golem-refuse-empty-image
Jul 20, 2026
Merged

fix(golem): asm and run refuse a Source that assembles to zero words#222
andraderaul merged 1 commit into
feat/golem-v2-unit-2from
fix/golem-refuse-empty-image

Conversation

@andraderaul

Copy link
Copy Markdown
Owner

Follow-up to #220, validating an operator-reported symptom: run on an empty Source starts a run that never ends.

The bug

Blank lines and comments assemble to zero words without errorassemble('') returns ok: true with an empty image. build() then reported assembled 0 words — editor locked, and run started the clock over a machine whose every fetch reads 0x00000000: opcode 0x00 with destination r0, an implicit nop. No int 0 ever arrives, so the machine never halts and the Console says running at N/s forever.

The tab never freezes and stop always lands — the clock driver's rAF yield guarantees that by design — but an endless run over nothing has no reading for the operator. (The reference emulator spins the same way: word 0 is an explicit nop in its main switch.)

The fix

build() refuses an image with no words:

nothing to assemble — the Source has no instructions

The editor stays unlocked and no Machine is created. The guard sits in build(), so one check covers asm and run both — and the three-state model is preserved: refusing at the Source → Image boundary means the dead-machine state is unrepresentable rather than handled.

Pinned by two app tests: asm on an empty Source, and run on a comments-only Source (neither locks the editor, neither starts the clock).

Verification

Full pre-commit suite green (biome check, all workspaces' test:run — 355 in golem now), typecheck clean. One changeset, golem patch.

🤖 Generated with Claude Code

Blank lines and comments assemble to an empty image without error, and
run over it started a run that could never halt: every fetch of zeroed
memory decodes as an implicit nop, and no int 0 ever arrives. The
reference emulator spins the same way — word 0 is an explicit nop in its
main switch — but an endless run over nothing has no reading for the
operator.

The guard lives in build(), so one check covers asm and run both. The
Console answers "nothing to assemble — the Source has no instructions"
and the editor stays unlocked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@andraderaul
andraderaul merged commit a56e500 into feat/golem-v2-unit-2 Jul 20, 2026
2 checks passed
@andraderaul
andraderaul deleted the fix/golem-refuse-empty-image branch July 20, 2026 01:22
@vercel

vercel Bot commented Jul 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ascii-art-converter Ready Ready Preview, Comment Jul 20, 2026 1:36am
cyberdeck-golem Ready Ready Preview, Comment Jul 20, 2026 1:36am
glitch-studio Ready Ready Preview, Comment Jul 20, 2026 1:36am

andraderaul added a commit that referenced this pull request Jul 24, 2026
…s, Watchdog, FPU) (#220)

* docs(golem): the v2 glossary — Device, Watchdog, FPU, Interrupção, ISR, Macro

Groundwork for GOLEM//Console v2 (#203). Sharpens Step (devices tick in
lockstep) and Alias (a macro is not an alias), and names the unit-2 vocabulary
the slices will use.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(golem): vendor the unit-2 fixtures, and park unit 3's oracle before it decays (#212)

Closes #204.

The four unit-2 reference sets (2_hello_world, 2_interruption, 2_watchdog,
2_fpu — each .s/.hex/.out) join the unit-1 fixtures under the same provenance
statute as ADR 0019. No product code: this is the oracle every later v2 slice
diffs against, landing first so each slice can go green on its own.

Unit 3's traces are parked with no consumer, and that is the point. The
reference emulators are C from 2017; the oracle is perishable and the window is
open today. The _cache.out traces came pre-generated with the material, so
nothing needed to compile — recorded as such in PROVENANCE rather than claimed
as a run of ours.

Renames the fabricated 2_blt/2_bnz to gen_blt/gen_bnz: their '2_' meant
'generated with poxim2', which stops being readable the moment real unit-2
programs sit beside them.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(golem): the Step event stream, and int N dispatching end to end (#213)

Closes #205.

step(machine) now returns { machine, events } — the one interface change v2
makes to an existing seam, and it is forced rather than chosen: `int 1` and a
division by zero leave byte-identical machines behind, same cause in CR, same
vector in PC. A dispatch is not derivable from a state diff, so it is reported.

The trace formatter and the Console narration consume that one stream. The
formatter emits the reference's [SOFTWARE INTERRUPTION] marker after the two
lines of the instruction that raised it, which is the order a diff against the
reference .out expects. The narration is one Console line per dispatch — never
the Terminal, which is the program's own output.

int N with IE clear is decided as a no-op, not a half-dispatch that writes CR
without jumping; no fixture pins it, and ISA.md records the reasoning.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(golem): Terminal readback, and 2_hello_world green end to end (#214)

Closes #206.

Unit 2's smallest behavior: a byte stored at the Terminal address keeps the
byte readable there. The write no longer returns early — it echoes the
character and then falls through to the ordinary byte write, which is what
gives the readback. The Terminal is a device *and* an address.

The trace formatter gained the reference's [TERMINAL] section, emitted only
when the program printed something, which is why no unit-1 fixture carries one.

2_hello_world now passes both oracles untouched: word-for-word against its .hex
and trace-for-trace against its .out. The v1 assembler needed no change for it.

UNIT_2_RUNNABLE grows by one entry per slice, so the fixture suite is never red
on work not yet done.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(golem): load <name> puts a reference program in the editor (#215)

Closes #207.

The one new Console command of v2. `load` with no argument lists the four
programs with a one-line summary each; an unknown name gets a did-you-mean off
the same edit-distance threshold commands use, generalized out of
`nearestCommand` rather than reimplemented beside it.

The three-state model holds by construction: with a Machine alive the editor is
locked, so there is nothing to load into and `load` says to reset first.

Losing work to a demo is prevented rather than merely warned about. A Source the
operator wrote earns one refusal, and re-issuing the same `load` confirms it —
no new grammar, no dialog. The starter example and an already-loaded program are
not work, so `load watchdog` then `run` stays two commands from a cold start.

The programs reach the bundle through Vite `?raw` imports of the fixture .s
files, deliberately not through the fixture loader: that one goes via node:fs to
keep .hex and .out oracles out of the bundle, and only the sources ship.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(golem): isr, reti and the enai macro — 2_interruption green (#216)

Closes #208.

ISRs become the operator's own code. `isr` gains its target operand and
*jumps*: it captures IPC and CR and then lands on the handler body, which is
what lets an interrupt vector fit in one word — the vector word is the preamble
and the branch at once. `reti` reads as `ret` does; they differ in intent, not
in what they do to the machine.

`enai` is the assembler's first Macro, and the only one. Its expansion was
pinned before a line of it was written: the predicted pair
(addi rX, r0, 64; or fr, fr, rX) appears verbatim at consecutive offsets in all
three unit-2 .hex fixtures. The operand register is scratch, clobbered on the
way through, exactly as the reference assembler does it.

Division by zero (cause 0x01) and invalid instruction (cause 0x2A) now dispatch
when IE is set. An invalid instruction prints no disassembly — there is no
instruction — so the trace entry is the marker lines alone, matching the
reference.

One test-harness fix worth noting: machine.fixtures counted [UFS] effect lines
as Steps, an assumption unit 2 breaks, since an invalid instruction costs a Step
and prints no effects line. It now counts the marker too.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(golem): the Watchdog — 2_watchdog green (#217)

Closes #209.

The first Device with a clock. Its register *is* memory word 0x2020 rather than
a field beside it, which is what makes it readable by the program and dumpable
by `mem` for free — a memory-mapped device whose state lived outside memory
would be a lie.

Ticking happens in `step`, after the PC settles. That ordering is not
incidental: a hardware interrupt saves the address the program would have run
next, which for a taken branch is the branch target. Software interrupts still
save PC + 4, since they are raised mid-instruction. `divert` names that as the
one thing the two kinds disagree about.

The reference pinned the timing exactly — 100 `bun` instructions between the
arming store and the dispatch — which only comes out right because the arming
Step ticks too. That off-by-one is now a documented, tested property rather than
an accident.

The trace needed one subtlety: the effects line of the instruction a hardware
interrupt preempts must report the PC that instruction produced, not the vector
the device then jumped to. The event carries that address, so the formatter
reports the instruction's own result.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(golem): the FPU — 2_fpu green, and all of unit 2 with it (#218)

Closes #210.

The second Device, and the first whose registers do not round-trip: a write
converts an integer to a float, a read converts back by a different rule. So
unlike the Watchdog — whose register simply *is* a memory word — the FPU keeps
its own state on the Machine, which is also exactly what the DEVICES panel
needs.

Devices are now intercepted in readWord/writeWord, the single memory-access
path, so ldw/stw/push/pop agree about them without four copies of the check.

Three behaviours were recovered by reading the reference emulator's source, not
guessed:

- Writes convert numerically (74 becomes 74.0f), which is why 74/8 yields 9.25
  rather than a denormal.
- Reads are **value-dependent**: a whole number comes back as an integer, a
  fractional one as its IEEE-754 bits. z = 9.25 reads 0x41140000, z = 19 reads
  0x13. Indefensible as design, and exactly what the fixture pins in both
  directions.
- The exponent mask is one hex digit short (0x7F80000 for 0x7F800000), so it
  reads mantissa bits. Replicated deliberately: unlike the broken `ble`, this
  only decides how many cycles an operation is said to cost. The fixture's
  divide-costs-4 and multiply-costs-3 only come out under the broken mask.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(golem): the DEVICES panel (#219)

Closes #211, and completes GOLEM//Console v2.

One panel, read-only, where the machinery v1 could not show becomes watchable:
the Watchdog's counter descending a tick per Step, and the FPU's x, y and z as
decoded floats beside the raw words they really are, with the in-flight
operation and its remaining cycles.

The reading model lives in `devicesOf`, a pure function, so "9.25 reads as 9.25
and not as 0x41140000" is a unit test rather than something only a DOM assertion
could reach — the same seam `flagsOf` and `formatMemoryDump` already use.

The IE flag joins the named-flags display. Without it a suppressed dispatch
looks like the machine ignoring the program.

Placed under Flags and sized to its content rather than sharing the stretch, so
it does not squeeze Memory and the Terminal; on a phone the column scrolls and
the countdown still reads.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(golem): one hardware dispatch per Step — Watchdog first, FPU held pending (#221)

* fix(golem): one hardware dispatch per Step — the Watchdog takes it, the FPU waits

When the Watchdog and the FPU ran out on the same Step, both dispatched
and the second divert overwrote CR/IPC with the hardware-1 vector as the
return address — the Watchdog's interrupt lost to a clobber. The
reference emulator does the same, but no fixture exercises the
coincidence, which puts it in the broken-ble category: an accident, not
a semantic, and GOLEM does not replicate it.

Now a Step carries at most one hardware dispatch. The Watchdog (line 1)
takes it; the FPU's completion holds at zero cycles and dispatches whole
on the Step after, with its own cause and a real resume address. With IE
clear the FPU still completes silently, as ISA.md already decided.

dispatchHardware and softwareInterrupt also collapse the three
duplicated divert call-site shapes the review flagged.

Pinned by a hand-written test; documented in ISA.md under "Divergências
deliberadas".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(golem): the DEVICES panel shows the raw word a program reads, plus review cleanups

The panel showed x/y/z raw words as IEEE-754 bits unconditionally, while
a read of the same register yields the integer for whole values — so the
panel said 0x41980000 where a ldw got 0x13. The raw word now uses the
machine's own value-dependent encoding, via the exported fpuWord.

Also from the review:

- UNIT_2_RUNNABLE had finished growing into UNIT_2_PROGRAMS; collapsed.
- nearestCommand was a one-line delegate with one caller; inlined.
- devices.tsx names DeviceReading[] instead of walking
  ReturnType<typeof devicesOf>.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(golem): asm and run refuse a Source that assembles to zero words (#222)

Blank lines and comments assemble to an empty image without error, and
run over it started a run that could never halt: every fetch of zeroed
memory decodes as an implicit nop, and no int 0 ever arrives. The
reference emulator spins the same way — word 0 is an explicit nop in its
main switch — but an endless run over nothing has no reading for the
operator.

The guard lives in build(), so one check covers asm and run both. The
Console answers "nothing to assemble — the Source has no instructions"
and the editor stays unlocked.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(golem): address v2 code-review findings (#223)

Standards and spec findings from the review of #220, none of them
behaviour changes (355 golem tests pass unchanged):

- machine.ts: drop the stray "(ADR 0020 groundwork)" from the
  memory-access-path comment — ADR 0020 is the Control Strip decision,
  not the v3 cache; there is no ADR to cite for future cache work.
- isa.ts: the IE JSDoc claimed only *software* interrupts are gated on
  it, but tickWatchdog and tickFpu gate hardware dispatch on IE too.
- machine.ts: fold the mirrored read/write FPU-register cascades into one
  address→field map (FPU_FIELD); CONTROL stays a special case on both
  paths since it is a command, not a value.
- inspect.ts: the three identical x/y/z device rows become one map.
- PROVENANCE.md: #204 banked the unit-3 cache traces by copying them; the
  spec's "the emulator still compiles" bet went unexercised. Verified it:
  poxim3 compiles clean under clang today, and rerunning it on
  3_memory_access.hex reproduces the committed 3_memory_access_cache.out
  byte-for-byte (bar the trailing blank line already documented). The
  window is confirmed open, not assumed.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant