Porting STARS to JavaScript

How STARSv2.EXE (Noooon, 1995) was unpacked, understood and re-written, and how each effect works.

Part 1: Making of

The executable

STARSv2.EXE is 1.24 MB: a tiny DOS stub, a 76 KB packed “MZ image”, then an overlay holding 58 resources (the table of contents sits in the last 696 bytes). The packing is two layers deep.

  1. Anti-debug decryptor. The entry point reads the interrupt-controller mask (port 21h) and jumps into the middle of an instruction. A word-by-word decrypt loop runs first, then a second stage that hooks INT 1 and INT 3 and executes one operation per trap, swapping vectors on the fly. Tracing it by hand is miserable, so we emulated it: a Unicorn 16-bit harness with a hand-made interrupt table finished in about 540k instructions.
  2. PKLITE-style LZ. The result is a classic LZ decompressor (“Not enough memory$”), about 1.7M emulated instructions, which expands to ~600 KB and relocates itself.
  3. PMODE v3.07 by Tran. What is left is a real-mode loader that detects RAW/XMS/VCPI/DPMI, enables A20 and switches to 32-bit protected mode. We dumped the flat 32-bit image (image32.bin) where file offset equals address. The demo itself is one big 386 assembly program with the LCA player (v3.02, GUS + Sound Blaster) linked in.

Resources

The program opens itself and reads 12-byte table entries (id, offset from EOF, size). Many resources are compressed with a format whose header carries the magic dlz at bytes 6-8, preceded by a decoy mov ah,4Ch / int 21h. It is a PKLITE-like decoder with 16-bit LSB-first flag words; we rewrote it in Python (re/dlz.py) and extracted all 58 resources.

Ground truth: the Unicorn harness

Instead of only reading disassembly, we run the original code in a Unicorn emulator (flat 32-bit model, a GDT with dynamic selectors, DPMI / disk / sound / PIT / VGA ports stubbed, a fake 70 Hz timer IRQ and a simulated music position). A snapshot after the 100-second global initialisation lets each part start in seconds. The harness dumps VRAM and palette as PNG frames. Each JS part is rendered headlessly in Node (web/tools/render.mjs) and compared pixel by pixel to the harness frames at many frame indices. Where the original uses random numbers, the RNG (s^=0x78d47e5d; s*=0xd413d473; rol 18) is reproduced, and the check is on shape rather than dots.

The 3D engine (lib3d.js) is a port of the shared library at 0x78000 plus its polygon rasterizers: fixed-point matrices (.10), projection, object sort, and about 20 shading modes (flat env lookup, texture, bump, warp…). Rasterizers are tested by calling the original function in Unicorn and the JS one on random triangles and hashing the 64000-byte result (400/400 identical for most modes).

Work split

One agent per effect, each given the same brief: where the code is, how to run the harness, the conventions of the original (segment tricks, fixed-point formats, timing), and three deliverables: a faithful web/parts/<name>.js, a short note in re/parts/<name>.md, and a verification against harness frames. A main agent owned the shared engine, the music, the timeline (script.js) and the integration. Because every part is verified independently against the original, a wrong guess shows up as a pixel diff rather than as an impression that something looks off.

What is approximate or unverified

Part 2: The effects, in demo order

All effects run at 70 Hz in mode 13h (320×200, 256 colours). The main program is a linear script: init, loop, cleanup, wait for a music position. Screenshots are 320×200 frames from the JS port.

1. Intro (title pictures and flower screens)

A scripted slideshow synchronised to the music position (order<<8 | row). Pictures are a 768-byte 6-bit palette plus 64000 pixels. They are never redrawn: the bitmap goes into VRAM with a black palette and the fade is done by scaling the palette every vsync (pal*level>>8, clamped), 129 frames in, hold, out. The four “flower screens” (one per member) composite a name sprite, a word sprite and a rotating 256×256 texture with colour 0 transparent.

Picture fade
Picture fade
Flower screen
Flower screen

2. E1: Tunnel

Precomputed lookup table: for every screen pixel a 16-bit value built by walking concentric ellipses (radius 0x1400/(m+19) gives the depth) with a sine table, first writer wins. Each frame two 256×256 textures (the second one reduced to 16 levels) are sampled at that table plus four scrolling offsets, summed to make the flowing tunnel. Scroll speed decelerates to zero around frame 0x124-0x208. 521 frames, palette fades in during the first 64.

E1
E1: Tunnel

3. E2: Wasp (environment-mapped morphing 3D scene)

Our 3D library (see below), 68 flat-shaded objects (the wasp parts) drawn with a spherical “env map” lookup (colour = a 256×256 image indexed by the face normal), over a still picture. Seven objects interpolate between 4 key poses, the camera interpolates through 9 key positions with a small sine wobble. A sprite blinks in the corner during a part of the path. 911 frames, starts from a white flash.

