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:
- Initializes runtime and sound support.
- Parses command-line switches.
- 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:
0x1fd90: readEBXbytes from the DAT stream toEDX.0x1fe20: skip an 18-byte TGA header, read a 768-byte palette, then read 65536 bytes of indexed pixels.0x1fea0: same TGA-like path, but with a caller-supplied pixel byte count.0x1f170: skip 8 bytes and read a 768-byte palette block.0x1ff20: load a pattern/grid format.0x20050: load a Vrender object block.
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:
0x1e6b3copies a dirty rectangle from a system buffer toA0000.0x1e712copies the rectangle and then clears the source area.0x1e790fills the same rectangle with the dword at[0x12064c].
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:
0x1cb33loads three vertices plus two table-derived fields and calls either the solid rectangle clear path or the special RGB span routine, depending on[0x126c40].0x1cc56averages three per-vertex shade values and calls the flat-shaded triangle filler at0xe448.0x1cedareads texture coordinate fields from the primitive and calls the mapped span routine at0x13334.
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:
- PMODE/W provides the flat 32-bit DOS environment.
- MIDAS and the S3M module provide music and sync timing.
SOLSTICE.DATis a sequential stream of Vrender objects, palettes, images, tables, and pattern data.- The cue dispatcher selects hard-coded visual routines from music/timer cues.
- Mode 13h copies handle stills and simple full-frame scenes.
- Unchained VGA planar mode handles split-plane RGB effects and page flips.
- Four-byte gather loops build warped chunky copies from sine-derived maps.
- The 3D path transforms 12-byte vertex records, buckets triangles by depth, and dispatches to specialized span fillers.
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.