Space = pause · ← / → = previous / next scene · F = fullscreen. Native resolution is 320×200, scaled with nearest-neighbour like a CRT in a 1999 bedroom would not have bothered to.
The retro context DOS, 1999, the demoscene
A demo is a program whose only purpose is to be watched: music, 3D, effects, all synchronised, all computed live on the machine rather than played back from video. The demoscene grew out of 1980s game-cracking intros and by the late 90s it was a competition of programming craft, graphics and tracker music, judged at "parties" and distributed on BBSs and FTP sites. ZILOG's own release notes say it all: TYPE: dos demo — seems to work under windows, but please run from dos.
Hardware target
A Pentium-class PC with a VESA-capable graphics card and a Sound Blaster/Gravis UltraSound. 320×200 in true colour, which on a 1999 bus meant pushing 256 000 bytes per frame through a banked window or a linear framebuffer.
DOS extender
The executable is a 32-bit flat-model LE program started by PMODE/W 1.30 (Daredevil & Tran), giving 32-bit protected mode with direct access to memory while still talking to BIOS/VESA through DOS services. Compiled with Watcom C/C++32, the compiler of choice for 32-bit DOS code.
No GPU, one FPU
Everything is rasterised in software. 3D transforms run on the x87 FPU (the Pentium could overlap an fxch with a floating-point op, which is why hand-tuned code is full of them), pixel work on integer ALUs with packed-byte tricks.
Packed twice
The binary is compressed with UPX 0.82, and the assets (63 files: textures, meshes, fonts, a 700 KB tracker module) sit in a custom LZW container appended to the executable.
The scenes eleven loops, driven by the music
The demo has no timeline file. Its main() is a chain of while (order < N) loops that read the tracker's current order and row from the sound player, so the picture changes exactly when the music moves on, and effects react to sync markers placed in the module. The port keeps this design: scene code receives the same order*64+row position and marker value, produced by a port of the music player. “▶” jumps the player to the scene.
The music a tracker player, rebuilt bit for bit
The tune (“73 millions seconds”, ana.u, 700 KB) is a USLM module for Useless Sound System 1.04: 28 channels, 60 orders of 64 rows, 57 patterns, 59 samples, 55 instruments, speed 3 / tempo 139. There is no public documentation of this format, so the player was reconstructed from the disassembly: file format, ~60 effect handlers, envelopes, period tables and the software mixer. The port reproduces the original's Sound Blaster 16 IRQ structure — every 884-frame block (20.045 ms) runs all due ticks, then mixes — and was checked against the real machine code on all 10 448 blocks of the song: order, row, per-voice state and every mixed 16-bit sample are identical.
The scene thresholds fall at these moments of the song: order 5 = 17.3 s, 14 = 48.5 s, 19 = 67.5 s, 23 = 81.4 s, 27 = 95.2 s, 31 = 109.0 s, 35 = 122.9 s, 41 = 143.7 s, 49 = 171.3 s, 51 = 178.3 s; the song ends at 209.4 s.
Inner loops asm worth reading
Disassembled from the unpacked binary (addresses are in the 32-bit flat image, load base 0x10000). Highlighted lines are the clever part. Most of these are 1999 optimisation folklore: no multiplies in the pixel loops, no divides, loop counters and coordinates hidden in registers, code that rewrites itself.
1 · A box blur with no division: the running sum is a pointer
func_16cdd blurs the 320×200 true-colour screen with a sliding window of width w. Instead of keeping a sum and dividing, each channel's sum is kept in a register that starts at the base of a lookup table; the table holds each value 0..255 repeated w times (built with rep stosb), so [base+sum] is sum/w. One pixel costs three adds, three subtracts, three table reads. The displacement 0x11111111 is a placeholder: the setup code patches the loop with the distance between source and destination, so the same code blurs screen→buffer without an extra register.
16d53: mov eax, [0x16b40] ; eax = edi = ebp = base of the "divide by w" table 16d58: mov edi, eax 16d5a: mov ebp, eax ... 16dcd: mov ebx, [esi + 0x11111111] ; incoming pixel (displacement patched at run time) 16dd3: mov dl, bl >16dd5: add eax, edx ; "sum" of channel 0 (a pointer into the table!) 16dd7: mov dl, bh >16dd9: add edi, edx ; channel 1 16ddb: shr ebx, 8 16dde: mov dl, bh >16de0: add ebp, edx ; channel 2 16de2: mov ebx, [esi + 0x11111111] ; outgoing pixel ... 16dea: sub eax, edx ; ... subtracted the same way (sub eax/edi/ebp) 16df7: mov bh, [ebp] ; three table reads = three divisions 16dfa: shl ebx, 8 16dfd: mov bl, [eax] 16dff: mov bh, [edi] 16e01: mov [esi + 0x11111111], ebx ; store the blurred pixel 16e0b: jne 0x16dcd
2 · A texture span whose loop counter lives inside the fraction register
The affine texture mapper (0x2bbec) keeps u and v as 8.16 fixed point. The 8-bit integer parts sit in bl and bh, so they wrap at 256 texels for free, and [ebx*4] is directly the texel address (textures are 256 KB-aligned, their base lives in the high half of ebx). The fractional parts are added with add and their carry is folded into the integer coordinate with adc. The pixel count rides in cx, the low half of the register whose high half is u's fraction.
2bd66: mov eax, [ebx*4] ; texel = tex[v<<8 | u], wrap-around for free 2bd6d: add edx, ebp ; v fraction += dv >2bd6f: adc bh, dh ; carry -> v integer 2bd71: add ecx, esi ; u fraction (high half of ecx) += du >2bd73: adc bl, dl ; carry -> u integer; the count in cx is untouched 2bd75: mov [edi], eax 2bd77: add edi, 4 2bd7a: dec cx 2bd7c: jne 0x2bd66
3 · Environment + texture in one pass: gradients patched into the loop
The two-texture mode (used for the brown blob) interpolates four coordinates — environment u,v and surface u,v. The four gradients are immediates written into the loop before it runs (0x11111111, 0x11 are placeholders), so the inner loop is two loads, one add and four add/adc pairs. The second texture lives exactly 256 KB after the first. The pixel is a plain 32-bit add of the two texels.
2f341: mov eax, [ebx*4] ; environment texel 2f348: add eax, [edx*4] ; + surface texel (second texture, 256 KB further) 2f34f: mov [edi], eax 2f351: add edi, 4 >2f354: add ecx, 0x11111111 ; each 0x11... below is patched by the setup code 2f35a: adc bl, 0x11 2f35d: add ebp, 0x11111111 2f363: adc bh, 0x11 2f366: add esi, 0x11111111 2f36c: adc dl, 0x11 2f36f: add bp, 0x1111 2f374: adc dh, 0x11 2f377: dec cx 2f379: jne 0x2f341
4 · The whole sound mixer fits in six instructions per sample
The Sound Blaster mixer (0x3a6e0) works at 44.1 kHz in 16-bit stereo with a 16.16 sample position. The 8-bit sample goes into bl; bh and dh hold the voice's left and right volume rows of a 65×256 table, so [ebx*2] is “sample × left volume” and [edx*2] “sample × right volume” — no multiply. The position's fraction advances with add cx,step and the carry is added to the sample pointer by adc esi,ebp (ebp = integer step).
3a6e0: mov bl, [esi] ; 8-bit sample (bh = left volume row) >3a6e2: add cx, [0x3989a] ; position fraction += step ... 3a6e9: mov ax, [ebx*2] ; left = table[vol_l][sample] >3a6f1: adc esi, ebp ; ... carry advances the sample pointer 3a6f3: mov dl, bl ; dh = right volume row 3a6f5: add [edi], ax ; accumulate into the 16-bit mix buffer 3a6f8: mov ax, [edx*2] ; right = table[vol_r][sample] 3a700: add [edi + 2], ax
5 · 3×3 colour matrix without a single multiply
func_22990 applies a colour-space transform (the YCbCr-like strip that pulses on the beat) using nine table sums: tables built at start-up hold trunc(i·2²⁴·coef) for each coefficient, so each output channel is three loads, two adds and a shift.
22a03: mov eax, [ebp + ebx*4] 22a07: add eax, [ebp + ecx*4 + 0x400] 22a0e: add eax, [ebp + edx*4 + 0x800] 22a15: shr eax, 0x18 22a18: add eax, 0x70
6 · A 2-D palette lookup in four instructions
The colourised plasma strip of the lattice and intro scenes is screen = LUT[(index << 8) | blur]: the two 8-bit planes are fetched into al and ah, which together form a 16-bit index into a 256×256 table of dwords. The counter runs from −64000 up to zero so the loop needs no compare.
22ba4: lea edi, [edi + ecx*4] ; bias the base registers by -count ... 22ba7: lea esi, [esi + ecx] 22baa: lea ebx, [ebx + ecx] 22bad: xor ecx, 0xffffffff ; ... and negate the counter: loop runs up to 0 22bb0: inc ecx >22bb1: mov al, [ebx + ecx] >22bb4: mov ah, [esi + ecx] >22bb7: mov ebp, [edx + eax*4] ; one load = the final colour 22bba: mov [edi + ecx*4], ebp 22bbd: inc ecx 22bbe: jne 0x22bb1
7 · A rotation matrix with fewer multiplies (Winograd's inner product)
Mesh rotation (0x17e5e) never does three multiplies per output coordinate. It uses x' = (m₀ + y)(m₁ + x) + z·m₂ − (m₀m₁ + x·y): the cross term x·y is computed once per vertex and shared by the three rows, and each row's m₀m₁ is precomputed once per object. On a Pentium, where an FPU multiply took several cycles, that was a real saving.
17fc9: fld dword [ebp - 0x68] ; x 17fcc: fmul dword [ebp - 0x64] ; * y 17fcf: fstp dword [ebp - 0x6c] ; x*y: computed once, used by all three rows 17fd2: fld dword [ebp - 0x50] ; m0 17fd5: fadd dword [ebp - 0x64] ; + y 17fd8: fld dword [ebp - 0x4c] ; m1 17fdb: fadd dword [ebp - 0x68] ; + x >17fde: fmulp st(1) ; (m0 + y)(m1 + x) 17fe0: fld dword [ebp - 0x60] 17fe3: fmul dword [ebp - 0x48] ; z * m2 17fe6: faddp st(1) 17fe8: fld dword [ebp - 0x2c] ; precomputed m0*m1 17feb: fadd dword [ebp - 0x6c] ; + x*y 17fee: fsubp st(1) ; result
8 · The assets: an LZW bit reader that shifts one bit at a time
All 63 assets are LZW-compressed. The decoder reads 9 to 12-bit codes LSB-first: it fetches three bytes at bitpos/8, then shifts the 24-bit value right by bitpos&7 one bit at a time with shr al,1 / rcr bx,1, and finally masks with a table indexed by width−9.
246a1: mov eax, [0x3eede] ; bit position 246a6: movzx ebx, word [0x3eed0] ; current code width (9..12) 246ad: add eax, ebx 246af: xchg [0x3eede], eax ; advance, keep the old position 246b5: mov dx, ax 246b8: and edx, 7 ; bit offset inside the byte 246bb: shr eax, 3 ; byte offset 246c6: mov ax, [esi] ; 3 bytes = 24 bits 246cf: mov al, [esi] 246d2: mov cx, dx 246d5: jcxz 0x246e1 >246d8: shr al, 1 ; 24-bit shift right, one bit per iteration >246da: rcr bx, 1 246dd: dec cx 246df: jne 0x246d8 246f2: and ax, [ebx*2 + 0x3eed6] ; mask table 0x1ff,0x3ff,0x7ff,0xfff
9 · ftrunc: truncating a float through the FPU control word
Watcom's runtime has no fisttp (SSE3 did not exist). To truncate toward zero, the code rewrites the x87 rounding-control bits, runs frndint, and restores the control word. It is called from over 200 places in the demo's code, and it's why the port has a trunc32() next to a round-to-nearest-even rne(): the original mixes both.
1c472: push eax 1c474: fnstcw [esp] ; save the control word 1c478: push dword [esp] >1c47b: mov byte [esp+1], 0x1f ; RC = 11b: round toward zero >1c480: fldcw [esp] 1c483: frndint ; ST0 = trunc(ST0) 1c485: fldcw [esp+4] ; restore 1c48e: ret
The 3D engine twelve meshes, ten render modes
Models are stored in a trivial chunked format: MOB:, then PNTS (float triples), FACE (index triples) and EDGE (index pairs). At load time the engine derives vertex→face adjacency (for smooth vertex normals) and edge→face adjacency (for silhouette edges: an edge is on the outline when its two faces have opposite screen-space winding). These are the twelve models, read live from the demo's own data file:
The pipeline, per object per frame
- Deform — scene-specific functions displace the points: radial wobble, spherical ripple, black-hole lens, ring twist, cone tornado…
- Rotate — three Euler angles built from nested sines, e.g.
3·sin(cos(0.008t)+0.009t); either from the original points or cumulatively in place. - Normals — face normals from cross products; vertex normals as normalised sums over adjacent faces; the normals' xy become environment-map coordinates (
n·127+127). - Project —
x·invz + cx,cy + y·invz·0.83(the 0.83 is the 320×200 pixel aspect),invz = 1/(z/focal + dist). - Cull & sort — signed screen-space area removes back faces; the survivors are sorted by
trunc((z₀+z₁+z₂)·100)with a quicksort (painter's algorithm — there is no z-buffer). - Rasterise — one of ten modes: environment-mapped faces (0, 1), environment + surface texture in one pass (2, 3), UV-textured faces (4), silhouette lines (5, 6), all edges (7, 8), or sprite points sized by 1/z (9).
Every one of these stages — including each rasterised pixel — is checked against the original machine code, see below.
How it was ported
- Unpacking. UPX 0.82's PMODE/W format is not understood by today's
upx -d. The packer's own stub was emulated with Unicorn until it jumped to the original entry point, and the memory image dumped. - Assets. The 2.4 MB tail of the EXE is a container of 63 LZW files; the decoder above was ported to JS and validated on every file.
- Decompilation. Ghidra (headless), with its calling-convention model patched to Watcom's register convention (EAX, EDX, EBX, ECX), the
ftrunchelper declared as a function takingST0, and float constants inlined, then read next to the raw asm. Several routines are self-modifying and had to be read in assembly. - Differential testing. Each ported function is run next to the original machine code (executed in an emulator, with its real data) on the same inputs and the output buffers are compared byte for byte: 216 vectors for the 2D library, 206 checks for the 3D engine (every rendered pixel identical), all 10 448 blocks of the music, and per-scene effect functions. Where numbers differ it is documented: x87 keeps 80-bit intermediates and JS has 64-bit doubles, so a handful of floating-point deformers differ in the last bit.
- Scenes. Each of the eleven loops of
main()was transcribed from the assembly, then compared with a capture of the original running in DOSBox.
Traps worth knowing
- Unicorn starts the x87 with a 24-bit-precision control word; the real program runs after
fninit(64-bit). Tests silently disagreed until this was found. - The DOSBox capture shows red and blue swapped in VESA 32-bit modes (verified with a test program drawing pure red): the screen dword is
0x00RRGGBB, and the warm orange of the title picture is the true colour. - A bug that only exists in a browser: canvas
ImageDatais a clamped array, soout[o] = d >>> 8saturates instead of wrapping; the Node tests used a plainUint8Array. - The decompiler hid that the rasteriser expects triangle vertices in memory as (v0, v2, v1); without it the fillers draw nothing.
Credits
ZILOG is © 1999 Sunflower — code, graphics and music by mrock, falcon, visualize and voomie. Sound system: Useless Sound System v1.04 by FreddyV/Useless. DOS extender: PMODE/W by Daredevil & Tran. This site is a fan study and port: original artwork, models and music are the property of their authors and are shown here with credit; tell us and they go.