Triangles: the 68 meshes hold 3012 vertices and 5595 triangles in total (18-180 vertices and 24-336 triangles each; static counts, before any culling). Measured in the JS port: on average 42.6 of the 68 objects survive the bounding-sphere test per frame, which means about 2040 vertices transformed and 3791 triangles submitted, of which about 1480 (277 to 2217) pass the backface/depth test and are rasterized. See 3D scenes by the numbers.

E2
E2: Wasp

4. Part B: Spiky ball (over a bump-mapped tunnel)

A 685-vertex, 1366-face object drawn in shading mode 15 (texture + bump map + environment lighting), while the light angle moves each tick and the object flies on a parametric path over a pre-rendered tunnel background. Phases (hold, fly, fast spin) change at fixed tick counts; 2346 frames in all, then a fade-out.

Triangles: one mesh, 685 vertices and 1366 triangles (static). About 568 triangles per frame survive backface culling (measured: min 468, max 690), so roughly 42% of the mesh is drawn.

Part B
B: Spiky ball

5. Part C: Warp (warped picture show)

Three pictures are stretched into 256×256 buffers. A ring of four curves (top, bottom, left, right edges) is computed from sine tables with per-stage amplitude and phase; it defines a warped quad. For every column the texture start and per-row step are derived, so each pixel is a texture lookup with 32-bit packed 8.8 coordinates. Stages change the ring radius, speeds and flip pictures every 66 frames; later stages tile and squeeze the picture into many small copies.

Part C, wavy stage
C: Warp, wavy stage
Part C, close-up
Part C, close-up

6. Voxel terrain + polar warp

A height map and a colour map (256×256) are rendered by a classic voxel ray caster: 256 vertical rays, 105 steps each, projecting height through a lookup table (LUT[j][h]=190-min(189,(0x2f4+14h)/(j+9))) into a 256×256 buffer. That buffer is then displayed through a polar map (angle/radius for each screen pixel, built by stepping from the centre for 4096 angles), so the landscape wraps around the screen centre like a planet. The camera drifts and the ray tables are wobbled with a sine each frame.

Polar voxel terrain
Polar voxel terrain

7. Part D: Morphing head (animated bump texture)

A 529-vertex face object with 10 key frames morphs between them (weights from a table, 63 steps, 34 key indices), seen with environment shading. The texture is a 64×128 tile cross-faded between two resources in 8 frames and repeated across the 256×256 texture buffer, while the palette itself cross-fades between two ramps. The object moves along six path keys.

Triangles: 529 vertices and 968 triangles in the base pose (plus 10 key frames of 529 vertices each, which add no triangles). About 898 triangles per frame are rasterized (measured: 712 to 968) (most faces pass the test; I did not investigate why so few are culled, the port matches the original pixel for pixel).

Morphing head
D: Morphing head

8. Picture

A still picture (palette + 64000 pixels) faded in and out with the same palette-scaling routine as the intro (65 frames each way), used between the head and the bouncing ball.

Still picture
Still picture

9. Part F: Morphing env (bouncing morphing ball)

Sphere sprites on a ring were precomputed at start-up: 64 frames of 16 spheres rendered with the 3D library, rows 77-199 stored as run-length encoded streams (about 1.2 MB). The foreground is a 650-vertex morphing object using a different env map. A ball falls in, bounces 7 times at shrinking intervals and each bounce triggers a 63-tick colour flash; the ring plays frames (t>>1)&63 underneath. 1805 frames.

Triangles: the ball has 650 vertices and 1296 triangles (3 extra key frames); about 531 are rasterized per frame on average (0 before it falls into view, at most 785). The start-up precalculation draws 1024 spheres of 158 vertices and 316 triangles each (323,584 submitted, 92,634 rasterized, measured), once, not per frame.

Part F
F: Morphing env

10. Part G: Zoom blur (feedback-blur text)

Two 320×200 byte buffers with values 0..31 that are zoom-blurred every frame: each output pixel is the average of its four neighbours, sampled with a 120/128 zoom around the centre. Random dots and sprites (the text bitmaps from one resource, flashed after each music row) are stamped into the feedback source, so they smear out into glowing trails. The palette is a 32-colour blue ramp (violet in the second variant, section 13). The inner loop is machine code generated at run time; we do the same arithmetic with exact per-byte carries.

Text G
G: Zoom blur

11. Part H: Checkered zoom (three-layer feedback blur)

Three 320×200 layers are blurred and zoomed (factor 0x77/0x80, plus a small sine wave offset) every frame; stamp streams (skip/count run lists from three resources) paint the girl into the layers on selected music rows. Output is a checkerboard that alternates each frame: a lookup table lut[L0,L2] mixes two layers into a colour, the third is shown through a green/magenta ramp. 444 frames.

Part H
H: Checkered zoom

