Analysis model: gpt-5.5 xhigh

Solstice by Valhalla - Technical Dissection

Scope

This is a static binary dissection of Solstice by Valhalla, the first-place demo from the Wired 1995 demo competition.

Release year: 1995. The archive text gives two close dates: FILE_ID.DIZ states 14 November 1995, while README.TXT states 15 November 1995.

Public references:

The Wired 1995 result file lists:

Demos Competition
 1. Valhalla "Solstice"                                    590 pts
 2. Legend Design "Just"                                   304 pts
 3. Purge "Phenotype"                                      286 pts

The analysis below uses archive inspection, PMODE/W PMW1 image flattening, static 386 disassembly, and data-flow checks against the shipped files. It is not a source-level reconstruction; names for internal routines are descriptive labels inferred from code shape, constants, strings, and call sites.

Examined Files

Archive:

71a60bdf046592a491b7b8e4fe3be1fe0c7a3b8765d7fd9296e3944f4d0ae1c5  solstice.zip

Extracted files:

402bca7372c532fff873cf0c797a4b323d10ca18a2edba5b27744303bde97179  FILE_ID.DIZ
4b5adcf7d9fba3b7a1ff0f1417c24159b938ecca56af43fccb2ed2f106fcb5ef  README.TXT
7a2c36c16e15b790eccf62fa7133dae3ae90579a9977a03cdfaed9d9b87ec464  SOLSTICE.DAT
602ffe9626b1e74497b53bc138183215f9eca550bc22f4227fac3f7148600537  SOLSTICE.EXE
e0e79016ade2e19ec558dc9a0635acef4c9fca4de35020f78defb9b7c05c2f8f  SOLSTICE.MOO
f99d4ceee80471a463b6a20d48e4127cb5c40443d532683f3ab9e5284618d623  VALHALLA.TXT
7cf81d4c83d4c54346d7f2f564f0c5b24b2f23e3b7360400c4b94515fb704121  scene.org

File roles:

SOLSTICE.EXE  MS-DOS MZ executable with a PMODE/W PMW1 payload
SOLSTICE.DAT  Sequential resource stream
SOLSTICE.MOO  Scream Tracker III module, title "Solstice"
README.TXT    Runtime notes and credits
FILE_ID.DIZ   Compact release description

The readme credits Sandman for code and most visual assets, Big Jim for music, Dambuster for the bar scene and one object, and Vision FX for the Solstice picture. It also says the demo needs a 486, recommends a 486DX2 or better, needs 4 MB RAM, and includes a documented rgbfix switch for machines that show banding in the RGB effects.

Executable Shape

SOLSTICE.EXE begins as a small MZ loader and then enters a PMODE/W PMW1 payload. The MZ header is minimal:

MZ image size:        9808 bytes
header paragraphs:   4
relocations:         0
initial CS:IP:       0000:0059
initial SS:SP:       0344:0100
PMW1 marker offset:  0x2650

The real-mode stub contains:

PMODE/W v1.21 DOS extender - Copyright 1995, Daredevil and Tran.

The PMW1 object table describes four packed objects:

entry object 1 offset 0x19efc -> flat entry 0x00027efc
stack object 4 offset 0x23c840 -> flat stack 0x0034c840

object 1: virtual 0x27365, actual 0x1173f, flags 0x2045
object 2: virtual 0x007ac, actual 0x0034e, flags 0x2045
object 3: virtual 0x0511f, actual 0x02f8a, flags 0x2045
object 4: virtual 0x23c840, actual 0x04ed0, flags 0x2043

The flattened image layout I used for disassembly:

object 1 base 0x0000e000 end 0x00035365
object 2 base 0x00036000 end 0x000367ac
object 3 base 0x00037000 end 0x0003c11f
object 4 base 0x00110000 end 0x0034c840

The flattener emitted warnings about a few relocation records. The image is good enough to follow code paths and local loops, but exact relocated pointers should still be treated carefully.

The flat image also contains WATCOM C/C++32 runtime text, and the visible entry at 0x27efc jumps into WATCOM startup code. The demo body starts later, with a main-like routine at 0x1ea10.

Startup And Switches

The main routine at 0x1ea10 does three things before entering graphics:

  1. Initializes runtime and sound support.
  2. Parses command-line switches.
  3. Opens and streams solstice.dat.

The two recognized switches found in the binary are:

fixrgb  -> sets dword [0x126c4c] = 1
+*(:-)  -> sets dword [0x126c50] = 1

