YACC1 microcode — the control store¶
What the sequencer plays: the format of the control-store image, the meaning of every one of its 64 bits, how the sequencer steps through a record, how the generator writes the records, how to add an instruction, how the two 2026-09-22 bus-fight fixes were found and made, and how the image gets into the card.
Written 2026-09-23 from the YACC1-D tree.
Sources: firmware/microcode/README.md, firmware/microcode/yaccsignaldefine.h and yaccsignaldata2.h,
firmware/microcode/ucode-generator2/ (main.c, controlLine.c, CodeGen.h, code.h, accumulator.c, branch.c,
io.c, memory.c, register.c, Makefile), the image files test.hex / test.hexz / test.123 / cache,
docs/isa/MICROCODE-REVIEW-NOTES.md (hardware model and findings), docs/isa/MICROCODE-REVIEW.md and docs/isa/steps.txt
(the mechanical pass), docs/isa/README.md and docs/isa/*.json (timing diagrams), tools/ucode_wavedrom.py,
tools/ucode_review.py, tools/ucode_send.py, software/ucemu/y1ucemu.c and its README, software/opcodes.h,
software/assembler/yacc1.def, tests/assembler/brur/, tests/ucemu/, embedded/sequencer-card/README.md and
sequencer4/Sequencer4.ino, tests/sequencer/ (run.py, mock_card.py, the boot logs), the sequencer schematics
(hardware/cards/sequencer-logic/eagle/v2.1/*.sch, hardware/cards/sequencer-memory/eagle/v2.1/*.sch, parsed with
Python), hardware/DESIGN-REVIEW-NOTES-control-io.md, docs/system/MACHINE.md, BACKLOG.md.
Conventions: as in ARCHITECTURE.md (- prefix = active low; To verify: marks what the tree does not settle).
1. The control store in one paragraph¶
Every opcode is a record of 64 steps; every step is a 64-bit control word stored as 8 bytes. The
sequencer-memory card holds all 256 records in eight 62256 RAMs (IC1..IC8, one per byte), addressed by the opcode
(CADDR6..13) and the step counter (CADDR0..5). The sequencer-logic card latches the word for the current step into
eight 74LS374 pipeline registers and drives their outputs onto the bus (the ones that are bus signals) or into its
own logic (the ones that are not). A step lasts two clock periods; a record ends at the step that carries
UCODE-COUNT-RESET. The image is produced by a C program, ucodegen, from named signals — nobody writes hex by
hand — and reaches the card's I2C EEPROM over a serial download; at every run-mode boot the card's ATmega copies the
EEPROM into the RAMs, verifies the copy and only then raises READY, which releases -BUS-EN
(firmware/microcode/README.md, embedded/sequencer-card/README.md, docs/isa/MICROCODE-REVIEW-NOTES.md 1.1).
Size: 256 × 64 × 8 = 131,072 bytes (CodeGen.h: MEMORY_SIZE = BYTES_PER_LINE*LINES_PER_INSTRUCTION*INSTRUCTIONS),
exactly the size of test.123.
2. Files and formats¶
All four images live in firmware/microcode/ucode-generator2/ and are written by ucodegen beside its own executable
(controlLine.c dumpCntlMemory(), exe_relative(); "so a Finder double-click regenerates them here").
| File | Format | Consumers |
|---|---|---|
test.123 |
raw binary, 131,072 bytes, record-major: byte b of step s of opcode o is at o*512 + s*8 + b |
software/disassembler/disasm2 (README) |
test.hex |
text, 257 lines: 256 records then a line holding ! |
y1ucemu (load_ucode()), tools/ucode_wavedrom.py (microcode()), tools/ucode_review.py, tools/ucode_send.py (load_records()) |
test.hexz |
byte-identical to test.hex today; the name the Processing loader reads |
embedded/sequencer-card/microcode-loader/simple_microcode_sender_64 |
cache |
what was last sent to the card = test.hex without the ! line, plus one blank 257th line (the Processing sender's habit, ucode_send.py load_cache()); the card holds this image |
both loaders, for differential sends |
2.1 The test.hex record¶
main.c documents the record as "%AIXXXX....XXXX-", controlLine.c writes it:
% cc ii <1024 hex digits = 512 bytes = 64 steps x 8 bytes, step 0 first, byte 0 first> -
%starts a record (START_CHAR);-ends it (END_INS_CHAR); the final line is!(END_ALL).cc= the checksum: the 8-bit sum of the 512 data bytes (checksum += cntlMemory[...], anunsigned char), written as two upper-case hex digits. Every reader ignores it ("Ignore for now"; the card too,tests/sequencer/mock_card.py).ii= the opcode number, two hex digits;ucode_send.pyrefuses an image whose recordidoes not carry numberi.- Each line is therefore 1 + 2 + 2 + 1024 + 1 = 1030 characters (
ucode_send.pyRECORD_LEN + 1).
test.hexz was once the "compressed" variant: the generator writes only the lines a record really used and then pads
with explicit zero bytes to 64 lines (the Z end-marker code is commented out in dumpCntlMemory()), which is why the
two files are identical today and why every reader still accepts a Z as "the rest is zero"
(y1ucemu.c load_ucode(), mock_card.py).
2.2 Reading a word by hand¶
The first record's first line, as the card dumps it at boot (tests/sequencer/boot-run-mode-2026-09-22-reload.log,
Ins=0 LINE:00) and as test.hex holds it:
byte: 0 1 2 3 4 5 6 7
03 D4 FF 00 85 57 03 D1 record $00 step 0
03 D4 FF 00 85 17 03 D1 record $0E (LDAI) step 0 -- the idle word of every other record
With the map of section 3: byte 0 = 03 means bits 0 and 1 set, i.e. -REG-FUNC-RD and -REG-FUNC-LD inactive
(active-low signals are stored as 1 = inactive) and all four REG-RD-ID bits and REG-LD-ID0..1 zero. Byte 2 = FF:
none of the eight strobes in that byte (memory, I/O, TMP) is active. Byte 4 = 85 = 1000 0101: bit 1 is 0, so
-VMA is asserted (the generator's "hack", section 5), -IO-ADDR-LD, -ALU-FUNC, -AC-LD-INV inactive, ALU code 0.
Byte 5 differs between the two records: 57 has bit 6 set = OUT-OFF asserted, which only record $00 carries
(main.c startInstruction(): if (instruction == 0) setSignal("OUT-OFF")), while 17 is the plain idle value. Byte 7 =
D1 = 1101 0001: -BRANCH-RD, -INT-JMP, -2-BYTE-OPERAND-SEL, -DEST-ADDR inactive, the active-high loads zero.
Step 4 of any record reads 02 54 FE ...: byte 0 bit 0 clear = -REG-FUNC-RD asserted, byte 1 bit 7 clear =
-REG-UP asserted, byte 2 bit 0 clear = -MEM-RD asserted — the PC increment of the fetch.
An all-zero word (H-4, section 5.5) is therefore not "nothing": it asserts every active-low line at once.
3. The signal map: every control bit¶
yaccsignaldefine.h defines struct signal { char *name; int chip; int port; int bit; }; yaccsignaldata2.h is the
table. chip and port are the MCP23017 expander (1..4) and port (0 = A, 1 = B) of the sequencer-memory card's loader,
and the byte index in the image is (chip - 1) * 2 + port (controlLine.c bitOn(); y1ucemu.c resolve_signals();
the control-io review checked that this order equals the loader's writeGPIOAB byte order and that all 64 bits land on
the RAM pin that reaches the same-named 74LS374 input on the logic card). "Active" follows the name: a - name is
asserted by a 0 bit, any other name by a 1 bit (main.c setSignal()/clearSignal()). Bus pins are from the
V3.2 pinout (BUS.md); "internal" means the pipeline output stays on the sequencer-logic card.
Byte 0 (chip 1, port A) — register selects¶
| Bit | Signal | Active | Where it goes | Meaning |
|---|---|---|---|---|
| 0 | -REG-FUNC-RD |
0 | bus B7 | select a register for read / count (the card drives the bus on this alone, ARCHITECTURE 4.2) |
| 1 | -REG-FUNC-LD |
0 | bus B8 | select a register for load (card receives) |
| 2 | REG-RD-ID0 |
1 | bus B9 (via IC4, or IC5 from the operand register) | read/count register number bit 0 |
| 3 | REG-RD-ID1 |
1 | bus B10 | bit 1 |
| 4 | REG-RD-ID2 |
1 | bus B11 | bit 2 (card select) |
| 5 | REG-RD-ID3 |
1 | bus B12 | bit 3 (always 0: eight registers) |
| 6 | REG-LD-ID0 |
1 | bus B13 | load register number bit 0 |
| 7 | REG-LD-ID1 |
1 | bus B14 | bit 1 |
Byte 1 (chip 1, port B) — register strobes¶
| Bit | Signal | Active | Where it goes | Meaning |
|---|---|---|---|---|
| 0 | REG-LD-ID2 |
1 | bus B15 | load register number bit 2 (card select) |
| 1 | REG-LD-ID3 |
1 | bus B16 | bit 3 (always 0) |
| 2 | -REG-RD-LO |
0 | bus B17 | put the selected register's low byte on ADATA0..7 → DATA0..7 (the swap path carries only the high byte; with -HL-SWAP the low byte stays inside the card) |
| 3 | REG-LD-LO |
1 | bus B18 as -REG-LD-LO (inverted and gated by N$53 on the logic card) |
load the low byte at the trailing edge |
| 4 | -REG-RD-HI |
0 | bus B19 | put the high byte on ADATA8..15 → DATA8..15, or → DATA0..7 with -HL-SWAP |
| 5 | REG-LD-HI |
1 | bus B20 as -REG-LD-HI (inverted, gated) |
load the high byte at the trailing edge |
| 6 | -REG-DN |
0 | bus B21 | count the read-selected register down at the trailing edge |
| 7 | -REG-UP |
0 | bus B22 | count it up at the trailing edge |
The table's comment "this active low on the bus" on REG-LD-LO/HI records the polarity change: the image stores them
active high, the logic card makes the bus lines active low (-REG-LD-LO = NOT(N$53 AND LREG-LD-LO), IC27/IC31;
docs/isa/MICROCODE-REVIEW-NOTES.md 1.1). The bus-tester's own table (embedded/libraries/YACC/YACC_Common_header.h)
lists them as -REG-LD-LO/HI because it sees the bus side.
Byte 2 (chip 2, port A) — memory, I/O and TMP strobes¶
| Bit | Signal | Active | Where it goes | Meaning |
|---|---|---|---|---|
| 0 | -MEM-RD |
0 | bus B23 | memory drives DATA0..7 from [ADDR] (with -VMA) |
| 1 | -MEM-WR |
0 | bus B24 | RAM/EEPROM write; data taken at the trailing edge (-WE rising) |
| 2 | -IO-RD |
0 | bus B25 | port IOADDR drives DATA0..7 |
| 3 | -IO-WR |
0 | bus B26 | port latches clock at the trailing edge |
| 4 | -TMP-REG-RD0 |
0 | bus B27 | TMP0 drives DATA0..15 |
| 5 | -TMP-REG-LD0 |
0 | bus B28 | TMP0 ← DATA0..15 at the leading edge |
| 6 | -TMP-REG-RD1 |
0 | bus B29 | TMP1 drives DATA0..15 |
| 7 | -TMP-REG-LD1 |
0 | bus B30 | TMP1 ← DATA0..15 at the leading edge |
Byte 3 (chip 2, port B) — address register and port number¶
| Bit | Signal | Active | Where it goes | Meaning |
|---|---|---|---|---|
| 0..3 | ADDR-REG-ID0..3 |
1 | bus C3..C6 (via IC18B) | which register drives ADDR0..15; the generator defaults it to PC in every line (initCurrentLine()) |
| 4..7 | IOADDR0..3 |
1 | bus C7..C10 | the port number; written only in I/O records (io.c setIo()), zero elsewhere |
Byte 4 (chip 3, port A) — bus cycle qualifier and ALU function¶
| Bit | Signal | Active | Where it goes | Meaning |
|---|---|---|---|---|
| 0 | -IO-ADDR-LD |
0 | bus C11 | no consumer on the I/O card v1.1 (review L-2) |
| 1 | -VMA |
0 | bus C12 | valid memory address: qualifies the memory card's selects and the register card's address drive; asserted in every step |
| 2 | -ALU-FUNC |
0 | bus C15 | opens the ALU's bus transceivers (and, with JP1, the condition mux) |
| 3 | ALU0 |
1 | bus C16 | function / condition / shift-mode code bit 0 |
| 4 | ALU1 |
1 | bus C17 | bit 1 |
| 5 | ALU2 |
1 | bus C18 | bit 2 |
| 6 | ALU3 |
1 | bus C19 | carry-in select (add/sub), shift serial-input select bit 1 |
| 7 | -AC-LD-INV |
0 | bus C20 | load the accumulator inverted; also inverts BR-COND |
Byte 5 (chip 3, port B) — accumulator, shift, branch test, halt, OUT¶
| Bit | Signal | Active | Where it goes | Meaning |
|---|---|---|---|---|
| 0 | -AC-RD |
0 | bus C21 | ALU drives DATA0..7 = ACC, DATA8..15 = $FF (needs -ALU-FUNC) |
| 1 | -AC-LD |
0 | bus C22 | ACC (and, for add/sub/shift, the carry FF) latch at the leading edge |
| 2 | -SR-LD |
0 | bus C23 | shift register clocks at the leading edge (mode = ALU1..0) |
| 3 | BR-TEST |
1 | internal | while high, a high BR-COND (bus C24, from the ALU) sets the branch-taken latch |
| 4 | -HL-SWAP |
0 | bus C25 | register card: high byte ↔ DATA0..7 through the swap transceiver |
| 5 | SOFT-HALT |
1 | internal | stops the clock in this step (HALT opcode); CONT resumes |
| 6 | OUT-OFF |
1 | internal → OUT (bus C27) | clears the OUT latch (OFF opcode; record $00 at reset) |
| 7 | OUT-ON |
1 | internal → OUT | sets the OUT latch (ON opcode) |
Byte 6 (chip 4, port A) — interrupt and sequencer control¶
| Bit | Signal | Active | Where it goes | Meaning |
|---|---|---|---|---|
| 0 | -INTA |
0 | bus C14 | interrupt acknowledge: pipelined but no record asserts it (review L-8) |
| 1 | -SRC-ADDR |
0 | see note | the logic card's IC18A enable takes this from jumper JP1 pin 3, not from the RAM |
| 2 | INT-LD-HI |
1 | internal | INT vector high byte ← DATA0..7 (leading edge) |
| 3 | INT-START |
1 | internal | clears the interrupt-enable latch and the pending flip-flop (INT, INTD) |
| 4 | INT-EN |
1 | internal | sets the interrupt-enable latch (INTE, IRET) |
| 5 | LD-INS-REG |
1 | internal | IR ← DATA0..7 at the leading edge (or $FF if an interrupt is pending and enabled) |
| 6 | UCODE-COUNT-RESET |
1 | internal (also on JP4 pin 3) | end of record: clears the step counter (this step lasts one clock) and the branch-taken latch |
| 7 | OPERAND-CLK |
1 | internal | operand register ← DATA0..7 at the leading edge |
Byte 7 (chip 4, port B) — branch register, operand select, spare¶
| Bit | Signal | Active | Where it goes | Meaning |
|---|---|---|---|---|
| 0 | -BRANCH-RD |
0 | internal → DATA0..15 | the branch register drives all 16 data lines |
| 1 | BRANCH-LD-LO |
1 | internal | branch register low byte ← DATA0..7 (leading edge) |
| 2 | SPARE3 |
1 | RAM IC5 I/O5 → memory JP1 pin 1 → logic JP1 → IC16 3D; 3Q → JP2 → -MEM-CPU-RESET |
spare; every record holds it at 0, so fitting JP2 would hold the sequencer-memory ATmega in reset (control-io review 1.4) |
| 3 | BRANCH-LD-HI |
1 | internal | branch register high byte ← DATA0..7 (leading edge) |
| 4 | -INT-JMP |
0 | internal → DATA0..15 | the INT vector register drives all 16 data lines |
| 5 | INT-LD-LO |
1 | internal | INT vector low byte ← DATA0..7 |
| 6 | -2-BYTE-OPERAND-SEL |
0 | internal (IC5/IC4 enables) | REG-RD-ID = operand bits 0..3 and REG-LD-ID = operand bits 4..7 instead of the pipeline fields |
| 7 | -DEST-ADDR |
0 | see note | the logic card's IC11A enable takes this from JP1 pin 4, not from the RAM |
Note on -SRC-ADDR / -DEST-ADDR: the generator writes the two bits into every word (both inactive, they are in
the table), but on the logic card the pipeline D inputs that carry those names come from the 2 × 2 header JP1
(USRC-ADDR pin 3, UDEST-ADDR pin 4), whose partner header on the memory card connects only SPARE3 and
-MEM-CPU-RESET (pins 1, 2) in the schematic (docs/isa/MICROCODE-REVIEW-NOTES.md 1.1; control-io review 1.1 and 1.4;
Sequencer-Memory-V2.1.sch JP1 nets). They would let the operand register's nibbles drive ADDR-REG-ID (register-indirect
addressing by operand byte), a path no record and no jumper uses. To verify: what, if anything, is fitted on JP1 of
either card.
The commented-out CADDRn and BITxxx entries at the top and bottom of yaccsignaldata2.h are the address lines
(driven by the loader, not stored) and a raw bit-name set from the v1 generator; they are not part of the word.
4. Sequencing: how a record is played¶
Details and pin references are in docs/isa/MICROCODE-REVIEW-NOTES.md 1.1; ARCHITECTURE.md section 5 tells the same
story from the machine's side. The points a microcode author needs:
- Address = opcode (IR, CADDR6..13) : step (counter bits 1..6, CADDR0..5). The counter's bit 0 is the pipeline
clock, so a step is two clock periods and the RAMs have one period of access time. Bit 7 =
COUNT-FAULThalts the clock: a record withoutUCODE-COUNT-RESETinside 64 steps stops the machine with its last word on the bus (y1ucemu.cprintsCOUNT-FAULT;ucode_review.pyrule E1). UCODE-COUNT-RESETclears the counter asynchronously during the first clock-high of its step: that step is one clock long (every strobe in it is half length, M-7) and the next record's step 0 is three clocks long (M-8, a ~50 ns race that works with LS parts). The generator always makes the reset step a pure hold step (endInstruction()writes the current line, then the same line plus the reset bit).- The IR latches at the leading edge of
LD-INS-REG(step 1 of the prologue since 2026-09-29, step 2 before), so the steps up to and including that one come from the previous opcode's record, and the new record takes over at the next step (2; it was 3). Hence the rule the generator enforces by construction: steps 0..2 are identical in every record (startInstruction()+loadNextInstruction(); with the six-step prologue it was steps 0..5), checked bytests/ucemu/prologue.py. Section 5.6 has the prologue step by step. - The two-byte operand path:
OPERAND-CLKlatches the byte after the opcode; while-2-BYTE-OPERAND-SELis asserted IC5 putsoperand[3:0]onREG-RD-IDandoperand[7:4]onREG-LD-IDinstead of the pipeline's IC4 (y1ucemu.ccompute():if (on(w, s_two_byte)) { rd_id = operand & 0x0F; ld_id = operand >> 4; }). MOVRR, PUSHR, POPR, JSRUR and BRUR are the users;yacc1.defencodes the operand accordingly. The bit is released before the record ends, and the moment it is released the pipeline's own (usually zero) fields return — which is how PUSHR's high-byte write found itself reading PC (H-1, section 8). - Branch-taken latch and the R0 gate:
BR-TESTwithBR-CONDsets a level-sensitive latch that is cleared byUCODE-COUNT-RESET; loads whoseREG-LD-IDis 0 reach the register card only while the latch is set. Every unconditional PC load therefore carriesBR-TESTwith ALU code 0 (branch.cJSR, RET, INT, IRET, BRUR,branch()). - Interrupt substitution: with an interrupt pending and enabled, the IR takes $FF at the next
LD-INS-REGwhatever the bus holds; record $FF is the interrupt entry (ARCHITECTURE.mdsection 8). -BUS-EN= NOT READY: the pipeline's eight 374s and the two CADDR buffers are tri-stated until the memory half reports the image loaded. Seventeen other lines are not (control-io review 1.1).
4.1 Edge summary (which step's bus a latch sees)¶
From docs/isa/MICROCODE-REVIEW-NOTES.md 1.6, the table that decides where a strobe may sit:
| Destination | Strobe | Takes the value at | So the source must be valid |
|---|---|---|---|
| IR | LD-INS-REG |
leading edge | in the step before the strobe step |
| operand register | OPERAND-CLK |
leading edge | before |
| branch register hi/lo | BRANCH-LD-HI/LO |
leading edge | before |
| INT vector hi/lo | INT-LD-HI/LO |
leading edge | before |
| TMP0/TMP1 | -TMP-REG-LD0/1 |
leading edge | before |
| ACC, carry FF | -AC-LD |
leading edge | before |
| shift register, shift-out FF | -SR-LD |
leading edge | before |
| index register byte | REG-LD-LO/HI (+-REG-FUNC-LD) |
trailing edge, level-sensitive | through the whole strobe step |
| index register count | -REG-UP/-REG-DN (+-REG-FUNC-RD) |
trailing edge (rising edge of OR(select, strobe)) | select must not change while the strobe is low |
| RAM/EEPROM write | -MEM-WR |
trailing edge (-WE rising) |
through the step, address stable |
| I/O latches | -IO-WR |
trailing edge | through the step |
| branch-taken latch | BR-TEST |
level | BR-COND stable through the step |
| step counter clear | UCODE-COUNT-RESET |
first clock-high of the step | (one-clock step) |
The generator's habit of "set-up line, strobe line, release line" (aluOp(), putBustoRegMem(), shiftOp()) is what
satisfies the leading-edge rows without the author having known which edge each part used (the source comments still
ask "rising or falling?", "reg up is rising edge??? are you sure"). Review L-3 lists the release steps that could be
merged with the next set-up because of the leading-edge behaviour.
5. The generator: firmware/microcode/ucode-generator2/¶
5.1 Files¶
| File | Role |
|---|---|
main.c |
the signal primitives (findSignal, setSignal, clearSignal, initCurrentLine), the field setters (setAddrId, setRdId, setLdId, setAlu, setIo), the building blocks (loadNextInstruction, incrementReg, decrementReg, putMemAtRegOnBus, putBustoRegMem), startInstruction/endInstruction, and main() which walks: record 0, HALT, then branchInstructions(), registerOnlyInstructions(), ioInstructions(), accumulatorInstructions(), doMemory(), then dumpCntlMemory() |
controlLine.c |
the memory image cntlMemory[MEMORY_SIZE], the working line currentLine[8], bitOn/bitOff (byte = (chip-1)*2+port), writeCurrentLine() with its duplicate filter and 64-line overflow check, dumpCntlMemory() writing the three image files |
CodeGen.h |
sizes, the ALU function codes (ALUDATA 0 .. ALUADD 7, CARRY_SHIFT 0x08), the compare codes (ALUBR 0 .. ALUCS 7), the shift modes (SHIFT_LEFT/RIGHT/LOAD, SHIFT_ZERO/RING/PROP/CARRY), INVERT, and the register roles PC 0, SP 1, IR 2 |
code.h |
prototypes |
accumulator.c |
MVRLA/MVRHA/MVARL/MVARH, LDAVR/STAVR, LDAI, INVA, the immediate and TMP forms of ADD/SUB/AND/OR/XOR (+ carry forms), the seven shifts via shiftOp(), MVAT/MVTA |
branch.c |
branch() (the BR family and BRVR), JSR, JSRUR, BRUR, RET, PUSH/POP, PUSHR/POPR, IADDR, INT, IRET, INTE, INTD |
io.c |
ON/OFF, OUTI Pn, OUTA Pn, INP Pn (16 ports each) |
memory.c |
LDIVR Rn |
register.c |
DECR/INCR, MVIB, LDTI, MVIW, MOVRR, LDR/STR, LDA/LDT/STA/STT |
Makefile |
make builds ./ucodegen; make check regenerates into build/ and diffs against the committed test.hex; make regen rewrites the three images in place; Makefile.netbeans is the old NetBeans build |
nbproject/ |
the NetBeans project (kept, not needed) |
Includes resolve through the symlink firmware/opcodes.h → software/opcodes.h (tools/layout_links.py), so the
opcode numbers have one source: software/opcodes.h is shared by the generator, the emulators and (via yacc1.def)
the assembler.
5.2 How a record is written¶
startInstruction(op); // startUcodeBlock(op); initCurrentLine(); write line 0 (+ OUT-OFF for op 0)
loadNextInstruction(); // lines 1..5: -MEM-RD at [PC]; LD-INS-REG; hold; PC++ ; hold (clearSignal("-MEM-RD") is
// applied to the line that follows, so it is the caller's next line that drops -MEM-RD)
initCurrentLine(); // most records: back to the idle word (ADDR-REG-ID = PC, -VMA on, everything else off)
... // the body: setSignal/clearSignal/set*Id + writeCurrentLine() per step
endInstruction(); // write the current line, then the same line with UCODE-COUNT-RESET; record the length
initCurrentLine() is worth reading once: it clears every signal in the table to its inactive value and, inside that
loop, re-applies setAddrId(PC) and setSignal("-VMA") — the two defaults that are true in every word of the
image. The -VMA default carries the comment // Hack prevent ROM mapping from triggering: it is the workaround for
the memory card's FORCE-ROM race (ARCHITECTURE.md 7.2, datapath review M1).
writeCurrentLine() has one surprise: a line identical to the previous line is not written ("dup found"), which is
why RSHL is 15 steps where the other shifts are 16 (its mode code 5 equals the SHIFT output code) and why a few family
members differ by one step (review L-5: LDAVR R0, BRVR R0, STR R2, OUTI P0). Harmless, but it means writeCurrentLine()
cannot be used to make a deliberate hold step of an unchanged word.
The building blocks and their conventions (all in main.c):
putMemAtRegOnBus(reg): ADDR-REG-ID = reg,-VMA,-MEM-RD, write one line. The next line is where a leading-edge latch may take the data (with one step of access time: review M-2 lists the records where that is tight).putBustoRegMem(reg, source): ADDR-REG-ID = reg,-VMA, assert the source, write;-MEM-WR, write; release-MEM-WR, write; release the source, write. One set-up, one strobe, one hold — the pattern the memory card needs (1.3). The source is a name ("-AC-RD","-TMP-REG-RD1","-REG-RD-HI"), so the caller must have arranged any other enable the source needs (-ALU-FUNCfor the accumulator,-REG-FUNC-RD+ the id for a register).incrementReg(reg): id +-REG-FUNC-RD+-REG-UPin one line, then both released in the next. The count happens at the trailing edge; the author's comment "might be an issue if current setRdId reg is different from one here" is the register card's R2 hazard (deselecting while the strobe is low counts the deselected register).decrementReg()writes a select-only line first.aluOp(func)(accumulator.c):-ALU-FUNC+ code, write;-AC-LD, write; release, write.branch(reg, mode, invert, source)(branch.c): fetch the two target bytes throughBRANCH-LD-HIthenBRANCH-LD-LO(each with its own PC increment), drop-MEM-RD, set up the condition (-AC-RDforSOURCE_AC,-TMP-REG-RD0forSOURCE_TMP,-ALU-FUNC, ALU = mode,-AC-LD-INVif inverted),BR-TEST, release the test and the source,-BRANCH-RD,-REG-FUNC-LDwith LD-ID = PC,REG-LD-LO+REG-LD-HI, release. The 2026-09-22 fix lives in the "release the source" line (section 8).
5.3 Building, checking, regenerating¶
cd firmware/microcode/ucode-generator2
make # builds ./ucodegen
make check # generates into build/ and reports "test.hex: IDENTICAL to the committed microcode image" or fails
make regen # rewrites test.123 / test.hex / test.hexz here (what the loaders send)
make check is the regression test for any change to the generator: it must say IDENTICAL until you mean it to
differ, and when it differs, docs/isa/steps.txt (regenerated by tools/ucode_review.py) shows exactly which
records and steps moved. The program prints a "dup found" line for each filtered duplicate and "Done".
5.4 Records that are never written¶
Fixed 2026-09-29 (H-4): after generating every instruction, main() gives each record still all-zero the HALT
record (haltRecord(), the same code as HALT $03: the fetch, then SOFT-HALT), so a stray fetch of $A5, $AE or
$F8-$FA stops the machine cleanly with the PC past the byte (CONT resumes at the next one) instead of the storm
described below; both emulators also stop on an undefined opcode. Exactly those 5 records changed; tests/ucemu/undefined.py
(in make check) requires no all-zero record and runs each on the model: HALT after 7 steps, 0 bus fights (the old
image: 124 fights, then COUNT-FAULT). Not yet in the EEPROM: it goes in with the next ucode_send.py --all. The
history:
main() never emits $A5 (BRNC was planned), $AE and $F8–$FA: 5 all-zero records remain in test.hex since
2026-09-24 (38 before BRUR filled $AD on 2026-09-22; 37 until LDZ/STZ/ADDIW/SHL16 filled $80–$8F and $C0–$CF,
where OUTVR and LDTVR/STTVR had never had microcode — section 7a). Because active-low signals are stored as
1 = inactive, an all-zero word asserts -REG-FUNC-RD, -REG-FUNC-LD, -REG-RD-LO/HI, -REG-UP, -REG-DN, -MEM-RD, -MEM-WR,
-IO-RD, -IO-WR, -TMP-REG-RD0/1, -TMP-REG-LD0/1, -ALU-FUNC, -AC-LD-INV, -AC-RD, -AC-LD, -SR-LD, -HL-SWAP, -BRANCH-RD,
-INT-JMP, -2-BYTE-OPERAND-SEL, -INTA and -VMA together; fetching such an opcode runs steps 3..63 of that storm until
COUNT-FAULT stops the clock (review H-4, HIGH; fixed 2026-09-29, above). (Until 2026-09-24 the assembler would emit LDTVR/STTVR/OUTVR
and the instruction-level emulator ran LDTVR/STTVR, so a program that worked there could do this on the hardware; those
mnemonics are gone.) The review
proposed filling every unwritten record with the idle word plus UCODE-COUNT-RESET (an illegal opcode becomes a
one-byte NOP) or with SOFT-HALT; the halt was chosen so a stray fetch is seen, not silently skipped.
5.5 Record lengths¶
Steps per record with the microcode of 2026-09-29 (the three-step fetch prologue, section 5.6, and the idle-step pass,
section 5.7), with the count before those changes in brackets (families as their R0 member; docs/isa/README.md
has the diagrams; the undefined opcodes are HALT's record, section 5.4):
| Steps | Opcodes (steps before 2026-09-29: six-step prologue, idle steps) |
|---|---|
| 4 | START (8) |
| 5 | ON (8), OFF (8), HALT (8) |
| 6 | INCR (9), DECR (10), INTE (9), INTD (9) |
| 7 | MVAT (10), MVRLA (10), MVRHA (10), INVA (10) |
| 8 | MVTA (11), LDAVR (10), ORT (11), ANDT (11), XORT (11) |
| 9 | LDTI (11), MVARL (12), MVARH (12), STAVR (12), OUTA (12), INP (12) |
| 10 | POP (13), LDAI (12), MVIB (12), OUTI (13), ORI (12), ANDI (12), XORI (12), ADDT (13), SUBT (13), ADDTC (13) |
| 11 | LDIVR (15) |
| 12 | PUSH (15), ADDI (14), SUBI (14), RSHL (15), ADDIC (14) |
| 13 | SHL (16), SHR (16), RSHR (16), PSHR (16), CSHL (16), CSHR (16), IADDR (16) |
| 14 | MOVRR (17) |
| 15 | MVIW (17) |
| 17 | RET (20), LDT (21), STT (22) |
| 18 | BR (21), BRZ (21), BRNZ (21), BRINH (21), BRINL (21), BRC (21), BRLT (21), BREQ (21), BRGT (21), BRNEQ (21), BR16Z (21), BR16NZ (21), BRUR (20), BRDEV (21), BRVR (21), LDA (22) |
| 19 | STA (23), IRET (22) |
| 24 | POPR (28) |
| 26 | PUSHR (33), LDZ (30), LDR (30), INT (30) |
| 27 | STZ (31) |
| 28 | JSR (31), STR (32) |
| 30 | JSRUR (32) |
| 32 | SHL16 (35) |
| 42 | ADDIW (46) |
Summed over the 85 opcodes of software/opcodes.h: 1,434 steps before, 1,226 after the prologue, 1,173 after the
idle steps (-18 %). A record of N steps takes 2N - 1 clocks (the reset step is one clock, M-7). tests/ucemu/run.py's
xisa program: 2,621,365 clocks -> 2,157,017 (prologue) -> 2,089,999 (idle steps); os/kermit_io.asm's wait loop
191 -> 161 -> 153 clocks.
Review section 5 estimates ~30 % of executed steps removable (22 % from the six-step prologue alone) and gives a
per-opcode "achievable" column. Applied: the three-step prologue (L-1, section 5.6) and the idle steps that can go
(S1, section 5.7). Still open: -IO-ADDR-LD (L-2) and the hold steps that are not idle (below). M-1 is fixed
everywhere (section 5.6).
5.6 The fetch prologue (three steps since 2026-09-29)¶
Every record starts with the fetch of the next opcode (loadNextInstruction()). Until 2026-09-29 it took six steps:
0 idle, 1 -MEM-RD, 2 + LD-INS-REG, 3 -MEM-RD hold, 4 PC++ with -MEM-RD still on, 5 release with -MEM-RD.
Steps 3 and 5 did nothing a latch needed, and step 4 had the memory card's LS245 driving the opcode against the
register card's LS245s driving $FFFF (-REG-FUNC-RD without a read strobe) - review M-1, in every instruction. The
review's L-1 proposed three steps, and that is what the generator writes now:
| Step | Signals | Why |
|---|---|---|
| 0 | -MEM-RD, ADDR = PC |
the opcode on the bus; step 0 is three clocks long (it follows the one-clock reset step) |
| 1 | -MEM-RD, LD-INS-REG |
the IR takes, at this step's leading edge, what step 0 left on the bus; -MEM-RD stays on through the step as hold time |
| 2 | -REG-FUNC-RD, -REG-UP, RD-ID = PC |
PC++ when -REG-UP rises at the end of the step; no -MEM-RD: the register card's $FFFF drives alone (no M-1) |
| (3) | release: step 2 without -REG-UP/-REG-FUNC-RD |
only in 55 records, below |
Why it is safe:
- Records change hands at step 2. The IR changes early in step 1, so the words of steps 0 and 1 are read from the
previous opcode's record and the new record's from step 2 (section 4, rule 3). All three steps are the same in every
record (START, $00, also has OUT-OFF in every step, as always: a flip-flop set, harmless a step late).
- Memory time is unchanged where it matters: the address has been on the bus since the previous record (the
-VMA hack keeps ADDR-REG-ID = PC driven; after a taken branch the PC was loaded at least 2.5 steps before the IR's
edge), and -MEM-RD is on for the three clocks of step 0 before the edge.
- The count edge. A 74LS192 counts when (select OR -REG-UP) rises (section 1.2 of the review notes). The old
release step dropped -REG-UP and -REG-FUNC-RD together with the selection unchanged. Now the record's first body
step follows step 2 directly, which is the same thing as long as that step keeps REG-RD-ID = PC and asserts no
-REG-UP, -REG-DN or -2-BYTE-OPERAND-SEL; otherwise the register selection could change while -REG-UP is
still low, and a decoder glitch would count another register. writeCurrentLine() checks the first body line and,
for the 55 records where it does not qualify (a first body step that selects another register or counts: MVRLA/MVRHA
and INCR/DECR of R1-R7, INCR R0, RET/POP/IRET's SP++, LDZ/STZ's read of R6, SHL16), writes the old release step in
front of it (releaseIfNeeded() in main.c).
- No latch reads the prologue's bus. In the six-step prologue, step 5 was a read at the new PC, and a body whose
first line was also "-MEM-RD at PC" merged into it (the generator drops a line equal to the one before). Every
body sets up its own read now; tests/ucemu/prologue.py checks that no first body line holds a leading-edge latch.
That lost merge is why 153 records save two steps rather than three.
Checked on the microcode emulator (software/ucemu, which applies the leading/trailing-edge model of section 4):
the compiler suite and --xisa (23/23 each), the emulated bench (isa's byte stream), romcount, romdiag, video,
monload, cfcard and kermit (timeouts recalibrated: os/kermit_io.asm PPS 5229 -> 6203), with 0 bus fights wherever
the run counts them; the OS sessions (32/32 on both emulators), the native tool chain and the disassembler pass too, and
make check is green. The self-host's estimate at 1 MHz went from 31 h 45 min to 26 h 51 min for both stages (27.4
clocks an instruction on the microcode emulator's native session against 32.4). make prologue6 in the generator folder builds the image as it was before this and the M-1 change below
(byte-identical to the one committed before the change, -DPROLOGUE6) for loading in two stages or going back:
python3 tools/ucode_send.py --all --hex firmware/microcode/ucode-generator2/build/p6/test.hex.
M-1 in the operand fetches (the same day, after the prologue). 116 steps in 78 records (LDAI and the other
immediates, MVIB/MVIW, the branches, JSR, LDA/STA/LDT/STT, STR, LDZ's R2++, RET/POPR/IRET's SP++, BRVR's Rn++)
incremented a register with -MEM-RD still on: the same LS245-against-$FFFF overlap. The pattern everywhere is read,
latch (or level load), hold, increment, then read the next byte at the new address; nothing uses the bus of the
increment step. writeCurrentLine() now writes any line that counts a register without -MEM-RD (m1Mask() in
main.c) and leaves -MEM-RD in the current line, so the next step reads as before. Exactly those 116 steps changed,
by that one bit; no record changed length (it is an electrical fix, not a speed one). tests/ucemu/prologue.py rule 5
checks the whole store: no count step with -MEM-RD (500 count steps), and no leading-edge latch right after one (it
would take the $FFFF).
5.7 Idle steps (2026-09-29)¶
tools/ucode_review.py rule S1 counts the steps where nothing is asserted but -VMA: 259 after the prologue change.
The generator's set-up / strobe / release pattern leaves one between many pairs of steps. Taking one out makes its
neighbours P and N adjacent, so what P switches off and N switches on happen on the same edge - harmless unless an edge
needs that step. removeIdleSteps() in main.c runs after every record is written and keeps an idle step when:
| Rule | Why the step is needed |
|---|---|
P ends REG-LD-LO/HI, -MEM-WR or -IO-WR |
the hold after a trailing-edge strobe (74LS192 LOAD is level-sensitive; a write needs address and data stable past its end) |
| N has a leading-edge latch (IR, operand, branch, INT, TMP, AC, shift register) | the latch takes the previous step's bus: it would take P's instead |
| N loads or writes | selects, address and data settle a step before the strobe |
P counts and N changes REG-RD-ID or -2-BYTE-OPERAND-SEL |
the count edge would meet a register-selection change: a decoder glitch counts another register (the rule of section 5.6) |
| N counts and its register selection is not P's, or P counts too | the same glitch at the start; two counts would merge into one |
-2-BYTE-OPERAND-SEL starts in N |
it replaces the ID fields: a selection change |
I/O (-IO-RD pops the UART's FIFO, -IO-ADDR-LD), BR-TEST (level-sampled), INT-EN/INT-START/-INTA, SOFT-HALT in P or N; OUT-ON/OUT-OFF in the step but not in P and N (ON, OFF) |
actions left as they were |
| N is the reset step with anything but the reset bit | M-7: its strobes would be one clock long |
| the step's selection fields (ADDR-REG-ID, REG-RD-ID, REG-LD-ID, ALU, IOADDR) are neither P's nor N's | it is a set-up step of its own |
One data-bus driver switching off and another on at the same edge (review A3, a few ns of overlap) is allowed; the machine already does it at many boundaries, the fetch's step 1 -> 2 among them. The prologue (steps 0-2) is never touched. The pass removes one step at a time and re-checks the new neighbours.
Result: 152 of the 259 removed, 107 kept (68 count-edge releases, 28 before a count whose selection is not yet
stable, the rest next to I/O, BR-TEST, INT or halt actions, and ON/OFF's OUT step). 82 records save one step, 31 two,
one three and one five (PUSHR, 31 -> 26). Compiled code gains ~3 % on top of the prologue's ~18 %.
Checked: tests/ucemu/idle.py builds the generator with -DKEEPIDLE (the records before the pass, byte-identical
to the image committed before it, d7af02a) and checks, with its own copy of the rules, that every record is that
record minus idle steps, that every removal satisfies the rules, that the prologue is untouched, and that no idle step
left would pass them. make check on the emulators (the native session: 27.4 -> 26.1 clocks an instruction); os/kermit_io.asm recalibrated (PPS 6528: 1,000,176 clocks a
second, 217 clocks a received character). make prologue6 still builds b98aec2's image.
Not done: hold steps that are not idle - e.g. LDTI's step after its TMP latch, which keeps -MEM-RD a second
step although the latch took its data at the leading edge of the step before. Those need a per-signal decision
(hold time of each latch) rather than a pattern rule.
6. Reading the timing diagrams (docs/isa/)¶
tools/ucode_wavedrom.py --all (make isa) writes one WaveDrom diagram per opcode in firmware/opcodes.h:
docs/isa/<MNEMONIC>.json (source) and .svg (picture), plus the index docs/isa/README.md. Control rows are read
straight from test.hex and grouped (bus cycle, registers, ALU/accumulator, branch/interrupt, sequencer/misc); rows for
signals that never change in the record are omitted. The last rows (ADDRESS BUS, DATA BUS, IR, ACC, PC) are modelled,
not read: the tool's TIMING constants place them as fractions of a step (pipe 0.05, reg 0.12, mem 0.30,
latch 0.00), and every diagram's footer says so. Register families are drawn once, for R2 ("R0 is the PC and would
show it being overwritten").
Known inaccuracies of that model, from docs/isa/MICROCODE-REVIEW-NOTES.md section 6 (to fold into the tool when the
diagrams are next regenerated, BACKLOG.md):
latch = 0.00("loads take effect at the trailing edge") is wrong for the IR, operand, branch, INT, TMP, ACC and shift registers: they take the leading edge, i.e. the previous step's bus (section 4.1).- The reset step and step 0 are drawn equal in length; the hardware makes them one and three clock periods.
-REG-RD-LO/-REG-RD-HIare drawn on one 8-bit bus; the data bus is 16 bits wide at the register cards, the TMP registers and the branch/INT registers.- The data bus is drawn idle during increment steps although the register card drives $FFFF there (M-1).
The textual form is easier for review: docs/isa/steps.txt lists every asserted signal of every step of every record
(tools/ucode_review.py writes it together with docs/isa/MICROCODE-REVIEW.md). The step tables in
ARCHITECTURE.md section 12 were read from it.
6.1 The review tool¶
tools/ucode_review.py runs rules W1–W3 (write strobes vs address/data changes and -VMA), R1 (read and write
together), R2 (two or more data-bus drivers in a step), L1/L2 (loads with no driver / driver appearing in the same
step), I1 (counting the register on the address bus during a write), E1/E2 (no reset within 64 steps / dead steps
after it) and S1 (idle steps). Totals on 2026-09-22: L1 8, R2 3, S1 359 (docs/isa/MICROCODE-REVIEW.md); before the
fixes R2 was 37 (software/ucemu/README.md). The review notes list the tool's blind spots (section 6): R2 should treat
-REG-RD-LO + -REG-RD-HI as byte lanes, add -INT-JMP to DATA_DRIVERS, and — the one that would have caught H-1,
H-2 and M-1 — count the register card as a driver whenever -REG-FUNC-RD selects a card that -REG-FUNC-LD does not,
and the ALU whenever -AC-RD and -ALU-FUNC are both on. E1 should run over all 256 records, not only the non-empty
ones (H-4).
7. Worked example: adding BRUR Rn ($AD) on 2026-09-22¶
BRUR Rn is "branch to the address in Rn" — PC ← Rn, nothing pushed, Rn unchanged — the direct counterpart of the
existing BRVR Rn, which is an indirect jump (PC ← the word at [Rn], Rn += 2; review section 3 found the
instruction-level emulator wrong about that). It was added because the monitor's G command and the C compiler's
jump tables needed a plain register jump (docs/system/MACHINE.md, BACKLOG.md "C compiler"). The places touched:
software/opcodes.h:#define BRUR 0xAD— a hole in the map ($AD had no record; H-4) with a comment "BRUR Rn = PC <- Rn (2 bytes, register in the operand byte like JSRUR)".software/assembler/yacc1.defline 89:BRUR \{regs}— the two-byte form, register number in the operand byte.branch.c: a new block after JSRUR. It is JSRUR without the "save pc to stack" part, and its steps (docs/isa/steps.txt,docs/isa/BRUR.svg) read, as built that day with the six-step prologue (since 2026-09-29 the body starts at step 3 with its own read step; section 5.6):
| Step | Signals | Why |
|---|---|---|
| 0–5 | prologue | fetch $AD, PC++ |
| 6 | -MEM-RD, OPERAND-CLK |
operand register ← the byte at [PC] (leading edge: memory was driving since step 5, one step of access) |
| 7 | -REG-FUNC-RD, -MEM-RD |
select PC for the count |
| 8 | -REG-FUNC-RD, -REG-UP |
PC++ past the operand (-MEM-RD dropped first) |
| 9 | -REG-FUNC-RD, -REG-RD-HI, -HL-SWAP, -2-BYTE-OPERAND-SEL |
REG-RD-ID = operand[3:0] = n; Rn.hi through the swap transceiver onto DATA0..7 |
| 10 | + BRANCH-LD-HI |
branch register high byte ← DATA0..7 (leading edge: step 9's value) |
| 11 | as 9 | release step |
| 12 | -REG-FUNC-RD, -REG-RD-LO, -2-BYTE-OPERAND-SEL |
Rn.lo onto DATA0..7 (straight path) |
| 13 | + BRANCH-LD-LO |
branch register low byte ← DATA0..7 |
| 14 | as 12 | release step |
| 15 | idle | everything released — -REG-RD-LO, -2-BYTE-OPERAND-SEL and -REG-FUNC-RD in one line, so the register card is off the bus before the branch register drives it (the H-1 lesson) |
| 16 | -BRANCH-RD, -ALU-FUNC, ALU = 0, BR-TEST, -REG-FUNC-LD, LD-ID = PC |
the target on DATA0..15; condition "always"; the branch-taken latch sets; card 0 load-selected (receive) |
| 17 | + REG-LD-LO, REG-LD-HI |
PC ← branch register at the trailing edge (allowed for R0 because the latch is set) |
| 18 | as 16 | hold |
| 19 | + UCODE-COUNT-RESET |
end: 20 steps |
The generator code is the JSRUR text with the stack block deleted and the release of -REG-FUNC-RD added at
step 15 (JSRUR releases it later, with its own comment "suspect, glitch?").
4. Both emulators: the instruction-level software/emulator was patched to execute $AD (tools/patched_files.txt);
y1ucemu needed nothing — it runs whatever record $AD holds.
5. The test tests/assembler/brur/brur.asm: a plain BRUR R5, a jump through an address fetched from a table, and a
four-way dispatch driven by a counter; expected console output ABC0123 then HALT, 89 instructions. It starts
with the boot stub (ORG 0F000H / BR 0F003H / MVIW R1,0EFFH / JSR main) so it also runs from reset on y1ucemu;
tests/ucemu/run.py includes it in the suite.
6. Regenerate and load: make regen, then make check (identical to itself), tools/ucode_review.py for the new
steps.txt/diagrams, and tools/ucode_send.py --all to the card (section 9). The C compiler had a --no-brur
switch for the interval between the generator change and the reload (BACKLOG.md).
What remains: bench-check on the hardware — the register-to-branch-register path (-REG-RD-HI/LO + -HL-SWAP into
BRANCH-LD-HI/LO under -2-BYTE-OPERAND-SEL) is the same one JSRUR uses and had not been exercised on the machine
before (tests/assembler/brur/README.md, BACKLOG.md). The reload changed six records ($07 PUSHR, $A1/$A2/$AB/$AC the H-2 branches, $AD BRUR): tools/ucode_send.py
--dry-run against the loader's cache listed exactly those on 2026-09-22 before the load (the "14 records" once in
docs/system/MACHINE.md was an estimate made before the images were compared; corrected).
7a. LDZ/STZ, ADDIW, SHL16 (2026-09-24)¶
Four register families for the C compiler's --xisa code (docs/programming/ISA-REFERENCE.md section 4a), in the
32 opcodes that had never had microcode. The same places were touched as for BRUR: software/opcodes.h (LDZ $80, STZ
$88, ADDIW $C0, SHL16 $C8; OUTVR/LDTVR/STTVR removed), yacc1.def, the interpreter, and the generator:
register.c: LDR's and STR's second halves (from "R2 holds the address" to the end) becameloadRegFromIR()andstoreRegAtIR(), the same lines in the same order, so $E8–$F7 did not change by a bit.zpageAddress()builds the address from the page registerZP= R6 (CodeGen.h):
(Step numbers as built with the six-step prologue; since 2026-09-29 the body is three steps earlier, section 5.6.)
| Step (LDZ R3) | Signals | Why |
|---|---|---|
| 0–5 | prologue | fetch $83, PC++ |
| 6 | -REG-FUNC-RD, RD-ID = 6, -REG-RD-HI, -REG-FUNC-LD, LD-ID = 2 |
R6.hi on card 1's high lane → DATA8..15 (straight transceivers, card 1 only read-selected); card 0 only load-selected: DATA → ADATA |
| 7 | + REG-LD-HI |
R2.hi ← DATA8..15 at the trailing edge (level-sensitive load: the source was set up a step before) |
| 8 | as 6 | hold one step after the strobe |
| 9 | idle | everything released before memory drives the bus |
| 10–14 | -MEM-RD (address = PC), -REG-FUNC-LD, REG-LD-LO |
R2.lo ← the offset byte (as LDR's second address byte) |
| 15–16 | -REG-UP on R0 |
PC past the operand |
| 17–29 | LDR's own steps 17–29 | Rn.hi ← [R2] (swap path), R2++, Rn.lo ← [R2], UCODE-COUNT-RESET |
No -HL-SWAP in steps 6–8: it is one bus line that both cards see, and both ends of this move use the high lane.
The unread low lane of card 1 drives the pull-ups' $FF onto DATA0..7 while nothing else drives it (the ucemu counts
it as a weak drive, not a fight). The 16-bit cross-card path is the one every MOVRR R3,R4 of compiled C uses,
which tests/bench has run on the machine.
- accumulator.c: ADDIW fetches w.hi into TMP1, then ACC ← Rn.lo, aluOp(ALUADD) with w.lo from memory, Rn.lo ←
ACC, PC++, ACC ← Rn.hi, TMP1 on the bus and aluOp(ALUADD | CARRY_SHIFT), Rn.hi ← ACC (46 steps). SHL16 loads each
byte of Rn into ACC and adds it again from the register, which keeps driving the bus through aluOp (35 steps).
-MEM-RD is released before every PC increment and before ACC drives the bus. Each add goes through aluOp(), so
SHIFT-OUT is cleared first (the 2026-09-23 carry fix) and only register moves separate the low add from the high one.
- Result: exactly the 32 records $80–$8F and $C0–$CF differ from the image in the EEPROM (cache); tests/ucemu/isa.asm
checks every family on both emulators (0 bus fights, the same bytes); docs/isa/ and MICROCODE-REVIEW.md were
regenerated. Still to do at the machine: tools/ucode_send.py --all, then tests/bench/run.py --port ... (isa
and xisa).
8. H-1 and H-2: lessons about bus-release ordering¶
Both findings (docs/isa/MICROCODE-REVIEW-NOTES.md section 2, HIGH) are the same mistake seen twice: a driver that
was needed earlier in the record is still enabled when a different driver is needed, and two LS outputs fight on
the data bus at the moment a latch or a memory write takes the value. The review found them by reading the netlists
(who drives the bus under which signals) and the step listings; software/ucemu then reproduced both on 2026-09-22
and the fixes were made in branch.c the same day.
8.1 What a bus fight is, and how the emulator counts one¶
Two totem-pole TTL outputs on one line with different values: the low output sinks the high one's current and the
line sits at a low-ish level, roughly 0.5–1.5 V, which every input reads as 0 — so the practical outcome is the
AND of the drivers (y1ucemu -F and, the default: "a low output wins, the lane is the AND of its drivers"). The
emulator's compute() collects the drivers of each byte lane every step (memory, I/O, TMP0/1, the branch and INT
registers, the register cards, the ALU) and notes a fight when two of them disagree; the register card's pull-up value
under a bare -REG-FUNC-RD is counted separately as a weak drive, because 327 steps of the image do that and the
machine lives with it (M-1). -F src is the alternative hypothesis (the ALU's drive loses to anything) that would
explain how the monitor could ever have printed a string on the old image; it was never confirmed on a scope
(docs/system/MACHINE.md item 6: "worth one scope look at DATA0 during a taken BRZ").
8.2 H-2: BRZ/BRNZ/BR16Z/BR16NZ loaded the PC while the ALU was still driving¶
branch() sets up the condition for the AC-sourced tests with -AC-RD (so that BDATA = ACC for the zero detector).
It cleared -TMP-REG-RD0 for the TMP-sourced tests before -BRANCH-RD — the author's own comment says why: "problem
will conflict with data transfer of branch reg to PC" — but never cleared -AC-RD. So from step 16 the ALU drove
DATA0..7 = ACC and DATA8..15 = $FF against the branch register's 16 lines while REG-LD-LO/HI loaded the PC at step 18.
A taken BRZ has ACC = 0 by definition: PC.lo ← target.lo AND $00 = $00. On the emulator the monitor's puts loop
never terminated (software/ucemu/README.md). The fix is one symmetrical line:
if (source == SOURCE_TMP) clearSignal("-TMP-REG-RD0"); // existed
if (source == SOURCE_AC) clearSignal("-AC-RD"); // added 2026-09-22 (H-2)
writeCurrentLine();
The condition latch had already been set at BR-TEST (step 14), so releasing the ALU at step 15 loses nothing; steps
15–20 of BRZ now read -ALU-FUNC without -AC-RD (steps.txt). Records $A1, $A2, $AB, $AC changed. H-3 stays: BR16Z /
BR16NZ test BDATA8..15, which under -AC-RD are pull-ups, so they can never work as microcoded (nothing uses them).
8.3 H-1: PUSHR wrote the stack while the register card was still driving¶
PUSHR moves the register's bytes through TMP1 (the ISA's push order is hi then lo at descending addresses). The
original record read Rn.hi through the swap path into TMP1 (steps 10–12), then released -2-BYTE-OPERAND-SEL — but
not -REG-FUNC-RD, -REG-RD-HI or -HL-SWAP. With the operand select gone, REG-RD-ID fell back to the pipeline's
field (0 = PC), so during the write of TMP1 to [SP] the register card was driving PC.hi onto DATA0..7 through the
swap transceiver; the second write had the same shape with SP.lo/SP.hi driven on all 16 lines. PUSHR R3 with
R3 = $ABCD pushed $21CC on the model; PUSHR is used 4 times in the monitor and 34 in BASIC. The fix, in two places:
clearSignal("-2-BYTE-OPERAND-SEL");
clearSignal("-REG-RD-HI"); // H-1: release the card before the write
clearSignal("-REG-FUNC-RD");
clearSignal("-HL-SWAP");
writeCurrentLine();
putBustoRegMem(SP, "-TMP-REG-RD1");
and likewise -REG-RD-LO / -REG-FUNC-RD before the second write. PUSHR is now 33 steps (steps 13 and 24 are the new
release steps, steps.txt); review section 5 notes it could be 18 by writing the register straight from the card as
JSR does, which would also remove the TMP1 detour.
8.4 The rules that fall out¶
- Know who drives on what. Memory drives on
-MEM-RD+-VMA; a port on-IO-RD; TMPn on-TMP-REG-RDn(16 lines); the branch and INT registers on-BRANCH-RD/-INT-JMP(16 lines); the ALU on-ALU-FUNC+-AC-RD(ACC on 0..7, $FF on 8..15); a register card on-REG-FUNC-RDalone unless-REG-FUNC-LDselects the same card. Exactly one of these per lane per step. - Release before you switch. A driver stays enabled until its signal is cleared;
-2-BYTE-OPERAND-SELgoing away does not disable anything, it only changes which register the card drives. - Think in edges. The leading-edge latches (section 4.1) have their data one step before the strobe; the release step after them is free for the next set-up. Register loads, memory writes and I/O writes need the source through the whole strobe step.
- The condition latch remembers. Once
BR-TESThas sampledBR-COND, the ALU can let go of the bus; the PC load only needs-REG-FUNC-LD, the id and the branch register. - Check with the model before the bench:
make checkto see what moved,tools/ucode_review.pyfor the mechanical rules,y1ucemu -x -m -f prog.img -wfor the fights,tests/ucemu/run.pyfor the compiler suite, thentests/assembler/*on the hardware.
Status on 2026-09-23 (DOC-PLAN.md rule 7): H-1 and H-2 fixed in the generator and loaded into the EEPROM on
2026-09-22 (tools/ucode_send.py --all); tests/ucemu/isa.asm, the compiler suite and the monitor from reset run with
0 fights over 6 million steps on the model; the bench checks (tests/assembler/brur, isa.asm's byte stream, then the
monitor from ROM) are pending. H-3 open; H-4 open (fixed in the generator 2026-09-29, section 5.4); M-1 fixed in the generator 2026-09-29 (the fetch
by the three-step prologue, the 116 operand-fetch steps by m1Mask(); section 5.6), not yet loaded.
9. Loading the card¶
9.1 The sequencer-memory card's firmware: Sequencer4¶
embedded/sequencer-card/sequencer4/ (flashed 2026-09-21; embedded/sequencer-card/README.md) runs on the card's
ATmega328P (IC15, 16 MHz crystal Q1) and talks to five MCP23017 expanders over I2C at 400 kHz: IC10 (address 0x20,
the CADDR lines) and IC14/IC13/IC11/IC12 (0x21..0x24, the four data pairs = the eight RAM bytes); the RAM control
lines -CMEMSEL/-CMEMRD/-CMEMWR come straight from the ATmega's PB2/PB1/PB0 (control-io review 2). The control-store
EEPROM is a 24Cxx in socket IC9 (on-board part at I2C 0x57); in the machine an adaptor in IC9 carries two 24Cxx at
I2C devices 6 and 7 (docs/system/MACHINE.md, hardware/FABRICATED.md). Two switches and three LEDs set and show the mode
(Sequencer4.ino):
| Control | Pin | Meaning |
|---|---|---|
UCODESWITCH (the UCODE slide switch) |
A0 | 0 = DOWNLOAD (receive an image over serial into the EEPROM), 1 = WRITEMEM (run mode: copy EEPROM → RAM, verify, raise READY) |
STARTSWITCH (START button) |
A2, default high | in DOWNLOAD mode, pressing it starts listening |
LOADING LED |
13 | blinks while copying / verifying |
READY LED |
12 | on when the RAM holds the verified image |
FAULT LED |
11 | blink codes: 5 = unexpected character in the download, 6 = RAM verify failed (READY never rises) |
READYLINE |
D7 → BUS-READY → SV1 pin 12 → logic card IC36 → -BUS-EN |
releases the pipeline onto the bus |
Also on the card: the CADDR14 header (jumper off = upper 16K of the RAMs, on = lower; load and run use the same half),
JP2 (EEPROM WP to GND/VCC), the FTDI header JP3, the LOCAL-CPU-RESET button, and two solder jumpers DTR-RESET
(dead-ends: flashing needs the button) and FTDI-VCC (would parallel the FTDI's 5 V with the backplane if closed while
on the bus) — control-io review 2.2.
Run-mode boot (loop(), mode == WRITEMEM): "Write Memory" → "EEPROM to RAM", 256 instructions copied in 32-byte
EEPROM reads (16 s measured) → "Verify RAM against EEPROM", every byte compared (29 s) → on success the card dumps
instructions 0, 1, 7, 124 and 255 as 64 lines of 8 bytes each (dumpInstruction()), sets the RAM to read/selected,
switches its address and data pins to inputs, raises READY and prints "RAM copy complete and verified, READY!!!" at
about 54 s → then a monitor loop that prints RAW=.. Addr=.. Seq=.. Ins=.. Line=.. Data= whenever the CPU moves to a
new (instruction, step) address, once a second, so a stepping machine can be watched from the serial console. On a
mismatch it prints the counts and the hint "is -BUS-EN inactive? (the logic card drives the microcode address lines when
it is asserted)" and stops with FAULT blinking 6: the 2026-09-21 boots taken while the bus tester still asserted
-BUS-EN dumped all-zero RAM for exactly that reason (embedded/sequencer-card/README.md; the logs in
tests/sequencer/boot-run-mode-2026-09-21*.log). Sequencer3, the previous firmware, did the copy in 156 s with no
verification (deprecated/sequencer3).
9.2 The download protocol and tools/ucode_send.py¶
In DOWNLOAD mode, after START, the card speaks (download.ino, mirrored by tests/sequencer/mock_card.py):
card: >> a prompt before every instruction (then it flashes a LED for 100 ms before reading)
host: %ccii<1024 hex digits> one record; the trailing '-' of test.hex is NOT sent; 'Z' = the rest is zero
...
host: ! end of the download
The card ignores the checksum, reads exactly 512 byte values, writes them into the EEPROM in page writes and prompts
again; a stray character is "Unexpected Char" (FAULT ×5). tools/ucode_send.py (2026-09-22) replaces the Processing
sketch simple_microcode_sender_64 and speaks the same protocol at 115200 baud:
tools/ucode_send.py [--port /dev/cu.usbserial-XXXX] [--all] [--dry-run] [--delay MS] [--hex FILE] [--cache FILE]
tools/ucode_send.py --boot-check [--port ...] [--log FILE]
- Only the records that differ from
cacheare sent (--allignores the cache;--dry-runlists what would go), and the cache is rewritten record by record, so an interrupted load resumes on re-run. - Two timing facts the Processing sketch met by its 25 ms/char pace: opening the FTDI port resets the ATmega (DTR), so
run the sender first and press START after it says it is waiting; and after every
>>the sender waits--settlems (default 250) because the card's 64-byte serial buffer would lose the head of a record sent during its 100 ms LED flash (seen 2026-09-22 at 1 ms/char). - Procedure on the card (
ucode_send.pydocstring): UCODESWITCH to DOWNLOAD, reset the card (LOADING on), press START; afterwards UCODESWITCH back to run and reset: the boot copies and verifies (READY after ~54 s). --boot-checkcaptures that run-mode transcript and compares the five dumped instructions withtest.hex(check_dumps()).
tests/sequencer/run.py exercises the sender without hardware against mock_card.py on a pseudo-terminal: a
differential send of four altered records plus one never sent, a --all send of all 256, and the --boot-check
comparison against the 2026-09-21 transcript (in which only the pre-fix PUSHR record may differ). The 2026-09-22 reload
transcript (boot-run-mode-2026-09-22-reload.log) shows RAM == EEPROM for all 256 instructions and the Ins=7 dump
with the fixed PUSHR.
9.3 What the card holds¶
cache == test.hex since the 2026-09-22 evening reload with --all (firmware/microcode/README.md): BRUR at $AD, the
H-2 fix in $A1/$A2/$AB/$AC, the H-1 fix in $07. The RAM copy was verified at that boot. The EEPROM had been verified
against the previous image on 2026-09-21 by the same five-instruction dump (docs/system/MACHINE.md "What is loaded").
10. Glossary of the vocabulary used above¶
| Term | Meaning here |
|---|---|
| record | the 64-step microprogram of one opcode (startUcodeBlock/endUcodeBlock) |
| step, line | one 8-byte control word; "line" is the generator's word (writeCurrentLine), "step" the hardware's (CADDR0..5) |
| word | the 64 control bits of a step |
| prologue | steps 0..2 (0..5 until 2026-09-29), identical in every record: fetch the opcode and increment the PC (section 5.6) |
| leading / trailing edge | the falling / rising edge of an active-low strobe, i.e. the start / end of the step that asserts it |
| set-up, strobe, release | the generator's three-line pattern around every strobe |
| bus fight | two drivers with different values on one data lane in one step (y1ucemu -w) |
| weak drive | the register card's pull-up $FFFF under a bare -REG-FUNC-RD (M-1), counted but not a fight |
| family | the eight (Rn) or sixteen (Pn) records generated by one loop, identical except for the select fields |