12. Part I: Torus mirror (stone torso and mirror)

Two morph objects: a statue torso in shading mode 2 whose environment map is a render target, and a spinning ribbon in mode 20/21 (warped texture mapping). A mirror image of the ribbon is drawn first into a 256×256 target which then serves as the environment map of the statue, giving real-time reflections. Wave functions on a subset of vertices move the ribbon; scripted by tick count (rises, spins, then two orbit phases). 2447 frames.

Triangles: torso 192 vertices / 384 triangles, ribbon 242 vertices / 459 triangles (329 of type “warp”, 130 of type “2-attribute”, per the notes). Measured average: about 484 triangles per frame (188 to 653) over 2.4 object draws per frame: the ribbon is drawn 1.7 times per frame on average (once, plus the mirror pass once the torso is shown), the torso 0.7 times.

Part I
I: Torus mirror

13. Part G: Zoom blur 2 (second variant)

The same code as section 10 with a violet/pink palette, a different sprite table (17 steps) and four orbiting squares from sine tables of different frequency.

Text G, violet
G: Zoom blur 2

14. Part J: Feedback (head over a feedback tunnel)

The metallic head is a pre-shaded stream: a 256×256 environment map is read at an offset that scrolls with a sine, and the pixel stream (skip/count runs) selects which pixels belong to the head. Behind it, the previous frame is remapped through a 32-row palette-lookup table (to darken) and warped: pix(y,x)=A[R[y]+D[x]] with R and D sums of two sines scaled by an animator P; with P around 256 nothing moves, when P sweeps the picture folds into a tunnel. A strobe at the end accelerates until the head flickers every frame.

Triangles: none; this head is not a mesh, it is a pre-shaded pixel stream (no 3D library call).

Tunnel warp
J: Feedback
Head
Head

15. Credits

Series of full-screen pictures (member names, music, the STARS logo) shown only by moving the palette: black palette, copy the image to VRAM, fade in 65 frames, wait for the right music row, fade out. The picture order of the final two screens depends on the DOS clock minute parity.

Credits
Credits
Final screen
Final screen

Part 3: 3D scenes by the numbers

All meshes are plain triangle lists: the object format stores three vertex indices per face, there are no quads, so nothing is split. Counts below come in two kinds, and they are labelled as such.

Scene (mode)ObjectsVertices / triangles per mesh (static)Static total, before cullingMeasured per frame
E2 Wasp (3, no sort)68 meshes18-180 / 24-336 (e.g. 34/64, 134/264, 180/336, 168/306)3012 v / 5595 tri42.6 objects drawn, 2040 v transformed, 3791 tri submitted, 1479 rasterized (277-2217); 911 frames
B Spiky ball (15)1685 / 1366685 v / 1366 tri685 v, 1366 submitted, 568 rasterized (468-690); 2346 frames
D Morphing head (15, morph)1 (+10 key frames)529 / 968529 v / 968 tri529 v, 968 submitted, 898 rasterized (712-968); 2158 frames
F Morphing env, ball (3, morph)1 (+3 key frames)650 / 1296650 v / 1296 tri650 v, 1296 submitted, 531 rasterized (0-785); 1805 frames
F Morphing env, start-up sprites (3)16 x 64 frames158 / 3161024 draws = 161,792 v / 323,584 trimeasured once at init: 92,634 rasterized (about 90 per sphere draw); not repeated per frame
I Torus mirror, torso (2)1192 / 384192 v / 384 tridrawn 0.7x per frame, about 125 rasterized per frame averaged over all frames (about 179 per draw)
I Torus mirror, ribbon (20, 21 for the mirror)1242 / 459 (329 warp type, 130 2-attr type, from the notes)242 v / 459 tridrawn 1.7x per frame, about 359 rasterized per frame (about 211 per draw)
I Torus mirror, total2434 v / 843 tri2.4 draws, 546 v, 1049 submitted, 484 rasterized (188-653); 2448 frames

Not in the table: the head of part J is a pre-shaded pixel stream (no polygon data), and the intro, E1, part C, the voxel terrain and the text and blur effects do not use the 3D library at all. Triangle counts of the original per scene are therefore small by modern standards: a frame never rasterizes more than about 2200 triangles (E2 peak) and typically 500 to 900, each filled by hand-written assembly at 70 Hz. Part F's morph ball takes 3 key frames, part D's head 10, and the per-vertex interpolation of the morph does not change the triangle count.

Two caveats. The E2 value of 42.6 objects counts the bounding-sphere survivors (an object is skipped before transformation if its projected sphere is off screen or outside the depth range), so about 25 of 68 objects are skipped on an average frame. And the part D figure (93% of triangles pass) is what the port computes; the original was compared pixel by pixel, so the original does the same, but I did not investigate why so few triangles are culled there.

Part 4: The 3D engine in depth

