ZILOG

A 1999 DOS demo by Sunflower, reverse-engineered and ported to JavaScript. Everything you see runs from the original code paths, rewritten function by function and checked against the real binary.

0:00 / 0:00

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

  1. Deform — scene-specific functions displace the points: radial wobble, spherical ripple, black-hole lens, ring twist, cone tornado…
  2. 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.
  3. Normals — face normals from cross products; vertex normals as normalised sums over adjacent faces; the normals' xy become environment-map coordinates (n·127+127).
  4. Project — x·invz + cx, cy + y·invz·0.83 (the 0.83 is the 320×200 pixel aspect), invz = 1/(z/focal + dist).
  5. 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).
  6. 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

  1. 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.
  2. 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.
  3. Decompilation. Ghidra (headless), with its calling-convention model patched to Watcom's register convention (EAX, EDX, EBX, ECX), the ftrunc helper declared as a function taking ST0, and float constants inlined, then read next to the raw asm. Several routines are self-modifying and had to be read in assembly.
  4. 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.
  5. 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

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.