The documented fixrgb switch is not just a cosmetic flag. It changes the page-flip RGB path by waiting for vertical blank at port 0x03da before returning from the split-plane display routine. That fits the readme note that it fixes banding at the cost of speed.

The hidden +*(:-) switch is a different hook. The common framebuffer-present routine at 0x1f0e0 normally does a direct 64000-byte copy to A000:0000. With [0x126c50] != 0, it calls the warp-table builder at 0xe16b and the warped gather blit at 0xe289 instead.

Resource Stream

The executable opens solstice.dat in binary read mode and stores the stream handle at 0x34830c. The many embedded path-looking strings such as data\newt-env.tga, data\brush.tga, data\phong.raw, data\lotwist.dat, and data\sol3.pat are status labels and source-era names. The loader reads a single packed stream, not many individual files.

Important loader helpers:

The Vrender loader at 0x20050 is especially explicit:

read 2 bytes -> vertex/record count A
read 2 bytes -> primitive/record count B
write count A to [object + 0]
write count B to [object + 2]
write destination pointer to [object + 4]
read count A * 0x0c bytes to the destination
write next pointer to [object + 8]
read count B * 0x20 bytes after that
return the advanced destination pointer

That matches a compact object format: a 12-byte transformed-vertex or point record array followed by 32-byte primitive records.

The pattern loader at 0x1ff20 reads four 16-bit header fields into 0x297ca0..0x297ca6, optionally reads a count * 0x0c vertex section, and then performs a nested grid read:

for y in 0 .. header3 - 1:
  for x in 0 .. header0 - 1:
    read 12 bytes to 0x2c89e8 + x * 0x18 + y * 0x0c

The stride is asymmetric: advancing x moves by 24 bytes, advancing y within an x cell moves by 12 bytes. That is a hint that the pattern is two interleaved rows or paired records per grid column.

Timing And Scene Dispatch

After loading, the demo starts music with MIDAS, switches to VGA, initializes a set of global pointers, and enters this frame loop:

0x1f050:
    eax = 0x60
    call 0x25b05      ; keyboard or wait/test helper
    cmp  eax, 1
    je   exit
    call 0x20638      ; timing/sync update
    call 0x22668      ; visual step
    jmp  0x1f050

The timing update at 0x20638 asks a MIDAS-side or timer-side helper for the current sync structure, then copies three fields:

[0x348328] = sync[0]
[0x348308] = sync[1]
[0x348348] = sync[2]

It also updates [0x126c58] by the delta of [0x348350], then computes three sinusoidal camera/effect parameters at 0x348340, 0x34833c, and 0x348344. Those values come from repeated FPU sine calls with different offsets, multipliers, and scales.

The frame routine at 0x22668 is a threshold dispatcher. It compares [0x348328] against a sequence of cue numbers and runs one branch until the cue crosses the next threshold. On every branch transition it resets [0x126c58] to zero and stores a small scene id at [0x126c60].

The static cue map:

cue < 0x01: fade first still, copy 0x326be8 to VRAM offset 0x3480
cue < 0x02: fade second still, copy 0x32e8e8 to VRAM offset 0x3480
cue < 0x03: Mode-X-ish setup, partial planar upload, fade palette 0x2979a0
cue < 0x07: mode 13h, palette 0x347708, call 0x21408
cue < 0x09: clear VRAM, palette 0x348008, call 0x21668
cue < 0x0b: call 0x21910
cue < 0x0f: clear/copy background, palette 0x3365e8, call 0x21b24
cue < 0x13: call 0x21e38
cue < 0x17: Mode-X-ish setup, palette ramp, call 0x20e68
cue < 0x19: stream two more 0xfa00 byte chunks, call 0x22580, then fade
cue < 0x1d: mode 13h, background copy, palette 0x347a08, call 0x21c94
cue < 0x21: Mode-X-ish setup, palette 0x2976a0, call 0x211d0
cue < 0x25: palette ramp, call 0x21008
cue < 0x29: mode 13h, palette 0x347d08, call 0x21fa4
cue < 0x2b: copy 0x1d6f60 to 0x20ef60, call 0x22e68
cue < 0x2e: call 0x23034
cue < 0x2f: palette dim using sync[2], call 0x23034
cue >= 0x2f: shut down sound and restore the final text screen

This is not a script interpreter in the usual sense. It is a hard-coded cue switch keyed by the music/timer state, with per-branch initialization blocks and persistent local tick [0x126c58].

VGA Setup

The VGA setup routine is at 0x1e56c. The important writes are:

mov ax, 0013h
int 10h