The library is one block at 0x78000-0x7a210 plus a family of polygon fillers at 0x4d2xx-0x54xxx. It was written for one purpose: environment-mapped, bump-mapped objects at 70 Hz without a floating point unit, a z-buffer or a division per pixel. Everything below is verified against the port (lib3d.js, bit-exact on the sampled frames and on 400 random triangles per filler) and, for the snippets, against a fresh disassembly of image32.bin. Where I only know something from the notes, or inferred it, it says so.

Pipeline of one frame

  1. Frame start (0x78905 “flip”). Copies back-buffer rows 1..197 to VRAM and, in the same pass, refills the back buffer from a background image (resource 19, 27, ...). So the background copy doubles as the screen clear; 0x78948 is the same with a constant colour. Rows 0, 198 and 199 are never touched (the demo is letterboxed).
  2. Scene (0x79483). The camera matrix is built from three 12-bit angles. For each object: its position minus the camera, rotated; projected centre and radius (the radius is the longest vertex vector, computed at load); objects whose sphere is off screen or outside the depth range are dropped. Survivors get a depth key (Z>>10, low 16 bits) and are sorted far to near by the same quicksort used for faces, then drawn one after another. This is the painter's algorithm at object level.
  3. Draw one object (0x789c4). A mode number indexes a table at 0x784d1 of 21-byte rows holding five function pointers, called in order: transform, light, cull, per-vertex setup, raster. Modes used by the demo: 2, 3, 15, 20, 21 (22 further rows are never selected). The first three stages are shared, only stage 4 and 5 differ.
  4. Stage 1, transform + projection (0x78a35 / 0x79b16). Object matrix times camera matrix gives one 3x3 matrix plus translation, all in .10 fixed point. Every vertex gets X'=x*M0+y*M4+z*M8+M12 (and Y', Z'), kept at full precision for the depth key, then projected: e = (dist - (Z'>>10)) << shift, sx = 0xa0 + X'/e, sy = 0x64 + Y'/e, one idiv per axis per vertex. If e <= 0 (vertex at or behind the camera plane) there is no divide and it falls back to X'>>10, a deliberately dumb guard that produces garbage coordinates which the depth limits of stage 3 then reject. dist (0x14 to 0x12c depending on the part) and shift (1 or 3) act as focal length.
  5. Stage 2, lighting (0x78a15). Not a lighting model: the vertex normals (16 B per vertex: x, y, z, then nx, ny, nz with 256 = 1.0) are rotated by a second matrix that uses the object angles plus a global angle triple rot (no camera, no translation). The rotated normal later selects a pixel of the environment map. Changing rot moves the highlights over the object without moving the object, which is how parts B and E2 make the light travel. (A face-normal variant, 0x789f5, exists; the modes the demo uses take vertex normals.)
  6. Stage 3, cull + depth key + face sort (0x78b08). For each face: the three projected points give a signed area (x2-x1)*(y3-y1)-(x3-x1)*(y2-y1); zero or negative = back-facing, skipped. The depth is the weighted mean of the three view-space Z values, ((z1+z2)/2+z3)/2 (two sar), also tested against a near limit -(dist<<10) and a far limit 0xfaa2b580. Surviving faces go into a list of dwords (face_index<<16)|key16 where key16 = (z<<6)>>16; the list is sorted by the low word only (signed 16-bit compare), farthest first. flags&1 turns the sort off (E2: the objects are tiny and flat, order does not matter visibly). There is no clipping of triangles against the screen at this stage, only the span fillers clip.
  7. Stage 4, per-vertex setup. Turns each rotated normal into texture-like coordinates: mode 15 builds u=(nx>>12)+0x80, v=(ny>>12)+0x80 (a normal of length 256 times a .10 matrix is at most 218, so >>12 leaves ±64, i.e. the sphere maps onto the central 128x128 disc of a 256x256 env map), shifted into the high half of a dword and combined with the vertex's own texture word (u|v<<8). Modes 3 and 2 build only the 16-bit env coordinates with shift 12 or 11 (radius 64 or 128 pixels). Mode 20 uses the mode 15 setup, then picks the filler per face.
  8. Stage 5, rasterize. For each face in sorted order: gather its three screen points and attribute dwords and call the filler (below).

Triangle setup and scanline interpolation

All fillers share the same skeleton (I read 0x51593 in full for this; the others differ in the span kernel and clipping limits). The three vertices arrive packed as y<<16|x. The filler first counts how many of the three x values are below 320 using three cmp/adc (so negative x, which is huge unsigned, counts as outside): all three inside selects the unclipped row drawer (0x51da8), none inside rejects the triangle, anything in between selects the clipped row drawer (0x51a62). It stores the three points, sorts them by y with xchg, rejects triangles entirely below the last drawable row, and then computes three edge slopes with idiv (long edge top-to-bottom, and the two short edges): x slope as (dx<<16)/dy in 16.16, and each attribute slope with a 16-bit idiv of the difference. That is a handful of divisions per triangle and none per pixel. Triangles of height 1 reuse the previous triangle's slope variables (the port keeps this quirk, since it changes pixels). The row drawers compare against rows 1 and 198 explicitly, which is why rows 0, 198 and 199 are never drawn.

