Mandelbrot in 14 seconds, no multiply

/ DINO / updated 2026-10-06 / from dino-homebrew @ a5963b5 dino software mandelbrot asm fixed-point

DINO draws the Mandelbrot set. It’s a 490-byte RAM program, loaded over serial with the monitor’s L and started with G 8100. It draws 80 columns by 33 rows and gives you the prompt back. I timed it at 14 s on a 1.024 MHz clock, and about 3 s of that is just the characters going out at 9600 baud.

G 8100 in minicom at 9600 baud. 80 columns by 33 rows, 14 s at 1.024 MHz, and the prompt comes back at the end.
G 8100 in minicom at 9600 baud. 80 columns by 33 rows, 14 s at 1.024 MHz, and the prompt comes back at the end.
Gridx from -2.0 to +0.47 in steps of 1/32, y from +1.0 to -1.0 in steps of 1/16
Iterations24 max, @ = never escaped
Size490 bytes at 0x8100, tables at 0x8600, variables at 0x8700
Time14 s at 1.024 MHz (stopwatch), ~2.5M instructions in the simulator
ROMPROG_imon

Doing the math without a multiply

DINO has no multiply, no shift right, and no add-with-carry. The ALU’s carry-in is wired to the opcode, and the control word bit that was meant to switch it to the carry flag isn’t connected to anything. So two-byte arithmetic would mean branching on carry for every add, and a software multiply on top of that. I worked it out on paper first and ended up with everything in one byte.

Numbers are 1/32 steps, offset by 64. A value v is stored as v*32 + 64, so 0.0 is 64 and anything from just over -2 to just under +2 fits in 1..127. That makes every “did it escape” check an unsigned compare. A CPI 128 with JNC or the borrow out of a SUB is all it takes, and signs never have to be handled in the arithmetic itself.

Squares come out of a table. S[n] = n²/32 gives x² directly. For the cross term I used the old quarter-squares trick: (a+b)² - (a-b)² = 4ab, so with Q[n] = n²/64,

2|x||y| = Q[|x|+|y|] - Q[||x|-|y||]

and the sign of 2xy is just sign(x) XOR sign(y), kept in one byte. One iteration is six table lookups and a handful of adds and compares.

The ranges were picked so nothing overflows where it matters. S[|x|] + cx tops out at 203. S[|x|] + S[|y|] tops out at 248. The only sum that can carry is the y update, and that carry is itself the escape test. Q’s index goes up to 126, so Q starts at 0x8680 and never crosses a page. That matters because indexing is MOVCA / LDBI page / LDAX, and there’s no 16-bit add to fix up a page crossing.

DINO builds the tables itself. Nothing gets typed in. It keeps n² as a running sum, 64q + r, adding n and then n-1 (each add stays under 256) and folding r into q in 64s. Then Q[n] = q and S[n] = 2q + (r >= 32).

The cost of one byte is resolution. A 1/32 grid is coarse, and the edge of the set comes out blobby. I checked the integer scheme against an ordinary floating-point Mandelbrot on the same grid, and they disagree on inside/outside for 50 of the 2640 cells (1.9%), all on the boundary.

One iteration

This is the whole inner loop after |x|, |y| and the sign have been worked out:

squares:  LDA   AX
          MOVCA
          LDBI  STAB
          LDAX
          STA   SA              ; S[ax]
          LDA   AY
          MOVCA
          LDBI  STAB
          LDAX
          STA   T               ; S[ay]
          LDB   SA
          ADD
          CPI   128
          JNC   new_x
          JMP   esc

; ---- x' = x^2 - y^2 + cx
new_x:    LDA   SA
          LDB   COL
          ADD                   ; S[ax] + cxo, at most 203
          LDB   T
          SUB                   ; - S[ay]
          JNC   esc             ; borrow: x' < -2
          CPI   128
          JNC   x_hi_ok
          JMP   esc             ; x' >= 2
x_hi_ok:  CPI   0
          JNZ   x_ok
          JMP   esc             ; x' = -2
x_ok:     STA   XO              ; old x is done with: AX, AY, SGN hold it

; ---- |2xy| = Q[ax+ay] - Q[|ax-ay|]
          LDA   AX
          LDB   AY
          ADD
          ADI   QOFF
          MOVCA
          LDBI  STAB
          LDAX
          STA   SA              ; Q[ax+ay]
          LDA   AX
          LDB   AY
          SUB
          JNC   d_neg           ; ax < ay
          JMP   d_abs
d_neg:    LDA   AX
          LDB   AY
          BSUB                  ; A = ay - ax
d_abs:    ADI   QOFF
          MOVCA
          LDBI  STAB
          LDAX
          STA   T               ; Q[|ax-ay|]
          LDA   SA
          LDB   T
          SUB
          STA   T               ; |2xy|

; ---- y' = 2xy + cy
          LDA   SGN
          CPI   0
          JNZ   y_minus
          LDA   ROW
          LDB   T
          ADD
          JNC   y_plus_ok
          JMP   esc             ; carried: y' >= 2
y_plus_ok: CPI  128
          JNC   y_ok
          JMP   esc             ; y' >= 2
y_minus:  LDA   ROW
          LDB   T
          SUB
          JNC   esc             ; borrow: y' < -2
          CPI   0
          JNZ   y_ok
          JMP   esc             ; y' = -2
y_ok:     STA   YO

DINO only branches when a flag is clear (JNZ, JNC), so “escape if set” is a JNC over a JMP. Every immediate ALU op and every INR/DCR/SHL loads its constant through B, so nothing that matters lives in B for long. Every value worth keeping goes to a RAM cell. The full source is asm/ram/mandel.asm in the repo.

Tested before it touched the machine

The host test loads the program through the real monitor image in the Python oracle, the same L and G the bench uses. The output has to match a Python model of the same arithmetic byte for byte, under both the old polling monitor and PROG_imon. My first version kept its variables at 0x80D0, which is inside imon’s stack, so they moved to 0x8700, and there’s now a test that keeps them out of 0x80A0-0x80FF. On the machine it ran first time.

Open