dx = 03c4h
if mode-table byte != 0:
    out 03c4h, 0100h      ; sequencer reset
    out 03c2h, clock byte
    out 03c4h, 0300h      ; sequencer restart

out 03c4h, 0604h          ; memory mode register
clear A0000, 0x8000 words ; 64 KiB
write 3 or 10 CRTC words from table 0x120660
out 03d4h, 4009h

The call sites pass small mode-table indices, usually 4, and the code uses that byte to decide how many CRTC words to output. The 0x0604 sequencer write is the usual kind of unchained/planar memory setup used for Mode X style effects.

The page-start helper at 0x1e68c writes CRTC start address registers:

write index 0x0d, low(start)
write index 0x0c, high(start)

Several RGB routines use two visible page bases, A0000 and A8000, then toggle CRTC start address between 0 and 0xffff8000. In the 32-bit flat view, 0xffff8000 is just a signed way to pass the alternate 32 KiB CRTC start value.

Chunky To Planar Upload

The upload routine at 0x1e5d9 converts a chunky system buffer into four VGA planes. It runs four times, once per map mask:

for mask in 1, 2, 4, 8:
    out 03c4h index 2
    out 03c5h mask
    edi = A0000 + dest_offset
    repeat 0x1f40 times:
        eax = 0
        al  = [esi + 0x08]
        ah  = [esi + 0x0c]
        eax <<= 16
        al  = [esi + 0x00]
        ah  = [esi + 0x04]
        [edi] = eax
        esi += 0x10
        edi += 4
    esi -= 0x1f3ff

0x1f40 dwords is 8000 dwords. Across four planes that covers 320x200 pixels. The source pointer advances by 16 source bytes per output dword because each plane takes every fourth pixel from a chunky row. The packed output dword contains four pixels for one VGA plane:

low byte       = source byte +0
next byte      = source byte +4
third byte     = source byte +8
highest byte   = source byte +12

0x1e62b is the same routine with a caller-supplied repeat count. That is used for partial uploads such as logos or centered panels.

Direct Dirty Rect Helpers

0x1e6b3, 0x1e712, and 0x1e790 are rectangle helpers over a 320-byte pitch:

All three use global bounds:

x min: [0x348384], rounded down to a dword boundary
x max: [0x348394], rounded up to a dword boundary
y min: [0x348388]
y max: [0x348398]

The row address math is the common fast multiply by 320:

y * 320 = (y << 6) + (y << 8)
        = (y << 6) + ((y << 6) * 4)

The copy loop is:

for row in y0 .. y1 - 1:
    ecx = width_in_dwords
    rep movsd
    edi += 320 - width_in_bytes
    esi += 320 - width_in_bytes

The clear-after-copy variant saves the source pointer, backs it up by the copied byte count, and clears exactly the copied area with rep stosd.

Palette Routines

The palette writes all use VGA DAC ports:

out 0x03c8, 0      ; starting palette index
out 0x03c9, value  ; repeated component writes

0x1f1d4 writes a raw 256-entry palette. The source palette is byte RGB or BGR data, and each component is divided by four before being sent to the 6-bit VGA DAC. The order in this routine is source byte 2, then 1, then 0.

0x1f29c is a fade/desaturate routine. It accepts a floating factor on the stack. For each palette entry it:

r6 = source_r / 4
g6 = source_g / 4
b6 = source_b / 4
gray = (r6 + g6 + b6) / 6
out_b = b6 + (gray - b6) * factor
out_g = g6 + (gray - g6) * factor
out_r = r6 + (gray - r6) * factor

The divisor 6 is unusual if you expect a normal average over three channels. It makes the target gray darker than a simple (r + g + b) / 3. This gives the still-picture fades a dimmer, washed look instead of a bright monochrome fade.

0x1f3e8 and the nearby longer variants implement signed brightness shifts. They clamp the shift to roughly -63..63, add it to each 6-bit DAC component, and clamp each component to 0..63. Those helpers are used for fast fades in the cue dispatcher.

Hidden Warped Copy Path

The ordinary present routine is tiny:

0x1f0e0:
    if [0x126c50] == 0:
        esi = source
        edi = A0000
        ecx = 0x3e80
        rep movsd          ; 64000 bytes
        return

    call 0xe16b
    call 0xe289
    update six phase globals from [0x348350]

The hidden switch therefore turns a plain present into a live displacement effect.

0xe16b builds two lookup tables from a 1024-entry sine table at 0x121e68. The x table at 0x3368e8 has 320 dwords. For each x column:

offset =
    sin(phase0) >> 11
  + sin(phase1) >> 12
  + sin(phase2) >> 13