The row loop keeps left and right x as 16.16 values in memory (0x4d190, 0x4d194) and advances them by the slope each row; the span length is the difference of the integer parts. Attributes at the span start are also interpolated along the edge and stepped along the span with one constant per triangle. All attributes are linear in screen space: this is affine mapping, no perspective correction. For environment lookups that does not show, because the interpolated quantity is a normal direction, not a surface position.

Per-pixel shading

Fixed-point formats

Tables

The shared buffer quirk

The sqrt table is built into the back buffer (0x682d0, 64 KB), and the env map generator then reads from it. The back buffer is overwritten by the first flip. So the square root is only usable between initialisation and the first frame, which is why part D (and the library, per the notes) rebuilds the table right before an object is created. The port deliberately keeps this: sqrt() after the first flip returns garbage there too. A similar sharing exists in part D, where the texture and the bump map are the same 256x256 block (the gs and ds selectors point at the same memory).

Memory layout: selectors and 64 KB segments

The demo runs 32-bit flat under PMODE, but the fillers deliberately use segment registers: before the span loop, fs, gs, ds and es are loaded from variables holding selectors of 64 KB buffers: [0x55912] texture (gs), [0x558dc] bump map (ds), [0x55918] env map (fs), es the destination (back buffer or VRAM). Each map is exactly 256x256 bytes, so a 16-bit register addresses a whole texture, bh is the row, bl the column, and wrapping around 64 KB (or around 256 columns) is free: coordinates never need a clamp or modulo, a texture simply tiles. The selectors are allocated dynamically (the emulation harness needs a dynamic GDT for exactly this reason). Texture data therefore lives in separate 64 KB blocks, not in one flat array.

Performance tricks (as far as I can see in the asm)

Annotated inner loops: the 3D engine

All listings are real instructions from re/image32.bin, disassembled with Capstone (re/ref/d.py); the left column is the address. Comments are mine. IMM marks an operand that the code patches at run time (the image holds a placeholder).

Vertex transform and projection (0x79be9-0x79ca2)

079be9  movsx eax, word [edi*4 + IMM]   ; x of vertex i (edi = 4*i, so 16 B per vertex; IMM patched per object)
079bf1  movsx ebx, word [edi*4 + IMM]   ; y
079bf9  movsx ecx, word [edi*4 + IMM]   ; z
079c01  imul  edx, eax, IMM             ; x * M[0]   (matrix entry patched in)
079c07  imul  ebp, ebx, IMM             ; y * M[4]
079c0d  add   edx, IMM                  ; + translation M[12]
079c13  imul  esi, ecx, IMM             ; z * M[8]
079c19  add   edx, ebp
079c1b  add   edx, esi                  ; edx = X' (.10)
079c1d  push  edx
079c1e  mov   [edi*4 + IMM], edx        ; keep X' in the 3D vertex array
        ...                             ; the same 9 instructions for Y' (079c25) and Z' (079c49)
