mul leaves 2·P in AC:IO, so scl 8s
gave 2·(P>>10) and the first plotted point was 2× off. Fix: scl 7s (shift 11 = 18−7).
Found by tracing the very first point against hand math.
Hopalong radiant attractor for the PDP-1 + Type 30 display — source directory document, 2026-08-14.
Files: head.mac (main source), tables.mac (generated), gen_tables.py (table generator), tail.mac (transfer word), build.sh, hops.mac (assembled), hops.lst (listing), hops.rim (READ-IN tape).
A port of the JavaScript hopalong attractor warpgrid radiant (hops.js, warpgrid.xyz/radiant) to PDP-1
assembly (macro1_1), drawing continuously on a Type 30 display. It is a pure number-crunching program: one tight loop
computes attractor points in Q10 fixed point, fires dpy instructions at the display, and every 256 points
samples the front-panel switches so you can steer the picture live. The whole program is 567 words (about a fifth of the
8K memory), of which 320 are lookup tables.
The attractor is the classic hopalong recurrence:
x' = y − sign(x)·√|B·x − C| y' = A − x
with defaults A = 0, B = C = 0.01, seed (1, 0) — identical to the JavaScript original. For those defaults the orbit is the dense circular "radiant" knot. The three parameters A, B, C are live front-panel controls (TW switches), so the attractor can be morphed while it runs.
The recurrence is order-one (the new x needs the old x for y'), but note that x' depends on old y and y' on
old x — the code keeps the old x in a spare cell (xtmp) for one instruction.
| Control | Function | Details |
|---|---|---|
| TW 1–3 | A − small / medium / large | Each parameter owns six switches: the left three decrement, the right three increment, in steps 0.001 / 0.01 / 0.1 (Q10: 1 / 10 / 102). A click on any switch applies its step once (rising-edge triggered). Multiple increments combine (e.g. medium+large = 112). |
| TW 4–6 | A + small / medium / large | |
| TW 7–12 | B −/+ small / medium / large | |
| TW 13–18 | C −/+ small / medium / large | |
| SS1 | Reset A to 0 | Level-triggered (acts every sample while up). Only the one slider is touched — never the point or the other parameters. |
| SS2 | Reset B to 0.01 | |
| SS3 | Reset C to 0.01 | |
| SS6 | Full reset + reseed | A=0, B=C=0.01, x=1.0, y=0 — mirrors the JS reset button. |
Sliders clamp to ±5.0 (Q10 ±5120). SW4/SW5 are spare; there is deliberately no auto-drift
(the JS's commented-out A += 0.005 was not carried over) — the picture only changes when you change it.
All values are Q10 fixed point: 1.0 = 0o2000 = 1024, so the whole attractor (which stays within ±3 units at default settings) is comfortably inside 18 bits. The slider range ±5.0 is ±5120 Q10.
mul. The EAE packs the product doubled (AC:IO = 2·P), so the
extraction is scl 7s, an arithmetic 36-bit shift right 11: (2P)>>11 = P>>10, sign-correct.
The `s` suffix matters: it is an arithmetic shift, so negative products stay negative.sqtab holding
round(32·√u) for u = 0..255. The argument u is clamped to 255, so extreme slider settings saturate
gracefully. The result is the Q10 square root: √(u/1024) = √u/32 units = 32·√u Q10.
s is then negated if x < 0 (and left at 0 for x = ±0, matching Math.sign).
./build.sh # gen_tables.py > tables.mac; cat head.mac tables.mac tail.mac > hops.mac; macro1_1 hops.mac
cp hops.rim /opt/pidp1/tapes/hops.rim
Two load paths:
hops.rim and press Read In. The tape's final
word is start go (the block-tape transfer word), so the program loads and starts itself and then runs
forever — the loop has no halt instruction. No keep-alive needed.deposit.py hops.lst (567 words, verifiable 0-error load), then
muldiv on (the EAE is a machine-global switch — without it mul is a single-step
mus), check (core vs listing), w pc go, run.run verb stops the machine after 10M cycles
(≈50 s; MAXRUN in dbg.c). For long demo sessions a host-side loop re-fires run 10000000 when it
stops. This is purely a debug-session convenience — the READ-IN path needs none.The program is a single infinite loop. Each pass computes one point and draws it; every 256th pass it samples the
panel instead of drawing (the counter cnt is reloaded from csamp):
go (init defaults)
|
v
loop --> q = (B·x)>>10 (mul + scl)
| u = |q − C|, clamp 0..255
| s = sqtab[u], sign-adjusted for x
| x' = y − s ; y' = A − x_old
| xd = (x−A/2)>>3, yd = −(y−A/2)>>3 (2× zoom, y-flip)
| clamp xd,yd to ±511 ; dpy (intensity max)
| cnt−− ; sza ? back to loop : smpl
| |
+----------<----------------- sample: read TW, edge-detect,
decode 3 slider fields via dltab,
sense-switch resets, reseed
The only indirect-addressing mechanism in the whole program is the dap splice: the address of an indexed
lac is patched at run time. Three slider lookups (drefA/B/C) and the sqrt lookup
(sref) work this way. (This is not stylistic: lac i p reads the wrong word on this emulator
— see §9.)
| Address | Symbol | Contents |
|---|---|---|
| 00004–00016 | c1 … csamp | constants (see table below) |
| 00017–00042 | x … p | variables (see table below) |
| 00043 | go | entry: load defaults, seed (1,0) |
| 00060 | loop | one attractor point (iterate + plot) |
| 00102 | sref | sqrt table load (dap-spliced) |
| 00212 | (dpy) | 720307 — dpy-i, intensity 3 |
| 00224 | smpl | panel sample (TW edges + sense levels) |
| 00243 / 00270 / 00316 | drefA / drefB / drefC | slider delta loads (dap-spliced) |
| 00356 | r4a | SS6 full-reset block |
| 00372 | sret | sample return (jmp ., dap-spliced) |
| 00373–00772 | sqtab | 256 words: round(32·√u), u=0..255 |
| 00773–01072 | dltab | 64 words: slider deltas for a 6-bit field |
567 words total (00004–01072). Code ends at 00372; two-thirds of the program is data.
| Symbol | Value | Meaning |
|---|---|---|
| c1 | 1 | counter decrement |
| c77 | 077 | 6-bit field mask |
| c255 | 377 | sqrt table clamp (255) |
| cmax | 12000 | slider clamp +5.0 (5120) |
| cnegm | 765777 | slider clamp −5.0 (one unit below −5120 — harmless asymmetry, §8) |
| cn511 | 777000 | display clamp −511 (one unit below — same footnote) |
| c512 | 1000 | display clamp +512 |
| cdefb / cdefc | 12 | B / C default 0.01 (octal 12 = decimal 10) |
| cseed | 2000 | seed x = 1.0 (1024) |
| csamp | 256 | points between panel samples |
| Addr | Symbol | Purpose |
|---|---|---|
| 17/20/21/22/23 | x, y, A, B, C | the state: point and the three parameters (all Q10) |
| 24 | xtmp | old x (y' = A − x_old) |
| 25 | at5 | A/2 offset in display units (A>>4) |
| 26 | u | sqrt argument, clamped 0..255 |
| 27 | s | sqrt term, sign-adjusted |
| 30 | q | (B·x)>>10 |
| 31 | dlt | slider delta / field stash |
| 32–35 | xd, yd, xr, yr | display coordinates (xd/yd = ±511 clamped; xr/yr = packed) |
| 36 | cnt | point counter (256 → sample) |
| 37/40 | curtw / prevtw | current / previous test word (edge detection) |
| 41 | twr | rising-edge mask |
| 42 | p | spare (unused in the final build) |
go (00043)Loads the defaults: x = 1.0 (cseed), y = 0, A = 0, B = C = 0.01, cnt = 256, prevtw = 0 (so the first sample sees everything as "new" edges only if you flip a switch after start — with prevtw = 0 the first sample's rising-edge mask contains every switch that is up at that moment, which is exactly the JS behaviour of picking up the initial panel state).
loop (00060)
loop, lac x
mul B ; AC:IO = 2·(B·x) (EAE packing: product doubled)
scl 7s ; AC = (2·(B·x))>>11 = (B·x)>>10, sign-correct
dac q
lac q
sub C ; q − C
sma
jmp qpos
cma ; abs: negate if negative
qpos, dac u
sub c255 ; clamp u to 255
spa
jmp uok
law 377
dac u
uok, law sqtab ; s = 32·√u (Q10), u-indexed
add u
dap sref ; splice table base+u into the load below
sref, lac 0
dac s
lac x ; sign of x: ±0 → s = 0
sza
jmp sx1
law 0 ; x = +0
dac s
jmp sxdn
sx1, cma
sza
jmp sx2
law 0 ; x = −0
dac s
jmp sxdn
sx2, lac x
sma ; x negative? skip the jump
jmp sxdn ; x positive: s unchanged
lac s
cma ; x negative: s = −s
dac s
sxdn, lac x
dac xtmp
lac y
sub s
dac x ; x' = y − s
lac A
sub xtmp
dac y ; y' = A − x_old
Notes:
mul leaves the product doubled in AC:IO (the classic packing;
the sign bit sits in IO's top bit). scl 7s shifts the 36-bit pair right 11, giving (2P)>>11 =
P>>10. A naive scl 8s would give 2·(P>>10) — the first live bug, §8.sma / jmp qpos / cma is the single-skip pattern; then u is clipped to 255 (the
table's index range) — spa skips the clamp when u ≤ 255.law sqtab; add u; dap sref patches the operand of sref, lac 0 to
sqtab+u, then executes it. This is the canonical PDP-1 indexed load. It is used because lac i p on
this emulator reads C(C(p)−2) instead of C(C(p)) — the defer quirk, §9.Math.sign(x) must return 0 for both +0 and −0, or the first step of the
orbit would be off by a full √ term. The four-way test: sza catches +0; the
cma; sza pair catches −0 (negating −0 yields +0); otherwise sma picks the
sign. Each skip jumps over exactly one instruction — this discipline is the whole program's bug-history
(§8).
lac A
sar 4s ; at5 = A>>4 (the A/2 offset, in display units)
dac at5
lac x
sar 3s ; x>>3: 2× zoom, 128 px/unit
sub at5
dac xd ; xd = (x − A/2)>>3
lac y
sar 3s
sub at5
cma
dac yd ; yd = −(y − A/2)>>3 (CRT y-up vs canvas y-down)
; clamp xd to +511 / −511, same for yd
...
yok2, lac xd
sal 8s ; x into AC bits 8–17
dac xr
lac yd
sal 8s
dac yr
lac xr
lio yr
720307 ; dpy-i: x in AC, y in IO, intensity bits 6-8 = 3
sar 3s halves the shift of the original 1× build (sar 4s, 64 px/unit).
The A-slider offset scales with it (A>>5 → A>>4), so the "move with A" behaviour is preserved at
2×. Verified by capture: mean radius 74 → 140, span 250 → 498, centroid unchanged.sub c512 / spa (upper) and
add c512 / sma (lower) pairs, forcing 511 (law 777) or cn511. At 2× the clamp can actually
engage at extreme slider settings (|x|→4096 Q10 → xd→±512), pinning the picture at the edge
— graceful by design. (One-unit asymmetry in the lower constants — §8.)dpy-i is fire-and-forget (no wait for the 35 µs deflection); the loop
body is ≈100 instructions, so points never pile up. The word is written as the raw octal 720307 because
dpy-i is not in macro1_1's symbol table (only dpy = 0730007 is). The intensity field
(bits 6–8) is 3 — which the emulator sends to the display client as wire brightness (3+4)&7 = 7,
the client's maximum (its shader maps wire 0..7 onto beam 0.25..1.0). The original build used 720007 (intensity
0 → wire 4 ≈ 68% beam).
lac cnt
sub c1
dac cnt
sza
jmp loop
jsp smpl ; every 256 points: sample the panel
lac csamp
dac cnt
jmp loop
smpl (00224)smpl, dap sret ; return splice lat ; AC = test word (18 TW switches) dac curtw lac curtw dac twr lac prevtw cma and twr ; rising edges only: twr = curtw & ~prevtw dac twr ; A: field = twr & 077 lac twr and c77 dac dlt law dltab add dlt dap drefA ; splice table base+field into the load below drefA, lac 0 add A dac A sub cmax ; clamp A to +5120 spa jmp aok1 lac cmax dac A aok1, lac A add cmax ; clamp A to −5120 sma jmp aok2 lac cnegm dac A aok2, ; B: field = (twr>>6) & 077 ... (identical block, drefB) ; C: field = (twr>>12) & 077 ... (identical block, drefC)
twr = curtw & ~prevtw makes each click apply its step exactly once, no matter how many samples
the click spans.sar 9s + sar 3s.
szs 10 ; skip next when SS1 is CLEAR
jmp r1a ; SS1 up: reset A
jmp r1 ; SS1 clear: skip the reset
r1a, law 0
dac A
r1, szs 20 ; SS2: B = 0.01
jmp r2a
jmp r2
r2a, lac cdefb
dac B
r2, szs 30 ; SS3: C = 0.01
...
r3, szs 60 ; SS6: full reset + reseed
jmp r4a
jmp r4
r4a, law 0
dac A
lac cdefb
dac B
lac cdefc
dac C
lac cseed
dac x
law 0
dac y
r4, lac curtw
dac prevtw
sret, jmp .
szs n skips exactly one instruction when the switch is clear. The naive
szs 10 / law 0 / dac A therefore leaves dac A dangling on the skip path — the AC
still holds whatever the previous code left. That exact bug wrote 5130 into A every sample (the C-clamp residue
10+5120) and, via the runaway SS6 reseed, was the "broken cloud" the display showed before the fix — §8.szs / jmp rNa / jmp rN — the first jump is
skipped when the switch is up (fall into the reset), the second is skipped when it is clear (fall past it).prevtw = curtw closes the edge detector; sret, jmp . is the return (its operand was
patched by dap sret at entry).sqtab (00373–00772, 256 words): round(32·√u) for u = 0..255. Example: the
defaults give u = 20 for the first step of the seeded orbit, s = 143 — matching hand computation. The table's
first word is labelled; continuation lines are bare octal words (the macro1_1 data-table idiom).
dltab (00773–01072, 64 words): slider deltas as described in §7.5. Word 0 = 0 (no bits set = no step), the natural "nothing pressed" case.
Both are emitted by gen_tables.py so the build is deterministic and machine-checkable
(python3 -c … re-derivation of all 320 words matched the listing).
tail.mac is exactly start go — the block-tape transfer word that makes the READ-IN
tape auto-start at go. It must be the last line, or the tape loader dies with garbage in 7760.
mul leaves 2·P in AC:IO, so scl 8s
gave 2·(P>>10) and the first plotted point was 2× off. Fix: scl 7s (shift 11 = 18−7).
Found by tracing the very first point against hand math.
spa / lac s / cma / dac s — spa skips only the
lac s, so cma negated x (still in AC) and dac s stored −1024 into
s. Fix: the jump-based sign block (§7.2). Lesson: on the PDP-1, every skip skips exactly one word — plan
accordingly.
szs 10 / law 0 / dac A skipped only the
law 0, so dac A executed with the C-clamp residue (10 + 5120) in AC — every 256-point
sample wrote 5130 into A. The runaway SS6 block then reseeded x,y constantly, so the display showed a dense centered
cloud — the broken orbit, not the attractor. The smoking gun was a conditional debug trap
(run 10000000 if M[21]=12012) that stopped right after the dac A at pc 336. Fix: the jump-in
blocks (§7.6). Same bug class as #2.
lac i p on this emulator reads C(C(p)−2), not
C(C(p)) — probed twice, unexplained. Worked around with the dap splices, which are the canonical PDP-1 idiom
anyway. Reported to Oscar as a candidate emulator bug.
(expr is a literal-pool
reference that only resolves with a constants pseudo-op; law symbol loads the symbol's
address, not its value. All three cost a debugging session each.
Known cosmetic asymmetries (invisible in practice): cnegm = −5121 (one unit below
the −5120 comment; the lower slider clamp range is −5121..+5120) and cn511 = −512 (the
lower display clamp constant is one below −511; xd = −512 slips through and center-pins). The orbit stays
well inside both.
check after every deposit — 0 of 567 words differ.muldiv on must be issued before running (and after any machine reset).run caps at 10M cycles (≈50 s); conditional traps
(run N if M[addr]=value) are excellent for catching rogue writers; sw commands use
decimal switch numbering (SS1 = sw ss 40, SS6 = sw ss 1, TW4 =
sw tw 10) and need panel on armed first; breakpoints re-fire on continue if the PC is
still at them (unarm before continuing).display_svg.py [seconds] out.svg sniffs port 3400 and renders the points
(bright/dim by wire intensity). The machine must be running — a stopped machine yields an empty capture.Document reflects the current build (2× zoom, intensity 3). The header comments of head.mac still describe the original 1× mapping (A>>5, 64 px/unit) and were left untouched.