xmap[x] = clamp(x + offset, 0, 319) - x

phase0 += 8
phase1 += 20
phase2 -= 13

The y table at 0x336de8 has 200 dwords:

offset =
    sin(phase3) >> 12
  + sin(phase4) >> 13
  + sin(phase5) >> 14

ymap[y] = offset * 320

phase3 -= 9
phase4 -= 17
phase5 += 11

The output is an address delta table, not a coordinate table.

0xe289 uses those deltas to copy a warped central window directly to A0000. It starts at byte offset 0x1414, runs 168 rows, and writes 70 dwords per row. That is 280 pixels per row; the row gap is 0x28 bytes, which brings the total row stride back to 320.

The inner loop gathers four source bytes and packs them into one dword:

row_delta = ymap[row]
xbase     = xmap + column * 4

byte0 = source[row_delta + xbase[0] + 0]
byte1 = source[row_delta + xbase[1] + 1]
byte2 = source[row_delta + xbase[2] + 2]
byte3 = source[row_delta + xbase[3] + 3]

dest_dword = byte0 | (byte1 << 8) | (byte2 << 16) | (byte3 << 24)

The implementation uses BL/BH, shifts EBX left by 16, then fills BL/BH again. This is a good 386-era way to build four adjacent pixels while avoiding four separate stores.

0xe30d is the same style of gather, but writes to the system buffer 0x326be8 over an 80-dword-wide area. 0xe38c and 0xe3da perform another map-driven four-byte pack using an offset table at 0x34843c.

RGB Split-Plane Effect

The routines around 0x20e68, 0x21008, and 0x211d0 are variations of the same RGB split-plane renderer.

Each routine chooses a page:

if [0x126c48] == 0: dest = A0000
else:               dest = A8000

Then it sets a VGA map mask and calls the 132-row mini-blitter at 0xe102 several times. The mini-blitter takes:

base table pointer
phase offset
destination pointer
offset-table pointer

Its loop is:

esi += 0x2940
for y in 0 .. 131:
  for xgroup in 0 .. 19:
    b2 = table[ word(esi+8)  + phase ]
    b3 = table[ word(esi+12) + phase ]
    ebx = (b2 << 16) | (b3 << 24)
    b0 = table[ word(esi+0)  + phase ]
    b1 = table[ word(esi+4)  + phase ]
    ebx |= b0 | (b1 << 8)
    ebx += [0x126c3c]
    [dest] = ebx
    dest += 4
    esi += 16
  dest += 160

The caller changes [0x126c3c] between 0x00000000, 0x40404040, and 0x80808080, and changes the VGA plane mask between 0x0c and 0x03. Together those writes split a 6-bit-ish source value into different VGA planes and intensity bands. The effect is very much built around VGA planar behavior, not just a normal chunky buffer.

After drawing, the routine toggles [0x126c48], changes the CRTC start address with 0x1e68c, and optionally waits for vertical blank when [0x126c4c] is set by fixrgb.

3D Object Transform

The Vrender object code uses 12-byte vertex records and 32-byte primitive records.

The transform setup routines at 0x1c50c and 0x1d7a4 step over object descriptors in 0x30-byte chunks. Per object, they read:

vertex count
source vertex pointer
screen center x/y/z-ish fields
three rotation indices
primitive shade/type fields
translation fields
destination primitive pointer

The rotation indices are masked with 0x7fe and used as byte offsets into the sine table at 0x121e68. Each rotation gets sine and cosine values by reading the table at angle and angle + 0x200. Matrix terms are then built with fixed-point multiplies and shrd shifts, usually at 15-bit scale.

The vertex loop does:

for each source vertex:
  x = source.x + object_tx
  y = source.y + object_ty
  z = source.z + object_tz

  z2 = m20*x + m21*y + m22*z
  if z2 <= 10:
      mark screen z as 0x8300 sentinel
      skip projection

  reciprocal = table[z2]
  screen_x = center_x + ((m00*x + m01*y + m02*z) * reciprocal) + global_x
  screen_y = center_y + ((m10*x + m11*y + m12*z) * reciprocal) + global_y

The projection uses a precomputed reciprocal-like table at 0x1206f8 indexed by z. The sentinel 0x8300 later lets the primitive builder reject faces whose vertices are behind the camera.

Depending on the primitive mode field, the transform also writes either a normal/depth term, extra screen coordinate terms, or copied auxiliary fields into the output vertex records.

Triangle Bucket And Dispatch

The primitive bucketer at 0x1c95e and the very similar one at 0x1d610 clear 1000 bucket heads:

edi = 0x110b98
ecx = 0x7d0
eax = 0
rep stosd             ; clears 2000 dwords = 1000 head/tail pairs

For each primitive, it fetches the three transformed vertex records, rejects any vertex with screen-z sentinel 0x8300, and rejects too-large z values above 0x03e8.

The face test is the screen-space signed area:

area =
  (x2 - x0) * (y1 - y0)
- (x1 - x0) * (y2 - y0)

Zero-area faces are skipped. Accepted primitives are linked into a bucket based on the sum of the three z values:

bucket = (((z0 + z1 + z2) * 5) >> 4) * 8 + 0x110b98

Each bucket entry has a head pointer and a tail pointer. Insertion is an append:

if bucket is empty:
  head = new_node
  tail = new_node
else:
  old_tail.next = new_node
  tail = new_node

The dispatcher at 0x1caf2 walks buckets from far to near:

esi = 0x110b98 + 0x1f40
ecx = 1000
for bucket from high to low:
    tail = [esi + 4]
    if tail != 0:
        node = [esi]
        while node != 0 and node <= tail:
            primitive = [node]
            type = word [primitive + 0x0c]
            jmp [0x110b64 + type * 4]
            node = node.next
    esi -= 8

The jump table sends different primitive types to flat fill, intensity fill, mapped span, or environment-like span routines. For example:

Flat Triangle Span Loop

The flat triangle filler at 0xe448 is useful because it shows the structure of the raster code without all the texture terms.

First it sorts the three vertices by y and clips obvious off-screen triangles. Then it computes fixed-point x edge slopes:

dx_long  = ((x2 - x0) << 16) / (y2 - y0)
dx_upper = ((x1 - x0) << 16) / (y1 - y0)
dx_lower = ((x2 - x1) << 16) / (y2 - y1)

The scan loop keeps two 16.16 x values, converts them to integer left/right edges, clips to 0..319, and fills each row:

for y from y0 to y2:
    left  = x_left  >> 16
    right = x_right >> 16
    if left > right: swap
    clip left/right
    edi = framebuffer + y * 320 + left
    al = shade + 3
    ah = al
    if span length is odd:
        stosb
    rep stosw
    x_left  += slope_left
    x_right += slope_right
    framebuffer_row += 320

This is a very classic 386 triangle core: sort, split at the middle vertex, use 16.16 edge walkers, then fill with byte or word stores.

The textured and shaded span routines use the same setup idea, but carry extra interpolants such as texture u/v or brightness values. The large routine at 0x19944 is the mapped/RGB version: it sorts vertices, builds edge deltas, updates dirty rectangle bounds, and uses reciprocal tables near 0x122876 to avoid divisions in the inner scan setup.

Vertex Interpolation Loop

The helper at 0x1d569 interpolates 12-byte records. The inner operation is repeated for six 16-bit fields:

out = start + (((end - start) * t) >> 8)

In pseudo-code:

for i in 0 .. count - 1:
  for field in 0,2,4,6,8,10:
    s = signed16(src_a[field])
    e = signed16(src_b[field])
    dst[field] = s + (((e - s) * t) >> 8)
  src_a += 12
  src_b += 12
  dst   += 12

This is likely used for morphing or keyframed object states. The >> 8 scale means t = 0 selects the first state and t = 256 selects the second.

Mid-Demo Streaming

The cue branch for 0x17 <= cue < 0x19 is notable because it returns to the DAT stream after the initial load. It calls the TGA-style loader with a 0x1f400 byte pixel block, then reads two raw 0xfa00 byte blocks into 0x2d4568 and 0x2e3f68.

That is a practical memory choice. Instead of keeping every 64 KiB-ish texture and page resident for the whole demo, Solstice streams a later set of effect inputs while the timeline is already running.

Exit

When [0x348328] >= 0x2f, the demo stops playback, shuts down the runtime audio path, restores text mode, copies 1000 dwords from 0x2f3c68 to B800:0000, places the cursor at row 23 column 1, and exits through the runtime.

That final text copy is a clean DOS-demo finishing touch: it restores an already-prepared 80x25 text page instead of printing line by line.

What Solstice Is Doing Technically

Solstice is built around a small set of reusable, fast kernels:

The most distinctive implementation detail is the mix of high-level C-shaped loader/timeline code with tight hand-shaped VGA and raster loops. The code is not trying to make one general engine solve every part. It has compact general Vrender object support, then drops into very specific loops whenever the effect needs exact VGA behavior or exact memory bandwidth.