079c6e  mov   ebp, edx                  ; edx = Z'
079c70  sar   ebp, 0xa
079c73  neg   ebp
079c75  add   ebp, [0x57ab6]            ; ebp = dist - (Z' >> 10)
079c7b  shl   ebp, IMM                  ; IMM = [0x5594c] (1 or 3)
079c7e  cmp   ebp, 0
079c81  jg    0x79c8d                   ; in front of the camera: divide
079c8d  mov   eax, ecx                  ; ecx = X'
079c8f  cdq
079c90  idiv  ebp
079c92  add   ax, 0xa0                  ; screen x
079c96  mov   cx, ax
        ...                             ; same for Y' (079c99-079c9e) with + 0x64
079ca2  mov   [edi + IMM], ecx          ; store sx
079ca8  mov   [edi + IMM], ax           ; store sy at +2 (overwrites the high half)

One vertex = three imul triples and two idivs. The loop body is written by 0x79b16 (see the long run of stores at its start) every time an object is drawn; that is the self-modifying part. JS: X=x*M0+y*M4+z*M8+M12; e=(dist-(Z>>10))<<shift; sx=idiv(X,e)+0xa0 (Lib3D.project).

Face sort: the quicksort at 0x7898d

07898d  cmp   ecx, ebx                  ; ebx = first, ecx = last element (byte addresses)
07898f  jle   0x789c3                   ; nothing to sort
078991  mov   edi, ebx                  ; edi = pivot slot
078993  lea   esi, [ebx + 4]            ; esi = scan pointer
078996  mov   edx, [edi]                ; edx = pivot (key in the low word)
078998  mov   eax, [esi]
07899a  cmp   dx, ax                    ; signed 16-bit compare of the keys only
07899d  jle   0x789a6                   ; pivot <= element: leave
07899f  stosd                           ; [edi] = element, edi += 4
0789a0  mov   eax, [edi]                ;   displaced entry just after the old pivot slot
0789a2  mov   [esi], eax                ;   goes to the scan slot
0789a4  mov   [edi], edx                ; pivot moves up one slot
0789a6  add   si, 4
0789aa  cmp   esi, ecx
0789ac  jbe   0x78998                   ; scan to the end
0789ae  push  cx
0789b0  push  di
0789b2  mov   ecx, edi                  ; sort [first .. pivot] recursively
0789b4  call  0x7898d
0789b9  pop   bx
0789bb  pop   cx
0789bd  add   bx, 4                     ; then [pivot+4 .. last]: tail call
0789c1  jmp   0x7898d

Not a textbook quicksort: the pivot is always the first element and it is dragged upward through the array as smaller keys are found. The order of equal keys therefore depends on the exact algorithm, and since coplanar faces tie often, the port copies it literally (sortList): a “correct” sort would draw tied triangles in another order and the output would differ by a few pixels. Smaller key = farther, so the list ends up far to near. JS: sortList(list, 0, (n-1)*4).

Mode 15 span: texture + bump + env (0x51f89-0x51fdd)

; edx, ecx: two 16-bit lanes each. Low lane = texture (v, u), high lane = env (v, u),
; integer part in dh / ch, fraction in dl / cl.  ds = bump map, gs = texture, fs = env map.
051f89  mov   bh, dh                    ; ---- pixel 0 ----
051f8b  mov   bl, ch                    ; bx = texture coordinate (v,u)
051f8d  rol   edx, 0x10                 ; swap lanes: env coordinates now in dh/ch
051f90  rol   ecx, 0x10
051f93  mov   al, gs:[bx]               ; texel
051f97  mov   bx, [bx]                  ; 16-bit bump word at the same offset (ds)
051f9b  add   bh, dh                    ; + env v
051f9d  add   bl, ch                    ; + env u
051f9f  add   edx, STEP1                ; step both lanes (value patched per triangle)
051fa5  add   al, fs:[bx]               ; + env texel -> pixel 0
051fa9  add   ecx, STEP2
051faf  mov   bh, dh                    ; ---- pixel 1 (env first, texture second) ----
051fb1  mov   bl, ch
051fb3  rol   edx, 0x10
051fb6  mov   bp, bx                    ; bp = env coordinate
051fb9  rol   ecx, 0x10
051fbc  mov   bh, dh
051fbe  mov   bl, ch                    ; bx = texture coordinate
051fc0  add   edx, STEP1
051fc6  mov   ah, gs:[bx]               ; texel
051fca  add   bp, [bx]                  ; + bump word
051fce  add   ah, fs:[bp]               ; + env texel -> pixel 1
051fd3  add   ecx, STEP2
051fd9  dec   si                        ; si = pixel pairs left
051fdb  stosw                           ; write both pixels (es:[edi])
051fdd  jne   0x51f89

Per pixel: three memory reads (texture, bump, env), two byte additions and no multiply. The bump word is a free displacement of the env lookup; being read at an arbitrary byte offset, its low byte comes from this bump pixel and its high byte from the next one, and the map is stored pre-biased so that both act as signed offsets. I read the lane layout from this code and the unit tests (400/400 random triangles identical); the point where rol swaps lanes is the one part I only checked by test, not by tracing every register. JS: out[i] = gs[tuv] + fs[(ds16[tuv] + euv) & 0xffff] (tri4).

Mode 3 span: flat environment (0x4ecc4-0x4ecef)

; esi: bits 8-15 of si = env v (fraction in the low byte);  ecx: bits 8-15 of cx = env u
; fs = env map (256x256);  edx: dl = pixel pairs left;  ebp = per-pixel step of u
04ecc4  ror   dx, 1                     ; dx = pixel count: split into pairs (dl) and odd bit (dh)
04ecc7  test  dl, dl
04ecc9  je    0x4ecef                   ; no pairs
04eccb  mov   ebx, esi                  ; (the upper half of esi is 0)
04eccd  mov   bl, ch                    ; ebx = v<<8 | u
04eccf  mov   al, fs:[ebx]              ; env texel, pixel 0
04ecd2  add   si, 0x1111                ; v += dv   (0x1111 = placeholder, patched per triangle)
04ecd7  add   ecx, ebp                  ; u += du
04ecd9  mov   ebx, esi
04ecdb  mov   bl, ch
04ecdd  add   si, 0x1111
04ece2  mov   ah, fs:[ebx]              ; env texel, pixel 1
04ece5  add   ecx, ebp
04ece7  stosw                           ; write 2 pixels
04ece9  dec   dl
04eceb  mov   ebx, esi
04eced  jne   0x4eccd

This is the whole inner loop of the flat-shaded environment mode: the entire shading model is one table read per pixel. The loop exits with the odd pixel (test dh,dh at 0x4ecef). JS: out[i] = fs[(v<<8)|u] (tri2).

Part 5: Inner loops of the other effects

Resource decompressor (0x4ad47)

; esi = packed stream (after the 17-byte header), edi = output, ebx = -1 on entry
; bp = current 16-bit flag word, dl = flag bits left
04ad5f  shr   bp, 1                     ; next flag bit into CF
04ad62  dec   dl                        ; dec does not touch CF
04ad64  jne   0x4ad6e
04ad66  mov   dl, 0x10                  ; word exhausted: reload (mov and lea keep CF)
04ad68  mov   bp, [esi]
04ad6b  lea   esi, [esi + 2]
04ad6e  jae   0x4ad73                   ; CF = 0: back-reference
04ad70  movsb                           ; CF = 1: literal byte, copy it
04ad71  jmp   0x4ad5f
04ad73  ...                             ; (04ad73-04ad81: the same shr/dec/reload for a second bit)
04ad82  lodsb                           ; al = low byte of the distance
04ad83  mov   bh, 0xff
04ad85  mov   bl, al                    ; ebx = 0xffffff00 | al : a negative distance
04ad87  jb    0x4addd                   ; second bit 1: longer match (variable length decoding)
        ...
04add2  mov   cl, 2                     ; short match: length 2
04add4  push  esi
04add5  lea   esi, [edi + ebx]          ; source = output pointer + negative distance
04add8  rep movsb                       ; copy (overlap gives run-length repetition)
04adda  pop   esi
04addb  jmp   0x4ad5f

It is LZ77 with a bit-stream of control flags. The decoder is clever about the flag refill: dec, mov and lea do not alter the carry flag, so the carry from the shr survives the optional reload and a single jae tests it. Longer matches read more flag bits (the code at 0x4addd and after; see re/dlz.py for the full bit grammar: one literal bit, then distance bits, and length 2 to about 272). In the short-match branch a distance byte of 0xff is followed by one more flag bit: 1 continues, 0 ends the stream. JS/Python: dlz() in re/dlz.py, i.e. out.append(rd()) for a literal, else for _ in range(n): out.append(out[-d]).

Zoom blur of the text effects (generator 0x13922, driver 0x13579)

This code is written by the program at start-up. The generator loops 80 times (one per group of 4 destination pixels), reading the column table at 0x12814 (x' = ((x-160)*0x78+0x40 >> 7)+160) and emitting these bytes (decoded here, they are not in the image as such):

; emitted at 0x139a7..0x139ff by the generator, one copy per 4 pixels (k = 0, 4, .. 316):
mov   eax, [esi + x'-1]               ; 8b 86 d32   dword one pixel to the left (unaligned)
mov   ebp, [esi + x'+1]               ; 8b ae d32   one pixel to the right
add   eax, ebp
mov   ebp, [esi + x'-0x140]           ;              row above
add   eax, ebp
mov   ebp, [esi + x'+0x140]           ;              row below
add   eax, ebp
shr   eax, 2                          ; c1 e8 02    average of the 4 neighbours
and   eax, edx                        ; 23 c2       edx = 0x1f1f1f1f: drop stray bits
mov   [ebx + k], eax                  ; 89 83 d32   write to VRAM ...
stosd                                 ; ab          ... and to the next feedback buffer
; driver 0x13579 (once per frame):
013594  imul  esi, [ecx*4 + 0x12d14], 0x140 ; row table: source row = ((y-100)*0x78 >> 7)+100, times 320
01359f  add   esi, [0x13038]            ; + source buffer
0135a5  call  [0x13040]                 ; run the generated code for this row
0135ab  add   ebx, 0x140                ; next VRAM row
0135b1  dec   ecx
0135b2  jns   0x13594                   ; ecx = 199 .. 0
0135b4  mov   eax, [0x13038]            ; swap source and destination buffers
0135b9  xchg  [0x1303c], eax            ; ...and store it in the other slot

Every destination dword (4 pixels, byte values 0..31) is the sum of 4 unaligned dword reads. Since each byte sum is at most 4*31 = 124 there is no carry between bytes; the shr 2 pushes the low two bits of each byte into the top of the byte below, and the mask 0x1f1f1f1f removes them again. That is four blurred pixels for 11 instructions with every address a constant, and the zoom (a 120/128 sampling grid) comes for free from the two tables. Rows are done from the bottom (ecx 199 to 0). JS: out[y][x] = (A[sy][sx-1]+A[sy][sx+1]+A[sy-1][sx]+A[sy+1][sx]) >> 2 & 31 (web/parts/textg.js); part H uses the same trick with factor 0x77 and a wave offset.

Voxel terrain: one ray (0x12c0-0x1309)

; es = height map, gs = colour map, fs = LUT[step][height] (screen height); edx = map address (dh = y, dl = x)
; ch = highest screen row already filled on this ray;  bh = step counter (distance), edi = output column
0012c0  mov   bl, es:[edx]              ; terrain height at this map position
0012c3  mov   cl, fs:[bx]               ; bx = step<<8 | height  ->  projected screen height
0012c7  cmp   cl, ch
0012c9  jbe   0x12f9                    ; hidden behind earlier terrain: skip
0012cb  sub   cl, ch                    ; cl = number of new pixels in this column
0012cd  mov   al, gs:[edx]              ; colour of the terrain here
0012d0  add   ch, cl                    ; new top
0012d2  shr   cl, 1
0012d4  jae   0x12dd
0012d6  mov   [edi], al                 ; odd bit: 1 pixel
0012d8  lea   edi, [edi + 1]
0012db  je    0x12f9
0012dd  mov   ah, al
0012df  shr   cl, 1
0012e1  jae   0x12eb
0012e3  mov   [edi], ax                 ; next bit: 2 pixels
0012e6  lea   edi, [edi + 2]
0012e9  je    0x12f9
0012eb  push  ax                        ; build eax = 4 copies of the colour
0012ed  push  ax
0012ef  pop   eax
0012f0  mov   [edi], eax                ; the rest: 4 pixels per iteration
0012f2  add   edi, 4
0012f5  dec   cl
0012f6  jne   0x12f0
0012f9  add   bh, 2                     ; next step along the ray
0012fc  add   dx, [ebp*2 + 0xcc6]       ; walk the map: 16-bit step (dy*256+dx) from the ray table
001305  inc   ebp
001306  cmp   bh, 0xd2
001309  jb    0x12c0                    ; 105 steps

A classic voxel ray caster, but the buffer is rotated: the 256 rays are the columns of a 256x256 buffer, so a ray's pixels are consecutive bytes and vertical fills become movs of 1, 2 or 4 bytes chosen from the bits of the fill length. Occlusion is a single compare with the running top ch (no z-buffer: the ray goes near to far). The map address is dx, a 16-bit register, so the 256x256 maps wrap for free, and one table of precomputed 16-bit steps moves the ray without any multiply. JS: for(j=0;j<105;j++){c=LUT[j*256+H[a]]; if(c>top){fill(top..c,C[a]); top=c} a=(a+T[j])&0xffff} (web/parts/terrain.js).

E1 tunnel, 4 pixels per iteration (0x12662-0x1269b)

; ebp = lookup table (one 16-bit entry per screen pixel, built at init), edi (after xchg) = scroll offset of layer 2
; gs = texture 1 (bytes), fs = texture 2 (16-bit read), si = scroll offset of layer 1, edx = VRAM pointer
012662  mov   ebx, [ebp + 4]            ; two table entries for pixels 2 and 3
012665  mov   al, gs:[bx + si]          ; pixel 2: texture 1 at (entry + scroll)
012669  shr   ebx, 0x10
01266c  mov   ah, gs:[bx + si]          ; pixel 3: same with the second entry
012670  and   bl, 0xfe                  ; word-align for layer 2
012673  add   ax, fs:[bx + di]          ; add texture 2 (one 16-bit read feeds both pixels)
012678  shl   eax, 0x10                 ; park pixels 2,3 in the high half
01267b  mov   ebx, [ebp]                ; entries for pixels 0 and 1
01267e  mov   al, gs:[bx + si]
012682  shr   ebx, 0x10
012685  add   ebp, 8                    ; 4 table entries consumed
012688  mov   ah, gs:[bx + si]
01268c  and   bl, 0xfe
01268f  add   ax, fs:[bx + di]
012694  mov   [edx], eax                ; 4 pixels at once
012696  add   edx, 4
012699  dec   cx
01269b  jne   0x12662                   ; cx = 0x3d90 = 15760 iterations = 63040 pixels (rows 1..197)

All the geometry of the tunnel was moved into one precomputed table (the walk over concentric ellipses at start-up), so per frame each pixel costs two table-indexed reads of tiny textures and an add; scrolling is just si and di, which are 16-bit and wrap on 256x256 textures. One 16-bit add handles two pixels, so the carry from the low byte can leak into the high byte; the port reproduces the leak (“16-bit add with carry” in the notes). JS: out[i] = (T1[(tbl[i]+s1)&0xffff] + T2[((tbl[i]&~1)+s2)&0xffff]) & 255 with the pair caveat above (web/parts/e1.js).

Sources: re/NOTES.md and re/parts/*.md in the project repository contain the per-part details (addresses, tables, constants).