Analysis model: gpt-5.5 xhigh
Model 0 by Analogue - Technical Dissection
Scope
Model 0 is a 1995 MS-DOS "inductro" by Analogue, released at Somewhere In
Holland 1995. The release text says it was made in roughly twelve hours to
introduce the new group. The public party listings place it in the SIH 1995 PC
demo competition behind Real-Time's Goldorak.
Public references:
- Demozoo production page: https://demozoo.org/productions/28598/
- Demozoo Somewhere In Holland 1995 page: https://demozoo.org/parties/720/
- Pouet production page: https://www.pouet.net/prod.php?which=2581
- Scene.org archive page: https://files.scene.org/view/parties/1995/sih95/demo/an-mod0.zip
- Scene.org download: https://files.scene.org/get/parties/1995/sih95/demo/an-mod0.zip
This writeup is based on the scene.org archive, bundled text files, a short
DOSBox-X execution smoke run, and static disassembly of MODEL0.EXE. It is
not a source-level rebuild. The binary does, however, expose enough code to
describe the loader, memory allocator, VGA helpers, timer hook, particle loop,
object/face sorter, and the classic span fillers in useful detail.
Old private contact/distribution entries from ANINFO01.TXT are deliberately
omitted.
Credits And Release Notes
MODEL0.TXT names the members introduced by the production as Simm,
Spoetnik, pyREX, Mirage, Hein, Metal, MCA, Morris, and The Dark Judge. It
credits coding and music to Simm, graphics to Spoetnik, Hein, and Mirage, and
ideas/design to Simm, pyREX, Spoetnik, Mirage, Hein, and The Dark Judge.
The same text dates the release to Somewhere In Holland 1995, held on 8-10 July 1995. It also says the piece was created very quickly, which fits the executable shape: a compact real-mode runtime, an S3M, and a custom resource stream rather than a large protected-mode framework.
Examined Files
Archive:
cc31cf654a48356430faf5ec8c5100014918105e77f4dfb67bfabc11bead7c20 an-mod0.zip
Archive contents:
MODEL0.EXE 69536 bytes 1995-07-10 00:00
MODEL0.ANR 522690 bytes 1995-07-09 23:52
MODEL0.S3M 184858 bytes 1995-07-09 11:33
MODEL0.TXT 1965 bytes 1995-07-10 00:22
ANINFO01.TXT 25160 bytes 1995-07-09 23:18
FILE_ID.DIZ 254 bytes 1995-07-09 23:21
Extracted hashes:
d43db7bf5384ea79ea49bd599440d097e2fcc8441c40a113d2b1dc93038965ff ANINFO01.TXT
43418eb1b3d8d44cdd10c84d89ed555432d4ff16f22cf27994aac521950abb6e FILE_ID.DIZ
3202c17cc5a8ac699caf13f97cf2b1f606bf01f124d961b56056dd79b90275af MODEL0.ANR
cff2663309c708d2b2e24c10f9f5b13dbb64b3f0e53406e5aef5c996d75f6c24 MODEL0.EXE
536da90597d0acabe7582126fb3b86590280baf160523e6b967b9d1679d1b552 MODEL0.S3M
ffaf314d9b6ec7df0c3b348dec8590cff2635b76b063efdfc7a8e1402a003051 MODEL0.TXT
file(1) identifies MODEL0.EXE as an MS-DOS MZ executable and
MODEL0.S3M as a ScreamTracker III module titled MODEL ZERO - Simm/Analogue. It misidentifies MODEL0.ANR as Targa data; the executable
uses it as a custom resource stream.
The first bytes of MODEL0.ANR already show why the Targa guess is unsafe:
they are a smooth ramp-like byte table, not a normal image header:
00 00 01 01 02 02 03 03 04 04 05 05 06 06 07 07
08 07 08 08 09 09 0a 0a 0b 0a 0c 0b 0c 0c 0d 0d
0e 0d 0e 0e 0f 0e 0f 0f 10 0f 10 10 11 10 11 11
MZ Layout And Offset Mapping
MODEL0.EXE is not packed by the usual LZEXE/PKLITE signatures. It is a small
real-mode executable with 386 instructions and external data files:
physical/MZ length: 69536 bytes
header bytes: 512
relocations: 5
minalloc/maxalloc: 0x0000 / 0xffff paragraphs
initial SS:SP: 0x0000:0x1000
entry CS:IP: 0x0d02:0x0945
entry file offset: 0xdb65
overlay number: 0
Two file bases matter when reading the static disassembly:
code segment file base: 0x0200 + 0x0d02 * 16 = 0xd220
data segment file base: 0x0200 + 0x0100 * 16 = 0x1200
The entry code begins with mov ax,0100h; mov ds,ax. Runtime relocation will
adjust segment constants, but the file bytes still show the pre-relocation
values. As a result, a code near-call printed by a binary disassembler has to
be normalized through the code-segment base, and data variables like
DS:26aa live at file offset 0x1200 + 0x26aa = 0x38aa.
The entry sequence at file 0xdb65 does this:
mov ax,0100h
mov ds,ax
mov dx,2100h
mov ah,09h
int 21h ; print startup text
call 10deb ; memory/exit-vector setup
call 1082e ; manual GUS port selection
call 10ea0 ; timer and keyboard hooks
call 104a0 ; GUS setup
call 10130 ; mode 13h and 64K draw buffer
call 106a5 ; S3M/player setup path
call 10e7d ; allocator snapshot
After that it repeatedly allocates a block, stores the returned segment into a
resource slot, sets ES to the segment, and reads one chunk from
MODEL0.ANR into ES:0000.
Custom Resource Stream
The resource filename is at data offset 0x268f, file offset 0x388f:
model0.anr\0
The slots immediately after it use this inferred structure:
+0: loaded segment, initially 0
+2: byte count
+4: 32-bit file offset inside MODEL0.ANR
Examples from the table:
slot DS:26aa size 9867 anr offset 0x10000
slot DS:2702 size 64000 anr offset 0x29a6e
slot DS:270a size 7224 anr offset 0x3946e
slot DS:2712 size 1332 anr offset 0x3b0a6
slot DS:271a size 250 anr offset 0x3b5da
slot DS:2722 size 414 anr offset 0x3b6d4
slot DS:272a size 15002 anr offset 0x3b872
slot DS:2732 size 32768 anr offset 0x3f30c
slot DS:273a size 33640 anr offset 0x4730c
slot DS:2742 size 12030 anr offset 0x4f674
slot DS:274a size 64000 anr offset 0x52572
slot DS:2752 size 57424 anr offset 0x61f72
slot DS:275a size 64000 anr offset 0x6ffc2
The high-level loader wrapper at file 0x1046c is concise and worth spelling
out:
push ds
push es
push cx
push edx
mov dx,bx
call 10422 ; open filename in DS:DX, handle saved in CS:3200
pop ecx
call 1045a ; seek to ECX, split as CX:DX for DOS int 21h/4200h
pop cx ; byte count
pop ds ; DS becomes the saved ES destination segment
mov dx,0000h
call 10444 ; read into destination DS:0000
pop ds
call 10439 ; close
ret
The wrapper is the key trick: it receives the destination in ES, then pops
that saved value into DS only for the DOS read call, so chunks land at
offset zero in freshly allocated segments.
Bump Allocator
The allocator setup at file 0x10deb preserves old interrupt vectors at
0:20 and 0:24, computes available conventional memory below VGA segment
A000h, and refuses to run if less than 0x40000 bytes are available. The
initial allocation cursor is segment 10dah.
The allocation routine at 0x10e42 is a paragraph-aligned bump allocator:
and ecx,000ffff0h
add ecx,10h
cmp ecx,[bfb4] ; remaining bytes
jl ok
mov word [bffc],bfe3 ; failure text pointer
jmp 10dc0 ; restore vectors, mode 3, print, exit
ok:
sub [bfb4],ecx
mov ax,[bfba] ; returned segment
shr ecx,4
add [bfba],cx ; advance cursor
ret
The helper at 0x10e7d snapshots [bfba] and [bfb4], 0x10e8c restores
that snapshot, and 0x10e6e resets the cursor to the initial free area. That
is why the entry code can load some early chunks, run a section, restore, and
reuse the same memory span.
Sound Setup
Model 0 is Gravis UltraSound-oriented. The setup path starts by asking the
viewer to select one of six GUS base ports. At file 0x1082e, the key read is
direct BIOS keyboard input:
mov dx,a71eh
mov ah,09h
int 21h
mov ah,00h
int 16h
cmp al,'1'
jb bad
cmp al,'6'
ja bad
sub al,'0'
shl ax,4
add ax,0200h
The selected value expands into the GUS register/data/status ports stored at
A712, A714, A716, A718, A71A, and A71C. The hardware init at
0x104a0 allocates 32 blocks of 0x0a00 bytes, then writes GF1 registers
such as 4c, 41, 45, 49, and 0e through the selected ports. Repeated
in al,dx reads are used as short hardware delays.
The S3M filename string is at file 0xd19f:
MODEL0.S3M
The player data area around file 0xb9ad contains SCRM@ and sample-name
placeholders, which matches the ScreamTracker module path.
Timer, Frame Wait, And VGA Setup
The video initializer at file 0x10130 is plain mode 13h plus a private
64 KB buffer:
mov ax,0013h
int 10h
mov ecx,00010000h
call 10e42
mov [9804],ax
mov es,ax
xor di,di
mov cx,4000h
xor eax,eax
rep stosd
The timer install at 0x10ea0 saves the old INT 08h and INT 09h vectors,
installs new handlers, then programs PIT channel 0 with command 36h and
reload bytes 95h,42h. Cleanup at 0x10ee4 restores the old vectors and sets
the PIT reload back to ffffh.
The INT 08h handler at 0x10f11 saves 32-bit registers plus segment
registers, sets DS to the main data segment, and drives three near callback
slots:
dec word [c00f]
jne skip0
mov word [c00f],0002h
call word [c000]
dec word [c011]
jne skip1
mov word [c011],0001h
call word [c002]
dec word [c013]
jne skip2
mov word [c013],0001h
call word [c004]
Then it sets a frame flag at C00E, increments a tick counter at 9806, sends
EOI to port 20h, restores registers, and returns with iret. The wait
helper at 0x10f86 spins until [C00E] == 1, then clears it.
Palette output is equally direct. The full 256-entry writer at 0x1016e
streams 768 bytes to DAC ports 3c8/3c9:
mov al,0
mov dx,03c8h
out dx,al
mov cx,0100h
inc dx
loop:
mov al,[si] ; R
out dx,al
mov al,[si+1] ; G
out dx,al
mov al,[si+2] ; B
out dx,al
add si,3
dec cx
jne loop
The blocking fade at 0x1018a copies 768 target bytes to scratch space,
expands each channel to a 16-bit accumulator, computes a 64-step delta, and
for 64 frames adds the deltas, emits the high byte of each accumulator, writes
the DAC, and waits for the timer flag. 0x1025a is the step-at-a-time variant
used by callback logic.
Full-Screen Byte Effects
The framebuffer helpers are simple but fast enough for 320x200.
Clear/fill at 0x10297 writes 64,000 bytes as words:
xor di,di
mov ah,al
mov cx,7d00h
rep stosw
Fade-down at 0x102a2 subtracts AL from every byte, clamping at AH:
mov cx,0fa00h
loop:
mov dh,es:[di]
sub dh,al
cmp dh,ah
jge keep
mov dh,ah
keep:
mov es:[di],dh
inc di
dec cx
jne loop
Vertical blur at 0x102bb runs over 32,000 words and averages each two-pixel
word with its vertical neighbors:
mov ax,es:[di]
add ax,ax
add ax,es:[di-0140h]
add ax,es:[di+0140h]
and ax,0fcfch
shr ax,2
mov es:[di],ax
add di,2
The horizontal blur at 0x102e0 is the same idea with di-1 and di+1.
Copy-to-screen at 0x10303 is a straight rep movsw from the private buffer
segment [9804] to A000:0000.
Particle Loop
The pseudo-random generator at file 0xdf40 is a tiny 16-bit recurrence:
mov ax,3467h
imul word [2f40]
ror ax,8
add ax,1540h
mov [2f40],ax
ret
0xdf80 seeds 200 records at 2770, each record being three signed words:
mov cx,00c8h
mov di,2770h
loop:
call rand
sar ax,2
mov [di],ax
call rand
sar ax,2
mov [di+2],ax
call rand
sar ax,2
mov [di+4],ax
add di,6
dec cx
jne loop
Plotting at 0xdf51 bounds x to 2..317 and y to 20..179, computes
y*320+x as (y<<6)+(y<<8)+x, clamps the color upward to [2f6c], and writes
one byte to ES:[di].
The update loop at 0xe013 is a damped pull toward three global offsets:
for each of 200 records:
z = ((z << 2) - [2f48]) >> 2
x = ((x << 2) + [2f44]) >> 2
y = ((y << 2) + [2f46]) >> 2
Every 0x12c ticks it cycles a velocity triplet from tables at
2f4a, 2f52, and 2f5a into 2f62, 2f64, and 2f66, then those values
are added into the global offsets. The visible effect is not a generic random
plotter; it is a 200-point cloud with table-driven drift and a z-derived color
term.
Object Transform And Face Buckets
The object code uses GS as the current object segment. Field names here are
inferred from repeated usage:
GS:00 vertex/count field for one loop family
GS:02 face count for triangle lists
GS:06 transformed 3D vertex buffer pointer
GS:08 projected/screen vertex buffer pointer
GS:0c face/depth-side data pointer
GS:10 per-vertex shade or texture-coordinate data pointer
GS:12 triangle index stream A
GS:14 triangle index stream B
GS:16 triangle index stream C
GS:18 per-vertex texture-coordinate pair stream
The generic loops at 0xe0a0, 0xe0be, 0xe0dc, and 0xe0fa all take a
count from GS, source and destination pointers from nearby fields, call a
transform/project helper, then advance by fixed record sizes. One family reads
6-byte 3D records and writes 4-byte projected records; another reads 3-byte
records and writes 6-byte transformed records.
Backface testing at 0xe118 loads the three projected vertices for a face and
computes the signed 2D cross product:
(x2 - x1) * (y3 - y1) compared with (x3 - x1) * (y2 - y1)
The caller at 0xe1be scans every face. If the orientation test says the
face is visible, it computes the maximum transformed z among the three
vertices, stores the face reference at 7172, stores that z at 3172, clears
the next-link entry at 5172, and increments [2f70].
0xe178 then builds 256 bucket heads at 2f72:
mov cx,0080h
mov eax,ffffffffh
mov di,2f72h
rep stosd ; 256 word heads, all ffffh
for each visible face:
bx = [3172+si]
bl = bh
bh = 0
bx = (bx + 80h) & 00ffh
neg bl
bx <<= 1
ax = [2f72+bx]
[5172+si] = ax
[2f72+bx] = si
That is a bucketed painter order. It is not a full sort: the high byte of z chooses the bucket, each bucket is a linked list, and the render passes scan the 256 buckets in order.
Triangle Walker Structure
The span fillers are the best part of MODEL0.EXE. There are many variants,
but they share the same y-sorted triangle skeleton:
- Find the smallest and largest y among
[9186],[9188], and[918a]. - Save the current y in
[91b1]and the row count in[91ad]. - Save the index of the top vertex in two edge cursors,
[91b9]and[91c9]. - Each row, if an edge countdown reaches zero, call one of the small edge setup helpers to move to the next triangle side.
- Clip y to
0..199, clip x endpoints to0..319, computedi = y*320 + x0, then drawx1-x0pixels. - Advance left and right edge accumulators, add 320 to the row base, advance y, and repeat.
The edge helpers are self-modifying. For example, 0xeae0 computes a left
edge x slope and writes it into code offset CS:1a3d. In the static file that
location is the immediate operand inside this row step:
add word [91bd],8000h
At runtime the 8000h placeholder is replaced by the real per-row slope.
The same pattern appears in all attribute-carrying variants: setup helpers
write the edge slopes directly into later add word [...] , imm16
instructions. Static disassembly alone makes those adds look like constant
half-step increments, but they are patched before the loop runs.
Flat Span Filler
The flat triangle filler starts at file 0xeb8c. Its hot span path is:
mov ax,[91aa] ; duplicated color byte
shr cx,1
jae even_count
stosb ; odd leading pixel
even_count:
rep stosw ; two pixels per iteration
The surrounding row loop uses x0 = [91bd] >> 6 and
x1 = [91cd] >> 6, clamps them, subtracts to get width, and fills only if the
width is positive. This is the simplest renderer family: face setup chooses a
single color, the span loop writes duplicated bytes.
The callers at 0xe25e and 0xe314 walk the face buckets, fetch three
projected vertices through GS:12/14/16, derive a depth color from face data,
write it to [91aa], and call either the flat filler at 0xeb8c or the
masked/additive filler at 0xed22.
Add-And-Mask Span Filler
0xed22 shares the same triangle walker, but its span payload modifies the
existing framebuffer:
mov ax,[91aa]
shr ax,4
and ax,0303h
; odd pixel
mov dl,es:[di]
add dl,al
and dl,3fh
mov es:[di],dl
inc di
; pairs
mov dx,es:[di]
add dx,ax
and dx,3f3fh
mov es:[di],dx
add di,2
This is not saturating; it wraps inside six-bit color lanes by masking with
3f/3f3f. That makes it suitable for palette-bank glow or accumulation
effects where the palette maps low six bits to a managed ramp.
Gouraud-Style Span Filler
The 0xef2a family adds one interpolated byte. The face caller at 0xe3ca
fetches per-vertex depth or shade values into [918c], [918e], and [9190].
The edge helpers carry an extra accumulator pair alongside x. The span loop
then computes a horizontal delta:
bp = x1 - x0
ax = right_attr - left_attr
idiv bp
bp = ax ; per-pixel attribute step
bx = left_attr
It also advances bx for any pixels clipped off the left side. The hot path
writes the high byte of the accumulator:
or bh,[91ac] ; palette band / high bits
; odd pixel
mov es:[di],bh
add bx,bp
inc di
; pairs
mov al,bh
add bx,bp
mov ah,bh
add bx,bp
mov es:[di],ax
add di,2
The important detail is that the interpolation is done in registers, not by a lookup table. The edge setup self-patches vertical attribute slopes, then the span derives the horizontal step for each row.
Texture Span Filler
The texture-only filler starts at 0xf1ad. The caller at 0xe4be gathers
per-vertex texture-like values from GS:10 into [9192]..[919c], then calls
this renderer.
The per-row setup calculates a packed 32-bit step:
ax = right_v - left_v
idiv width
shl eax,16
ax = right_u - left_u
idiv width
ebp = eax ; packed v/u step
edx = left_v:left_u ; packed accumulator
The hot loop samples through FS:
shld ebx,edx,8
mov bh,dh
mov al,fs:[bx]
add edx,ebp
mov es:[di],al
The pair loop does that twice, packing two sampled bytes into AX before the
store. This is a compact 386 texture mapper in real mode: no multiply in the
per-pixel loop, just shift/extract, table load, 32-bit add, and byte/word
store.
0xf471 is the saturating-add version of the same texture span:
sample = fs:[tex]
old = es:[di]
old += sample
if carry: old = ffh
es:[di] = old
For pairs it performs the same carry test independently on BL and BH, so
each byte lane saturates to ffh on overflow.
0xf74d is another texture variant that ORs a small mask/color band from
[91aa] into the sampled pixels before writing them. It is useful for palette
banking because the texture provides detail while the face or object state
selects a band.
Textured Shade Span With Stack Pointer Counter
The 0xfa66 renderer combines texture lookup with a horizontally interpolated
shade/band value. The unusual part is that it briefly borrows SP as the
inner-loop pair counter:
cli
mov [91b3],sp
mov sp,cx ; span width
...
shr sp,1
...
dec sp
jne pair_loop
mov sp,[91b3]
sti
Inside the loop, CH is the high byte of a shade accumulator, and each texture
sample is ORed with that byte:
mov al,fs:[tex]
or al,ch
add edx,ebp ; texture coordinate step
add cx,si ; shade step
mov es:[di],al
This is a very 1995 kind of speed tradeoff: interrupts are disabled for the
span, the stack pointer is used as a free counter register, and the real stack
is restored immediately after the row. It saves register pressure in a loop
that already wants EAX, EBX, ECX, EDX, EBP, SI, and DI.
Two-Stage Lookup Texture Span
The deepest variant at 0xfdf0 uses both GS and FS. It keeps two packed
fixed-point accumulators: one in EDX and one in ECX. The hot path is:
shld ebx,edx,8
mov bh,dh
add bl,bl
mov si,gs:[bx] ; row/table pointer from GS
shld ebx,ecx,8
mov bh,ch
mov al,fs:[bx+si] ; final sample from FS plus row pointer
add edx,esp ; first coordinate step
add ecx,ebp ; second coordinate step
mov es:[di],al
This is more than a plain affine texture mapper. GS contributes a row or
warp table, and FS supplies the actual byte texture. The caller at 0xe957
switches GS to the segment stored at [9172] before entering this renderer,
so the table segment is distinct from the object segment used by the face
walker.
What The Inner Loops Reveal
Model 0 looks like a compact intro, but the code is not a throwaway one-off
effect. It has a reusable real-mode 386 triangle system:
- A custom resource stream and paragraph allocator load large parts on demand.
- The frame clock uses a PIT hook with three near callback slots.
- Effects render to a private 64 KB buffer and then copy or blend to VGA.
- Faces are culled, bucketed by depth, and emitted through several renderer families.
- The span renderers use self-modifying slope immediates, 32-bit packed
fixed-point texture coordinates, byte-pair writes, and even temporary
SPreuse in the hottest textured shade loop.
The binary does not expose symbolic scene names, so naming every visual part would be speculation. What is clear from the recovered loops is the technical shape: a fast real-mode software renderer with multiple palette/texture variants, bolted to a minimal loader/player runtime and used as an introductory showpiece for Analogue.