hops

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).

1. What it is

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.

2. What it does

Panel UI

ControlFunctionDetails
TW 1–3A − small / medium / largeEach 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–6A + small / medium / large
TW 7–12B −/+ small / medium / large
TW 13–18C −/+ small / medium / large
SS1Reset A to 0Level-triggered (acts every sample while up). Only the one slider is touched — never the point or the other parameters.
SS2Reset B to 0.01
SS3Reset C to 0.01
SS6Full reset + reseedA=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.

3. Fixed point and the math

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.

4. Building, loading, running

./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:

Keep-alive: the debugger's 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.

5. Program anatomy (high level)

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.)

6. Memory layout (octal)

AddressSymbolContents
00004–00016c1 … csampconstants (see table below)
00017–00042x … pvariables (see table below)
00043goentry: load defaults, seed (1,0)
00060loopone attractor point (iterate + plot)
00102srefsqrt table load (dap-spliced)
00212(dpy)720307 — dpy-i, intensity 3
00224smplpanel sample (TW edges + sense levels)
00243 / 00270 / 00316drefA / drefB / drefCslider delta loads (dap-spliced)
00356r4aSS6 full-reset block
00372sretsample return (jmp ., dap-spliced)
00373–00772sqtab256 words: round(32·√u), u=0..255
00773–01072dltab64 words: slider deltas for a 6-bit field

567 words total (00004–01072). Code ends at 00372; two-thirds of the program is data.

Constants

SymbolValueMeaning
c11counter decrement
c770776-bit field mask
c255377sqrt table clamp (255)
cmax12000slider clamp +5.0 (5120)
cnegm765777slider clamp −5.0 (one unit below −5120 — harmless asymmetry, §8)
cn511777000display clamp −511 (one unit below — same footnote)
c5121000display clamp +512
cdefb / cdefc12B / C default 0.01 (octal 12 = decimal 10)
cseed2000seed x = 1.0 (1024)
csamp256points between panel samples

Variables

AddrSymbolPurpose
17/20/21/22/23x, y, A, B, Cthe state: point and the three parameters (all Q10)
24xtmpold x (y' = A − x_old)
25at5A/2 offset in display units (A>>4)
26usqrt argument, clamped 0..255
27ssqrt term, sign-adjusted
30q(B·x)>>10
31dltslider delta / field stash
32–35xd, yd, xr, yrdisplay coordinates (xd/yd = ±511 clamped; xr/yr = packed)
36cntpoint counter (256 → sample)
37/40curtw / prevtwcurrent / previous test word (edge detection)
41twrrising-edge mask
42pspare (unused in the final build)

7. Code walkthrough

7.1 Entry — 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).

7.2 The iterate — 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:

7.3 The plot (00120–00214)

        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

7.4 The counter

        lac cnt
        sub c1
        dac cnt
        sza
        jmp loop
        jsp smpl       ; every 256 points: sample the panel
        lac csamp
        dac cnt
        jmp loop

7.5 The panel sample — 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)

7.6 Sense-switch resets (00330–00364)

        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 .

7.7 The tables

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).

7.8 The transfer word

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.

8. Bugs found and fixed (the war stories)

1. EAE packing — everything doubled. 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.
2. The sign-dance single-skip. 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.
3. The A = 5130 mystery (the longest one). The naive reset 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.
4. The defer quirk (emulator finding). 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.
5. macro1_1 gotchas. Space is the ADD operator (TAB separates fields); (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.

9. Verification

10. Emulator notes and tooling

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.