The engine is not inside the game. It is three AmigaDOS shared libraries in
libs/, each with its own version, each signed by the same name, and all
three dated 1992 — a year before the CD32 existed.
libs/math.library 3,724 B 'MATH 1.2 (C) Wyvern 1992'
libs/tridee.library 7,232 B 'TRIDEE 1.3 (C) Wyvern 1992'
libs/vector.library 11,236 B 'VECTOR 1.5 (C) Wyvern 1992'
Wyvern is the second of the two version numbers in the game's own $VER:
string (Ratt V2.02 : Wyvern V2.00c), so the split the disc advertises in its
title screen is the split on its file system: one author's game, another
author's engine, versioned separately.
All three are ordinary Amiga libraries with a resident tag, an autoinit
structure and a function table. tools/libinfo.py
reads the tables:
| Library | version | data size | exports |
|---|---|---|---|
math.library |
1 | 44 | 15 |
tridee.library |
1 | 44 | 7 |
vector.library |
1 | 44 | 4 |
They are also layered: tridee.library opens math.library in its own init
code (bNumath.library sits at offset 0x1A1 of its single code hunk), and the
game opens all three itself.
Fifteen exports at LVOs −30 to −114. The game calls two of them, and both are worth reading in full because they are the whole of its arithmetic.
LVO −30, a 2D rotation. The 512-entry sine table lives at hunk offset
0x9E4 and runs to 0xDE3, which is the library's declared end — so it is
the last thing in the file, 1,024 bytes, and reading it is free:
0001F2 move.w $8(a7),d0 ; the angle, off the stack
0001F6 beq.b $246 ; angle 0: nothing to do
0001FA andi.w #$1ff,d0 ; 512 entries = one full turn
0001FE add.w d0,d0
000200 lea $9e4(pc),a1
000204 move.w (a1,d0.w),d3 ; d3 = sin(angle)
000208 addi.w #$100,d0 ; + 128 entries = a quarter turn
00020C andi.w #$3fe,d0
000210 move.w (a1,d0.w),d4 ; d4 = cos(angle)
000214 move.w $a(a7),d1
000218 muls.w d4,d1
00021A move.w $8(a7),d2
00021E muls.w d3,d2
000220 sub.l d2,d1 ; x*cos - y*sin
000222 add.l d1,d1
000224 add.l d1,d1 ; << 2
000226 swap d1 ; >> 16 -> /16384
000228 move.w $a(a7),d0
00022C move.w d1,$a(a7)
000230 move.w $8(a7),d1
000234 muls.w d4,d1
000236 muls.w d3,d0
000238 add.l d0,d1 ; x*sin + y*cos
00023A add.l d1,d1
00023C add.l d1,d1
00023E swap d1
000240 move.w d1,$8(a7)
000244 movea.l (a7)+,a1
000246 rts
512 entries for a full turn, amplitude 16,384 = 2¹⁴, and cosine is sine
shifted by 128 entries — one table, no second table, and the quarter-turn
offset done with an addi.w #$100 on the doubled index. The first sixteen
words are
0, 201, 402, 603, 804, 1005, 1205, 1406, 1606, 1806, 2006, 2205, 2404, ...
498 of the 512 entries are exactly round(16384·sin(2π·n/512)) and the
other 14 are one least-significant bit away — a table generated once, in
1992, by something that rounded a little differently from Python. The scaling
after the multiply is <<2 then swap, which is a right shift of 14 — the
table's own amplitude — done in three instructions with no divs.
LVO −102 is the same table with no work done: mask the angle, read sin and cos, return both plus the table pointer. That is the entry point for anything that wants to build its own matrix.
LVO −36 is the perspective divide, and it is the one with a story:
00015A move.l a0,-(a7)
00015C movea.l $8(a7),a0
000164 move.w $c(a0),d3 ; the screen centre, packed as two words
000168 swap d3
00016A move.w $a(a0),d3
00016E move.w $8(a0),d1 ; z
000172 lsl.w #$2,d1
000174 move.w $4(a0),d2
000178 add.w $6(a0),d2 ; the divisor
.retry: asr.w #$1,d1 ; <-- the overflow path lands here
00017E move.w (a0),d0 ; x
000180 muls.w d1,d0
000182 divs.w d2,d0
000184 bvs.b .retry ; division overflowed: halve and try again
000186 asr.w #$1,d0
000188 add.w d3,d0
00018A move.w d0,(a0) ; x'
00018C move.w $2(a0),d0 ; y
000190 muls.w d1,d0
000192 divs.w d2,d0
000194 bvs.b .retry
000196 swap d3
000198 asr.w #$1,d0
00019A add.w d3,d0
00019C move.w d0,$2(a0) ; y'
divs.w on a 68000 overflows whenever the quotient does not fit in sixteen
bits, which for a perspective divide means whenever a vertex comes close to
the camera. The routine's answer is to catch V, halve the numerator's
scale and retry the same divide, giving up one bit of precision each time
rather than clamping the vertex or dropping the polygon. bvs back to a
label above the multiply means the retry re-reads x from memory, so both
coordinates end up on whatever scale the harder of the two needed. That is a
1992 fixed-point renderer's answer to near-plane clipping, and it costs four
instructions.
LVO −78 is a 16-bit integer square root by binary search — mulu.w d3,d3
against the target, bisecting between 0 and $FFFE, at most sixteen
iterations. The game calls it once. A square root in a first-person engine
with a shop and a taxi is almost certainly a distance, not a normal.
Four exports, and the game calls all four; LVO −42 twelve times. It is the
only file on the disc that programs the Blitter, and it programs nothing else:
45 of its 47 absolute custom-chip references are Blitter registers
(BLTCON0×5, BLTCON1×2, BLTAFWM, BLTALWM, the four channel pointers,
the four modulos, BLTADAT×3, BLTBDAT, BLTSIZE×5), and it touches no
bitplane pointer, no colour register and no copper list.
- −30 computes an address into a bitmap:
y × 40for the row (so 320 pixels, 40 bytes a plane-row),+ 0x36for a header,+ x × 4,+ plane × 6, with three flag bits in D3 selecting which of those terms to include. It then calls its own vector at −24 with a parameter of 2 — the library's fourth standard slot, which it has repurposed as an internal entry point. - −36 frees two allocations hanging off a structure at
$2A(a0)and$2E(a0), going throughexecwhen the library is not in its own context. - −42 is the one the game leans on. It takes a byte at
(a0)as an opcode, scales it by four and jumps through two dispatch tables at hunk0x27EAand0x280A— a prologue table and an epilogue table — with the work between them. It allocates its workspace on the stack (suba.l d0,a7), which is what a re-entrant renderer with a variable polygon count does. - −48 is the largest routine in the library, about 9,000 bytes, and it
walks a display list: for each entry it reads a flag word at
$14(a4), masks three bits out of it, follows a chain of$C(a4)pointers, and branches on$FFFFsentinels and on byte indices at$E(a4)and$F(a4)into a table hanging off$4(a5). It is the scene traversal.
So the shape of the renderer is: geometry in tridee, arithmetic in math,
and every pixel through the Blitter — no span-filling loop, no chunky
buffer, and therefore nothing for Akiko to convert (section
06).
Seven exports at −30 to −66, five of them called; zero references to any
custom-chip register. It opens math.library at init. LVO −54 is called
five times and −60 twice. This is the transform and projection layer between
the object files and the blitter.
40 files, 1,896 to 37,650 bytes, in two IFF shapes
(tools/x3g.py):
.x3d FORM VCDO { EXVL, PLST } one object
.x3g FORM O3DG { OFFS, FORM VCDO × N, [VFTX] } a group
EXVL is the vertex table and decodes cleanly:
UWORD vertex count
repeat:
WORD x, WORD y, WORD z model space, signed, ±few hundred
BYTE scratch[10] zero on disc
Sixteen bytes a vertex, of which six are the model and ten are run-time
scratch that ships as zeros — the transformed coordinates go back into the
same record. Across the 40 files there are 367 objects, and 2 + count × 16
reproduces the chunk length exactly on 294 of them. The other 73 carry a
short even-length tail after the last vertex, 4 to 74 bytes:
0BankVectors.x3g object 0, 8 vertices, 6 spare bytes:
02 00 01 06 04 00
0CityMonsters.x3g object 4, 32 vertices, 12 spare bytes:
00 05 06 15 07 0d 0a 09 10 08 0f 00
Those are byte-sized vertex indices: every value is inside the object's own
vertex range on 42 of the 73, and 41 of the 73 end in 00. A short index list
appended to a vertex table is a hull, an outline or a set of attachment points;
which is not settled (12-open-questions.md).
PLST is the polygon list and is only partly read. Its first two words are
0 and one of 0x28, 0x2C, 0x2E — a header length — and the four words
before the first polygon are the object's screen-space bounding box. The
polygon records themselves are not decoded; see
12-open-questions.md.
VFTX appears once, appended to Objects.x3g after the last VCDO, and it
is the whole content of /Demo.vfx. It is a table of byte pairs
(9f 1f 9e 1f 9e 1e 9e 1d …) where the first of each pair has bit 7 set —
a texture or shading table, not decoded.
Vertex counts and bounding boxes, from
notes/sprite-banks.md:
| Family | objects | vertices (level 0) |
|---|---|---|
CityVectors |
33 | 519 |
RoomVectors |
26 | 632 |
CityMonsters |
9 | 302 |
Droids |
6 | 251 |
ShopVectors |
4 | 104 |
people |
4 | 145 |
BarVectors, BankVectors |
3 each | 70, 31 |
Monsters…Monsters4 |
2–3 each | 70–116 |
Objects |
3 | 12 |
PoliceVectors |
2 | 49 |
The city is built out of 33 objects whose bounding boxes are all ±82 in x
and z, and whose y runs from −80 to about −77. A 164-unit square footprint
repeated 33 ways is a tile set: the city is a grid, and CityGen chooses
which tile goes in which cell (section 09).
Seven families exist four times over, prefixed 0 to 3, and the game's
filename table holds only the 0 form (:0CityVectors.x3g), which it patches
with the digit it wants. The obvious reading is four levels of detail — and it
is wrong:
| 0 | 1 | 2 | 3 | |
|---|---|---|---|---|
BankVectors vertices |
31 | 28 | 31 | 28 |
BankVectors PLST bytes |
920 | 788 | 920 | 788 |
BankVectors file size |
4,594 | 4,414 | 3,336 | 3,156 |
CityVectors vertices |
519 | 341 | 519 | 341 |
RoomVectors vertices |
632 | 604 | 632 | 604 |
Sets 0 and 2 have identical geometry, and so do 1 and 3. The vertex counts
and the polygon-list lengths match to the byte across all seven families; only
the file sizes differ, by 15–30 %. So the four sets are two geometries × two
of something else — the second axis is inside PLST and is almost certainly
the shading or texture data, since that is where the surplus bytes are. Two
detail levels and two visual qualities, not four detail levels. What the
second axis selects is unresolved.
/BankVectors.x3g with no prefix is byte-identical to /0BankVectors.x3g
(SHA-1 14d4bdc9b8c1…); /BarVectors.x3g and /ShopVectors.x3g are
unprefixed files with no prefixed twin that is identical, and nothing on the
disc names any of the three. They are the pre-split originals, left behind
(section 11).