Saturday, 31 December 2016

The I76 FSM Processor in (painful) Detail

Long time, no code

So, the real world got in the way for a while, but it's time to look at Interstate 76 yet again to clean up some of the unresolved details from the finite state machine that I left hanging from last time.

The Plan Of Attack

Until now mainly I've been guessing as to how the FSM execution flow works; the kind of instructions that are supported and the way that it hangs together. However there's a limit as to how far we can go with that approach.

It's not that we can't come up with a model that will make the mission bytecode work; it's that there are too many possible working variations and we need to narrow these down into a manageable set to determine a concrete implementation.

The easiest way we have to do that is to look at the actual interpreter assembly and the game code that processes the incoming bytecode, so to go further we have to pick apart the I76 executable itself.


There's a couple of important decompiling tools that I've used to poke around the I76.EXE binary


A "native code to C/C++ decompiler, supporting x86, AMD64, and ARM", Snowman provides a convenient way of extracting out decompiled code flow and matching this to the i386 assembler. The decompiled code is a little "C++ style" verbose, but very useful for top level navigation.

Snowman is available at


A "32-bit assembler-level analyzing debugger" - once we've located and had a pass over the processing we can use OllyDbg to step through the running code and confirm our theories as to how the binary actually works.

Although I have version 2 this has trouble running with I76, so I'm generally using Version 1.1.

When using Olly be sure to go to the Options/Debugging Options menu, select the "Exceptions" pane and tick ignore for "invalid or privileged instruction".

Olly is available at


I'm running I76 on Linux under wine, and the wine debugger offers a neat CLI to set breakpoints, inspect memory, read registers, etc. This provides a slightly more repeatable debugging method than Olly, but Olly is better at interpreting the state of the code and exploring.

Unfortunately I couldn't get the gdb interface to run up I76, so we have instructions for the basic winedbg only.

Finding the relevant piece of the Binary

What To Look For

The things to look for are the target strings that describe FSM operations, and using Snowman's decompiler output to find the case selections that cover the ranges for op-code and action processing.

The action processing is particularly distinctive; the number of actions will result in a large switch. In practice I found the action processor first by looking through the decompiled code for a large case statement and then established the linked functions from there.

Where things actually are

To cut a long story short: For the basic I76 binary then these are the mappings.

The function at 0x0040EBB0 contains the instruction switch handling for the FSM op-code processor. It's a loop around a case statement covering the range of available instructions, and contains the action handler references. Also 0x40ec05 puts out the error string "unknown fsm assembler instruction" when the requested switch fails which helps confirm this is the right code.

The function at 0x0040D0D0 is the switch for the action handler. We can spot this because it's the function with a suitably large case statement, is called by the "right" case handler in the op-code processor and also 0x40d0f9 has a debug string referencing "unknown fsm prototype" in the switch failure handler.

Other Versions

In the (unpatched) Nitro executable then the FSM decoder is at 0x414db0 and the action handler at 0x413420. At first glance the FSM execution code looks largely the same.

The FSM Processor Up Close

Looking at the FSM handler - at the lead in then we load the first argument to the function as:

40ebd2: mov edi, [esp+0x64]

We then use the loaded EDI value to locate the op-code and use it for the switch:

40ebf0: mov ecx, [edi+0x10a8]
40ebf6: mov eax, [ecx]
40ebf8: dec eax
40ebf9: cmp eax, 0xd
40ebfc: ja 0x40ec05

i.e. we use "arg1 + 0x10a8" to locate the opcode, move it into EAX, and then subtract one from it (so our opcode range of 1-14 is a switch value of 0-13). Finally the the cmp/ja is the opening of our switch.

So we can say, with a reasonable degree of confidence that the "edi+0x10a8" is the FSM instruction pointer, and extrapolate from that to say the EDI value provided by argument 1 is the base of memory for this FSM instance.

One other operation we perform in the prologue is clearing the ESI register which we use as a loop counter, so we have

40ebd0: xor esi, esi

And at the tail end of the case operations we have

40ee79: inc esi
40ee7a: cmp esi, 0x3e8
40ee80: jl dword 0x40ebde

Which is the test to loop around and carry on processing the FSM. However some Op-codes (e.g. op-code 10 and 12) can break out and skip this test, so when the FSM runs it actually spins through repeatedly until it hits a return condition.

As a result we have a "pseudo code" loop of...

  if (loopcount > 800)
 ... set global flag ...

 opcode switch
 ...some of which break out...
} while (loopcount++ < 1000);

Interestingly this tells us how the jump op-code "10" may be different from the other jump op-codes, since it breaks out of the counting loop. We'd flagged this as the last op-code we see in microcode blocks and speculated about the side effects, but now we have a clue as to why that might be.



Looking at the assembler for Case 0, which we thought was a push (op-code 1):

40ec17: mov eax, [ecx+0x4]
ECX was referencing the op-code, and therefore it was pointing into the FSM bytecode containing op-code and argument pairs. Since this is an increment then it's likely that this should be the corresponding argument data to our current op-code. It looks like the FSM bytecode has been unpacked into 8 byte fields (4 byte op-code, 4 byte argument).

40ec1a: mov ecx, [edi+0x10b0]
Since EDI is the machine structure base, and we're expecting push, then the chances are this is a reference to the stack pointer address.

40ec20: mov [ecx], eax
The stack pointer referenced address is now set to the argument data value we retrieved from the FSM bytecode pointer.

40ec22: mov ecx, [edi+0x10b0]
40ec28: add ecx, 0x4
The stack pointer value is loaded to ECX and the value incremented by 4 (i.e. a dword)

40ec2b: mov edx, [edi+0x10a8]
40ec31: add edx, 0x8
Update the current opcode/data pointer (move FSM bytecode pointer forward). We move forward by 8 bytes, which is one 4 byte opcode & one 4 byte argument.

40ec34: mov [edi+0x10b0], ecx
40ec3a: mov [edi+0x10a8], edx
Update the actual Stack pointer and FSM bytecode pointer values in memory with the new versions we have in ECX and EDX

40ec40: jmp dword 0x40ee79
And finished. This goes back to the processing loop.

So, yes that's a push. And we've got the following mappings of what's in memory:
EDI + 0x10a8 => Current Pointer in the loaded FSM bytecode (FSM-IPtr)
EDI + 0x10b0 => Stack Pointer


Case 7 (Opcode 8) which we initially felt was a definite jump/call style instruction uses another field:
40ed51: mov eax, [ecx+0x4]
Load argument to EAX again

40ed54: mov ecx, [edi+0x10a4]
Load ECX with the property "+0x10a4". Since we're looking at a JMP style calculation this instantly looks to be the base of the FSM bytecode.

40ed5a: lea eax, [ecx+eax*8]
Calculates the target memory address - since bytecode fields are 8 bytes (4 byte op-code, 4 byte argument) the multiplier is 8 and so the result is the address of the "arg'th" entry from the "+0x10a4" address. This ties up with a jump calculation.

40ed5d: mov [edi+0x10a8], eax
Set the current FSM instruction pointer to the calculated value.

So that's a "goto" style statement with the argument as an absolute address in the FSM code which confirms another piece of the memory layout:
EDI + 0x10a4 => Base of the FSM bytecode.

Confirming That Push

Now we can look at these operations in the debugger to confirm our reading of the op-codes and fields.

Launch The Debugger

Launch it with:

cd .wine/drive_c/Program\ Files/Activision/Interstate76/
winedbg ./I76.EXE

And at the debug console set up the break on the FSM processor and start the application.
Wine-dbg>b *0x40ebd2

This runs up the app, and we can launch a mission. Choosing the training mission we'll see a "Somewhere In The Southwest" and then the debugger will hit the breakpoint.

Look At The Running Program State

We can use the wine debugger to look at the source where we have halted:
0x0040ebd2: movl 0x64(%esp),%edi
0x0040ebd6: movl 0x6c(%esp),%ebx
0x0040ebda: movl 0x70(%esp),%ebp
0x0040ebde: cmpl $0x320,%esi
0x0040ebe4: jle 0x0040ebf0
0x0040ebe6: movl $0x1,0x005054ac
0x0040ebf0: movl 0x10a8(%edi),%ecx
0x0040ebf6: movl 0x0(%ecx),%eax
0x0040ebf8: decl %eax
0x0040ebf9: cmpl $13,%eax

This ties up with the Snowman view of these op-codes, although different mnemonic conventions are in use (AT&T versus Intel).

Now we can use "si" to step forward:
0x0040ebd6: movl 0x6c(%esp),%ebx
0x0040ebda: movl 0x70(%esp),%ebp
0x0040ebde: cmpl $0x320,%esi
0x0040ebe4: jle 0x0040ebf0
Wine-dbg> info regs
Register dump:
CS:0023 SS:002b DS:002b ES:002b FS:0063 GS:006b
EIP:0040ebe4 ESP:0033f474 EBP:0000005f EFLAGS:00000283( - -- I S - - -C)
EAX:00000000 EBX:05f58138 ECX:00000000 EDX:05f6c5b8
ESI:00000000 EDI:05f61da0

At this point we can look at the running FSM pointers referenced through EDI
Wine-dbg>x/16x ($EDI+0x10a4)
0x05f62e44: 05f6c5b8 05f709d8 05f61da8 05f61dac
0x05f62e54: 05f62da0 05f62e68 00000000 000010c0
0x05f62e64: 04455355 05f6c574 05f6c58c 05f6c590
0x05f62e74: 05f6c594 05f6c598 05f6c5a8 00000000

And from our guesses so far, then we think that from this block of data:
0x05f6c5b8 (+0x10a4) is the base of the loaded FSM microcode
0x05f709d8 (+0x10a8) is the current FSM Instruction pointer
0x05f61dac (+0x10b0) is the current FSM Stack Pointer

Also looking at the value in "+10ac" this is one dword below the current stack pointer (0x05f61da8), so there's an immediate suspicion this might actually be a reference to the stack base.

Dumping those memory values (as decimal) then the base of the loaded microcode is
Wine-dbg>x/16d 0x05f6c5b8
0x05f6c5b8: 0006 0001 0008 0002
0x05f6c5c8: 0005 -0004 0005 -0005
0x05f6c5d8: 0001 0275 0004 0002
0x05f6c5e8: 0013 0064 0007 0001

And this ties up with our extraction of the FSM microcode, specifically our initial "Block 3" dump from the A01 mission looks like this:
Block3/0 6:1
Block3/1 8:2
Block3/2 5:-4
Block3/3 5:-5
Block3/4 1:275
Block3/5 4:2
Block3/6 13:64
Block3/7 7:1

which all ties up. Hooray.

Also our current FSM IPtr is referencing:
Wine-dbg>x/16d 0x05f709d8
0x05f709d8: 0001 0000 0001 0000
0x05f709e8: 0001 0001 0006 0001
0x05f709f8: 0008 2185 0013 0052
0x05f70a08: 0009 2191 0004 0004

And this is 17440/8 = 2180 entries into the FSM microcode which we had as:
Block3/2180 1:0
Block3/2181 1:0
Block3/2182 1:1
Block3/2183 6:1
Block3/2184 8:2185
Block3/2185 13:52
Block3/2186 9:2191
Block3/2187 4:4

All of which looks correct

Watching the Push Happen

Walking through the first three operations we should see the stack pointer updating by 12 bytes (from 0x05f61dac to 0x05f61db8) and the stack values "0 0 1" should be pushed onto the stack.

So after stepping through the FSM processing for the first three instructions we see looking at the first three values from the initial Stack Pointer:
Wine-dbg>x/3d 0x05f61dac
0x05f61dac: 0000 0000 0001

And the running FSM pointers are:
0x05f62e44: 05f6c5b8 05f709f0 05f61da8 05f61db8
0x05f62e54: 05f62da0 05f62e68 00000000 000010c0
0x05f62e64: 04455355 05f6c574 05f6c58c 05f6c590
0x05f62e74: 05f6c594 05f6c598 05f6c5a8 00000000

Which is all in line with the changes we believe we should have for the stack & pointer values.

And The Rest

So, this is the basics of how we verify the FSM op-code operation. It takes a lot of verbose detail to go over this for every instruction, so I'll skip over the JMP but it's a similar process to validate. Next time I'll write up the other op-code values we can decipher, and some additional structural details, but this'll do for now. Updated: A couple of minor typo/spelling fixes

Tuesday, 20 September 2016

The I76 Mission File - The Single Player Scenarios

The Mission File - Scripts

First off a huge Thanks to Karl Meissner and Kurt Arnlund, of the original I76 development team, for suggestions and hints as to what we're looking at in this section, and how to understand it.

Also a warning: this information is (mostly) still at the level of guesswork - unlike the earlier file formats where I  could extract and modify complete sets I'm still at the stage of tweaking around the edges here. Hopefully I'll be able to refine a few guesses later, but for now here is where I am.

The Data Wrapper

The single player mission script implementations are held in the "ADEF" section of this mission file - this is another nested BWD2 structure, and the first element in it is a revision tag "AREV", which is 12 bytes long, and always reports a revision number of "1".

Following this is the FSM tag, which starts with "FSM " and a 4 byte size, which occupies the remaining content of the ADEF field: it will always be the length of the ADEF field minus 28 bytes (version header, and two trailing EXIT tags)

The data in the FSM region gives us the key information about the mission state, and is arranged in chunks of "4 byte size" followed by data. The size is given as "number of entries"

The FSM section contains:
  • Action Table: An array of 40 byte strings, defining actions that the game engine can perform.
  • Entity Table: An array of 48 byte entries, each of which contains
    • a 40 byte label
    • a corresponding 8 byte object name
  • Clip Table: An array of 40 byte file names, each of which is a sound file
  • Path Table: An array of paths, where each path is:
    • a 40 byte path label
    • a four byte count of points, followed by
      • a 12 byte point, of 3 four byte floats, for each point
  • Machine Table: a number of 42 byte machine definitions
  • Variable Table: a number of 4 byte integers
  • ByteCode: a number of 8 byte entries
The last three entries are particularly involved, so we'll deal with those separately.

The action table is always the same (same size, same entries, same order). There are 95 actions, and this is a list of instructions shared with the executable, also viewable as readable strings in the binary. So there are entries like:
Action 65:isWithinEnemy
Action 66:isAttacked
Action 67:isShot
Action 68:isRammed
Action 69:hpLesser
Action 70:isDead
The Entity table has a number of descriptive names associated with the compiled (i.e. masked) versions of the object names, so this has entries like (e.g. from Trip 1):
Entity 0: speedsign -> Object sl50x_1
Entity 1: groove -> Object vppirna1
Entity 2: taurus -> Object t01js01
Entity 3: enemy1 -> Object t01al01
The clip table contains sound clips used during the level. The first 105 clips (0-104) are always the same set of common sounds (same files, same order), and the later entries vary between levels, e.g. from Trip 1 these are the clips used in the opening communications between Taurus and Groove with annotations added by me to define which is which:
Clip 105 :01sta66.wav (T: Ok, follow me in formation. That means...)
Clip 106 :01sta09.wav (T: Ok Baby, here's the school ... this car's been Mod-i-fied [First clip])
Clip 107 :01sgc06.wav (G: Yeah, I got it. [1st groove])
Clip 108 :01sta10.wav (T: Follow me to seagraves [clip after first pan to sign])
Clip 109 :01sgc07.wav (G: Got it.)
The path table is a handily labelled set of paths and points which trigger events, cars follow, etc. These are in world co-ords in meters. Again, from Trip 01:
Path 9: loop 3535,0,40215 3525,0,40135 3545,0,40075 3625,0,40085 3640,0,40115 3700,0,40160 3690,0,40195 3670,0,40245 3590,0,40245
Path 10: theway 4545,0,38675 4490,0,38680 4250,0,38680 4200,0,38680 4065,0,38680 3895,0,38680 3895,0,38685 4200,0,38685 4200,0,38510 4190,0,38365 4580,0,38350 4665,0,38615
Path 11: tohome 3730,0,40230 3720,0,40290
Path 12: torise 3860,0,38830 3790,0,38905 3670,0,39045 3640,0,39165 3675,0,39245 3825,0,39325 3910,0,39345 4030,0,39435 4050,0,39565 4035,0,39675 3995,0,39810 3930,0,39980 3905,0,40035
Now, for those big tables.

The Compiled Script Tables

Again a warning: the remainder here is mostly guesswork - I'm not at the stage where I fully understand the execution of the bytecode itself, and modifications have a tendency to crash.

This section of the file is used to run a number of tasks on a set of basic Virtual Machines; the game scripts were compiled to bytecode and these bytecode blocks are run on the internal VM. 

Of the three tables, the third is the actual bytecode, the first table is a set of execution hooks into this bytecode, and the second is some form of global constant/variable array.

My first guess was that due to the limited number of opcodes this would be a stack machine. For the moment I'm assuming that each running "machine" has an incoming argument list and a local stack used to marshal arguments which go to the game engine actions.

The first table, which I'm calling the machine table, has a start point (i.e. a location inside the third bytecode block) and then a count for the number of arguments and the arguments themselves. Obviously by giving different arguments then some script sections can apply to different objects, so for the tutorial level, a01.msn, then this table has the entries:
Block1/1 Start Address: 2238 Initial args Sz: 6 args: [1 7 8 9 10 14 ]
Block1/2 Start Address: 2238 Initial args Sz: 6 args: [2 8 9 10 7 14 ]
Block1/3 Start Address: 2238 Initial args Sz: 6 args: [3 9 10 7 8 14 ]
Block1/4 Start Address: 2238 Initial args Sz: 6 args: [4 10 7 8 9 14 ]
So, this is the control of the first four drones (entities 1,2,3 & 4) which follow the loop path set out by the four points 7, 8, 9 & 10. Each drone is given the points with a different start in order to ensure they are neatly queued.

ByteCode and operations Guesswork

Looking initially at the compiled bytecode: each 8 byte entry appears to form a pair of two 4 byte numbers. The first number is always a fairly low value (<16), and I'm guessing that this means we're looking at a 4 byte operation and a 4 byte argument pair. The operation codes run from "1" up to "14", and opcodes 2,3 and 11 are never used in the default i76, but 3 & 11 are used in the Nitro pack. I'll focus on the original i76 missions to simplify things.

Looking at the range of values associated with the opcodes we can make some initial guesses.

OpCodes 8, 9 and 10 are Jump style instructions which have a valid destination address as an argument. Given the last entry in all the bytecode tables is opcode 10 this is almost certainly the unconditional branch (and we can split up the bytecode using the ranges in the machine table and see this idea is confirmed).

OpCode 13 has the correct range of values to cover the table of actions, and substituting in we get some "sensible" patterns.

Opcode 5 appears to line up with passed arguments: I view this a stack copy from an input argument stack, however the value appears to be one value deeper than would be expected from the initial values, indicating a possible addition.

As a result, and looking at the way functions are entered currently I believe that leaves 8 as a "Call with arguments", and 9 as a "Jump if Zero". The significance of 8 as a call is that it potentially modifies the stack in line with our expectations.

Also by looking at the audio clip list and correlating the clips we know about with the actions in the game it becomes clear that opcode "1" is used to push audio clips which are then used by functions like "cbPrior" to play sounds. More generally this opcode is used to supply function parameters, so we treat opcode 1 as a stack push.

The Bytecode Proper

The first 2180 entries of the bytecode table match between the files, and presumably form a common set of operations and conditions used in the single player game.

Looking at one of the unique blocks in Trip 3 then the machine table has a section which invokes this logical flow:
Block1/20 Start Address: 3368 Initial arg Sz: 2 arg: [0 1 ]
Block3/3368 CALL: 3369
Block3/3369 COPY: -3
Block3/3370 ACTION(isDead)
Block3/3371 JZERO: 3385
Block3/3372 PUSH: 0x75:[117]
Block3/3373 (?)4: 1
Block3/3374 PUSH: 0x1:[1]
Block3/3375 (?)4: 2
Block3/3376 ACTION(cbPrior)
Block3/3377 (?)7: 0x2:[2]
Block3/3378 PUSH: 0x5:[5]
Block3/3379 (?)4: 1
Block3/3380 PUSH: 0x1:[1]
Block3/3381 (?)4: 2
Block3/3382 ACTION(failAllObj)
Block3/3383 (?)7: 0x2:[2]
Block3/3384 (?)12: 0x2:[2]
Block3/3385 COPY: -2
Block3/3386 ACTION(isDead)
Block3/3387 JZERO: 3407
Block3/3388 PUSH: 0x6:[6]
Block3/3389 (?)4: 1
Block3/3390 PUSH: 0x3:[3]
Block3/3391 (?)4: 2
Block3/3392 ACTION(failAllObj)
Block3/3393 (?)7: 0x2:[2]
Block3/3394 PUSH: 0x76:[118]
Block3/3395 (?)4: 1
Block3/3396 PUSH: 0x1:[1]
Block3/3397 (?)4: 2
Block3/3398 ACTION(cbPrior)
Block3/3399 (?)7: 0x2:[2]
Block3/3400 PUSH: 0x77:[119]
Block3/3401 (?)4: 1
Block3/3402 PUSH: 0x3:[3]
Block3/3403 (?)4: 2
Block3/3404 ACTION(cbPrior)
Block3/3405 (?)7: 0x2:[2]
Block3/3406 (?)12: 0x2:[2]
Block3/3407 ACTION(null)
Block3/3408 JMP: 3369
The Trip 3 entity table has:
Entity 0: user -> Object vppirna1
Entity 1: taurus -> Object t03js01
And looking at the bytecode we can see the "obvious" flow of:
Block3/3368 CALL: 3369
So now the argument stack list may have three values "0 1 <return address>"
Block3/3369 COPY: -3
Block3/3370 ACTION(isDead)
Block3/3371 JZERO: 3385
i.e. copy the "0" value from the argument list to the stack and call "isDead" to check if entity 0 (the player) is alive. Assuming isDead returns false then we'd follow the jump and get to
Block3/3385 COPY: -2
Block3/3386 ACTION(isDead)
Block3/3387 JZERO: 3407
Which is essentially the same check for Entity #1 (Taurus). Assuming isDead returns false again then:
Block3/3407 ACTION(null)
Block3/3408 JMP: 3369
Which is a "do nothing" then a return to the entry point to repeat this check. So we would loop around checking that the player and Taurus are alive.

If, however, the isDead on the player came back non-zero then we'd instead follow this branch:
Block3/3372 PUSH: 0x75:[117]
Block3/3373 (?)4: 1
Block3/3374 PUSH: 0x1:[1]
Block3/3375 (?)4: 2
Block3/3376 ACTION(cbPrior)
Block3/3377 (?)7: 0x2:[2]
Block3/3378 PUSH: 0x5:[5]
Block3/3379 (?)4: 1
Block3/3380 PUSH: 0x1:[1]
Block3/3381 (?)4: 2
Block3/3382 ACTION(failAllObj)
Block3/3383 (?)7: 0x2:[2]
Block3/3384 (?)12: 0x2:[2]
This looks to push audio clip #117 for the player death, and instructs the engine to play the clip using "cbPrior" (CB Audio, Priority?). Then it calls into failAllObj. Opcode 12 probably halts this execution flow at this point.

The case where Taurus dies is similar, but this plays two audio clips (#118 & #119) before failAllObj.

Looking up the clip table references then clip# 117 is a "Groove Dies" and clip #118 is a "Taurus Dies" clip, with clip #119 Groove goes "Uh Oh".  Everything ties up there, and we can change the audio clip reference in the mission file to confirm our ideas so far. Also we can modify the arguments to failAllObj and get different reports from the mission NPT file, change Groove dies to Taurus dies cases and vice versa.

From looking at this instruction flow, at this point OpCode 4 looks to be setting the stack pointer manually after each push, and Instruction 7 could be stack cleanup (POP) - removing the earlier arguments.  Although the stack set isn't necessary, since it's clear from other blocks there's an auto increment on PUSH, it's not unreasonable to think that marshalling up the arguments here might take care to force explicit values.

However this isn't consistent enough to be certain, and in particular the difference between the way in which the stack state combined COPY and PUSH counts line up with the POP is odd when we generalise this over some functions and actions. In addition I've been (deliberately) vague about how the return value from actions is actually stored, and the whole machine table call stack thing is a little thin.

Right now I've only convinced myself and the cat that this is close to true.
Yeah. This is my "Convinced Face". No, Really.
Maybe not the cat then, who seems particularly unconvinced by the call stack stuff.

Clearly some more work on reverse engineering this section is required, but that's all for now....

Saturday, 3 September 2016

And yet more I76 - Roads, Objects and Related Guesswork

Early on I mentioned that we would need to decode the RDEF section of the mission file to get more detail on the roads.

This turns out to be fairly straightforward for the basics. There are a couple of loose ends around the details which I've yet to decode, however here's the progress so far.

Parsing the RDEF

The RDEF section is another nested BWD2-style tag/length/data section, it opens with the size of the field and a revision tag, RREV, which for the I76 I have here is always “1”.

Following this there are a number of “RSEG” declarations, each of which contains a declaration for a piece of road, and has the data:
  • 4 byte “RSEG” label
  • 4 byte Data Length (uint32_t)
  • 4 byte Segment Type
  • 4 byte Segment Pieces Count
  • Followed by a number of 24 byte segment pieces.

The segment type tells you the type of road, 0 is “paved highway”, 1 is “dirt track” and 2 is (I believe) the rarer “river bed” type. T05 also has a segment of type 3072, but that has no actual pieces associated with it (so I'm being optimistic and assuming that's an artefact of some four lane highway stuff that was cut from the game, as opposed to a bug on my part).

The segment count tells you the number of 24 byte segment pieces remaining in this block of data, which describe this section of road. (So the overall RSEG data size will be ((24*'segment pieces count')+16).

Each of these 24 byte entries is 6 floats, arranged as two 3 byte vertex co-ordinates, and these outline the road edges. One thing to note is that these values are in absolute game co-ordinates, in meters (so they run from 0,0 to  51995,51995). We can treat these values as “XZY” triplets (for the convention of X vs Y on a plane and Z as vertical), and by placing vertices we end up with a road path which we can simply mesh directly:

One oddity is the 'Z' values – they are almost all zero or near zero, which makes sense since the road will follow the terrain height map for vertical values, however the end of some paths, particularly those at junctions have high values.

However I have no idea quite why these values are high – there may be some hint to the render engine, and the actual Z value groups around particular junctions and junction objects in a suspiciously deliberate way, but I have no idea why the values do what they do. It may be they're masked values, or not actually floats, but for looking at imported road meshes I simply ignore them for now.

Also I think the road definition is just a texturing cue to the render engine, since the actual behaviour of the roads depends on the values in the terrain height field higher order bits, but that's also something of a guess at the moment.


Just a quick note on the objects: these are in the ODEF section of the mission file – again this is a nested BWD2-style section of the file, and has an OREV revision tag of “3” in my case.

Each object starts with the tag “OBJ “ and a 4 byte field length and is always 108 bytes long. The data is
  • 8 byte raw label
  • 2 byte integer
  • 2 byte integer
  • Followed by “at least” 11 bytes of floating point value
The object label is an odd mash of the Class Name string (as per the asset bible) masked with a unique id value to prevent collisions – if you mask out the high bit with a piece of code like
    for (int i=0; i < 8; i++)
      unsigned char v =;
      if (v > 0x7f)
        labelhigh = (labelhigh <<1) |0x01;
        labelhigh = (labelhigh <<1) & 0xfe;
      v = v &0x7f;
      if (v != 0)
        object.label += v;
Then you wind up with a set of ASCII strings and associated ID values, i.e. from M01 separating the labels and labelhigh values of the spawn points gets:
Object Label "spawn" High Bits "0"  Of length  "108"
Object Label "spawn" High Bits "16"  Of length  "108"
Object Label "spawn" High Bits "15"  Of length  "108"
Object Label "spawn" High Bits "14"  Of length  "108"
Object Label "spawn" High Bits "13"  Of length  "108"
etc. Quite why this wasn't just split down into a label with a trailing ID number originally isn't entirely clear, although it could be that parts of the engine had to work inside the 8 byte label restriction (another guess).

Although I haven't gone into too much detail on what the object fields actually do (i.e. how ClassID, size and rotation is encoded) the X & Y co-ordinates appear to be from the 9th and 11th floating point value, and we can place them directly on the road render – just dropping these on as simple planes around the target point we get:
t01 roads and objects
To help decode what's being displayed we can use the .obj format to add names to the objects – before declaring the face simply add a line beginning with “o” and followed by the name string, which we can from from the base label and the ID value we extracted above e.g.:
  s = "o " + obj.label +"_"+ QString::number(;
And you get this:
Highlighting the Red Deacon Fireworks stand from t01

Friday, 26 August 2016

Those I76 Level Heightmaps, in 3D

So, since we can extract heightfield information for the level terrain then there's an obvious thing we can do, which is try and convert the heightfields to .obj format as a mesh, and look at them in Blender to get a nice 3D view of the whole level.

Anyway, this is actually simple: take the heightmap, and walk through it dumping out the co-ordinate as x & y, and the height value as z, prefixing these values with a "v" to generate vector values in an obj file format-

      QString s = "v " + QString::number(x) + " "+ QString::number(y) + " "  + QString::number(z);
This gives us the basic height map as a set of points in an obj "importable to blender" format.

Then, since obj files can support faces that are quads we can simply walk through the heightfield and form a quad  with the lower edge from two values in this row, then the values from the next row as the upper edge, i.e. for each pixel we take this pixel, the next X pixel, and the go up one Y interval to the next row and take the two adjacent pixels to form the edges points of the quad.

  int fcount =1;
  int fh = fcount + (w*128);

...and then, for points in the heightmap...

        QString s = "f " + QString::number(fcount) + " "+ QString::number(fcount + 1) + " "  + QString::number(fh+1)+ " "  + QString::number(fh);

We need to watch out for going over the boundaries of the heightmap in the outer loops (i.e the upper X & Y values), but that's all.

So this is the output for the crater.

The slight discontinuity (around the zone edges) on the inside of the crater walls is odd - for a while I assumed it was a fault in the export or rendering, but going into the game on that map there *is* actually a slight step there which you can see when you drive along it, so  I suspect it's either an artefact of the level editor, or possibly a deliberate step to help vehicles get up the slope (although it's a bit too regular for that to be likely).

The Z-scaling is fairly arbitrary - I just played with the Z scale in blender for these screen shots rather than work out a mapping.

Looking at something larger, here's trip level 1.

And here's a close up of a section (which matches the mesh from the introductory image to this post)

Which is kinda neat. You can see the nice smooth, editor generated, slopes of the hills, and the blocky "looks kind of manual" cliff edges. Some of the "pits" cut into the terrain look strange, but having driven around in the game level they do appear to be there as deliberate (and annoying) traps to catch you if you go too far off the line that Taurus gives you. The LOD in the game does actually help these look less abrupt, which is actually quite a clever example of using the engine limitations when you think about it.

And that'll do for today.

Tuesday, 23 August 2016

I76 - Nitro Riders and Compression

The Cover, via Wikipedia

Nitro Riders (or Nitro Pack in the US) uses the same ZFS file format as the original I76, however it also supports compression of the files in the ZFS, which leads to a minor complication in unpacking.

The compression scheme used is, as per Battlezone, LZO and the standard lzo library can handle the decompression process.

The standard library is available here:

So we can add a simple link (on my stock Debian box) to -llzo2, or in Qt/qmake speak we can update the .pro file with:
 LIBS += -llzo2

And our code needs to include an initialisation call to:

There are actually several different compression algorithms inside the LZO family, and the Nitro ZFS uses two different variants, so there are three possible options for each file entry in the ZFS.
  • No compression
  • lzo1x algorithm
  • lzo1y algorithm
In the original I76 ZFS then we noted that file header contains a 4 byte "NULL" value. This is used to flag compression in the Nitro version.

If this value is zero then there is no compression involved, and the file can simply be unpacked, as in the original I76.

Otherwise the lowest byte details the exact compression algorithm used
  • If the value is 2 then use lzo1x
  • If the value is 4 then use lzo1y

The remaining (upper three) bytes tell us the unpacked size of the target file.

So we can implement a file decompressor with the signature:
static bool do_lzo(QString name, QByteArray& ba, int comp, int size)

Where name and ba are the input data from the ZFS, and comp and size are supplied by:
  • comp:  fh.null&0xff
  • size:  fh.null >> 8
And we can implement a simple decompress (given a correct comp field) with:

dst =  (unsigned char*)malloc(size);
dst_len = size;
src = (unsigned char*);

  if (comp & 2)
    r = lzo1x_decompress_safe(src, ba.length(), dst, &dst_len, NULL);

  else if (comp & 4)
    r = lzo1y_decompress_safe(src, ba.length(), dst, &dst_len, NULL);

  if (r != LZO_E_OK)
  {// Error

  {// Good - write it out

And that's it. We can just write out the decompressed data from the 'dst' buffer to the target file name.

We could be neater and keep the decompression buffer rather than reallocate each time and use the non-safe version of the LZO decompression algorithms, but this is fast enough for our purposes.

We'd also be caught out by any other comp values/LZO variants, but this seems to cope with every piece of Nitro content I have here (from the original game CD).

Friday, 19 August 2016

Even More I76 - Levels and Heightmaps

Some level terrain looks suspiciously hand-drawn

Before I forget though... the .map files

I didn't mention the .map files in the last post, because they're pretty simple - they are just direct bitmap files, used for some of the surface and lower resolution textures. Each file opens with a 32 bit pair of integers for width & height, and then it's just a 1 byte/pixel lookup into the game CLUT. contains a team photo easter egg

Onto levels

The level layout is defined by the mission file and a terrain map. These are held under the “missions” directory on the install CD and in the target install directory. Fortunately between the level editor, and similarities to the (already documented) Battlezone formats, we can decipher quite a bit of the detail.

The terrain map is a fairly simple format – it's held in a ".ter" file as packed set of 128 x 128 x 16 bit terrain values. So each terrain block is 32KBytes.

Each 16 bit element represents a height and some terrain flags which determine some rendering decoration details and rolling resistance. For our current purposes of this we're just interested in the height information, which is the lower 12 bits of the value, giving us a height from 0 – 4095.

Each 16 bit value represents a 5 meter section in the game, and so each terrain block of 128*128 values is a 640 x 640 meter region.

So parsing this file gives us a number of square blocks of terrain, however to know how to put these together to make a level layout we need to look into the mission file.

This is a “BWD2” file format with the extension “.msn” (for missions supplied with the game, “.lvl” for editor created ones). This contains a number of nested Tag/length/data fields. However to extract the layout we need to look at the zone map, which is prefixed by a “ZMAP” tag.

The zone map is an 80 x 80 grid of bytes, each of which references a terrain block. The value of 0xff is used to indicate a “default” zone – this is a flat piece of the map with the default texture for the level.

Non-default values represent a reference to one of the blocks of terrain, and are simply an index (so the value '0' represents the first block, '1' the second, etc). It's unusual to use more than a handful of terrain blocks in a map – the biggest is the second trip level, which uses 59 terrain segments.

The first byte of the ZMAP data is actually a count of the number of terrain zones that this zonemap references, so the ZMAP data is:
  • 4 byte ZMAP label
  • 4 byte length (always 6409)
  • 1 byte terrain reference total count
  • 6400 bytes, representing the 80 x 80 zone map.

(In theory as a result of all this the largest possible level could run a straight line section of up to 80 x 640  meters, which is 51.2 kilometers, or about 32 miles a side.)

The terrain file to use with a particular map is held in the “ZONE” tagged data field in the level description, but it's usually generated with a matching name to the mission file, e.g. "t01.msn" uses "t01.ter".

Ensuring the orientation is correct is a bit fiddly – I fill in the height maps in pixmap order, and rotate them when assembling the complete map. Also the zone information runs with 0,0 in the bottom left hand corner rather than top right, requiring some final image map rotation to get the orientation "right".

However putting these together, and just using the 12 bit height field values then for something like the melee "crater" map this gets us:

Although we would have to extract more information to determine the road layout (e.g. the RDEF field) we can actually see most of the roads based on the additional information in the terrain map – the roads have different higher order flag bits set to the default terrain.

The bowl is pretty simple, with the dirt road running around the top edge:

And the first TRIP level looks like this:

Saturday, 13 August 2016

Interstate 76 and Vehicle Textures

The textures for the vehicles are handled a little unusually.

Basically there are two important file types involved here, a CBK and a VQM file.

The VQM file contains the texture image, and references to the CBK file.

Internally there is a 256 Colour look up table, used by all the game graphics:

The CBK file is actually a set of 16 pixel patterns, at 1 byte/pixel, and each pattern represents a 4 x 4 block of pixels.

The first value in the CBK file is the number of pattern entries in this file, and the remaining data is the list of patterns. Each pattern is a 16 byte entry, of 16 * 1 byte per pixel CLUT references which will be rendered as a 4 x 4 pixel block.

The VQM file references into the CBK file for patterns. The format of the VQM file is
  •     four byte image width
  •     four byte image height
  •     16 byte label, indicating the associated CBK

Then this is followed by pattern references. Each reference is two bytes, and there are actually two different types of references - one using the CBK and one not, based on the most significant bit of the two byte entry.

If the most significant bit is not set then this is a reference to a CBK pattern – fill the next 4x4 block of the output image with the pixel pattern at the CBK file index indicated by "this value & 0x7fff".

Else If the most significant bit is set then this is actually a Colour LUT reference – fill the next 4x4 block  of the output image with the solid colour indicated by the lower byte.

Given that the VQM has up to 15 bits for the pattern reference then in theory it can reference up to 32K patterns in a single CBK, but the largest value I've seen is 4K.

Running through this process filling in 4x4 blocks generates the output image. Other than being careful not to overrun the image boundaries if the dimension is not a 4 pixel multiple this is a straightforward process. The output needs to be flipped and rotated, but that's pretty much it.

I'm kind of surprised by this setup though -  it achieves a consistent 8:1 compression for the textures themselves (2 bytes describe 16 bytes worth of pixels), but it seems a relatively involved way of doing things over an off the shelf image compression scheme, or even RLE coding given the nature of the graphics.

Saturday, 6 August 2016

And a bit more I76 - Higher Resolution Geometry

Getting Slightly Better Models...

The higher resolution car meshes in i76 are distributed throughout the .geo files, each of which can hold a different section of the car.

To assemble the full model you need to select the right group of .geo files, extract the meshes and plug them together in the correct way.

The root definitions for these groupings are in the vehicle VDF files, and these files open with the "BWD2" signature.

A quick note on the BWD2 files

The "BWD2" files (vdf, cdf, wdf, etc) are all arranged in a Tag-Length-Data format.

The first four bytes are a simple tag string (e.g. “VDFC”, “VSHL”, “VGEO”)

The next four bytes are the length of this data field (including the tag). The shortest length here is 8 (the four byte tag and four byte length). The BWD2 marker itself and “EXIT” fields, which appear to be used as section delimiters in the files, have this length.

Following this is the data, formatted with a layout specific to the tag.

So the BWD2 Header is:
  •   4 Bytes BWD2
  •   4 Bytes Length of this field. This is always 8 (The tag & length only)

Then a revision tag follows, as:
  •   4 byte “REV “ tag
  •   4 byte Length, Always 12
  •   4 Byte of revision data (Always 8 in the version of i76 I have)

And the remaining fields vary between files. So in car definitions (vdf) the next label is (always?):
  •   4 byte “VDFC”
  •   4 byte Length, always 72 for the i76 files
  •   64 byte data – a car description

Putting together the cars

When assembling geometry we initially care about the VGEO fields. These tell us how the various .geo files are combined to form the car views.

So the VGEO field is formatted :
  •   4 byte header, which is “VGEO”
  •   4 byte length
  •   A single uint32_t (unknown?)

Then a number of 100 byte entries, each of which is:
  •   8 byte part label
  •   48 bytes, which are 12 four byte floats
  •   8 byte position root label
  •   36 bytes “unknown stuff”

Either the main or geometry label can be replaced with the string “NULL ” in which case this entry is empty.

The first label specifies the object name, which tells us the .geo file we should load for this piece of geometry.

The "position root label" specifies the thing that this item is positioned in relation to. For items which are placed with global co-ordinates then this is “WORLD”.

So for example the main car body is placed globally, i.e. has the position label “WORLD”, but the side mirror co-ordinates are offsets from the main car body itself, and have the position label "PP11BDYM".

The 12 floats give the offset co-ordinates to use. In the vehicle case the last three are interesting, since we can use them as “X” “Z” and “-Y” respectively to place imported objects in blender. The first 9 values are (always?) “1 0 0 0 1 0 0 0 1”  - I'd guess at a set of three scaling vectors which map 1:1 into X, Y & Z, but with uniform values it's hard to tell.

The Results

For the piranha from vppirnha.vdf then parsing the first 14 values in the VGEO section gives us the geometry files for a slightly higher resolution version of the car.

So we get this from the first VGEO sections:

VGEO: "PP11BDYM" "WORLD" "1 0 0 0 1 0 0 0 1 X:-5.32866e-05 Z:0.656801 -Y:-0.00405699 "
VGEO: "PP11BDYF" "WORLD" "1 0 0 0 1 0 0 0 1 X:-5.31375e-05 Z:0.645133 -Y:1.51002 "
VGEO: "PP11BDYB" "WORLD" "1 0 0 0 1 0 0 0 1 X:-4.19021e-05 Z:0.694423 -Y:-1.56466 "
VGEO: "PP11BDYT" "WORLD" "1 0 0 0 1 0 0 0 1 X:2.08616e-07 Z:1.17125 -Y:-0.341815 "
VGEO: "PP11BLGT" "WORLD" "1 0 0 0 1 0 0 0 1 X:-0.00209564 Z:0.853375 -Y:-2.32971 "
VGEO: "PP11BWLL" "PP11BDYB" "1 0 0 0 1 0 0 0 1 X:0.000834048 Z:-0.225637 -Y:0.263154 "
VGEO: "PP11FWLL" "PP11BDYF" "1 0 0 0 1 0 0 0 1 X:0.000631988 Z:-0.175059 -Y:-0.134667 "
VGEO: "PP11HLGT" "WORLD" "1 0 0 0 1 0 0 0 1 X:0.00434026 Z:0.716669 -Y:2.32806 "
VGEO: "PP11MIRL" "PP11BDYM" "1 0 0 0 1 0 0 0 1 X:-0.831573 Z:0.39666 -Y:0.452536 "
VGEO: "PP11MIRR" "PP11BDYM" "1 0 0 0 1 0 0 0 1 X:0.846213 Z:0.3966 -Y:0.452659 "
VGEO: "PP11PIPL" "PP11BDYM" "1 0 0 0 1 0 0 0 1 X:-0.727008 Z:-0.454351 -Y:-0.734841 "
VGEO: "PP11PIPR" "PP11BDYM" "1 0 0 0 1 0 0 0 1 X:0.718643 Z:-0.451539 -Y:-0.728832 "
VGEO: "PP11RTCL" "PP11BDYF" "1 0 0 0 1 0 0 0 1 X:-0.412791 Z:0.427997 -Y:-0.579116 "
VGEO: "PP11TLGT" "PP11HLGT" "1 0 0 0 1 0 0 0 1 X:-0.0064359 Z:0.136707 -Y:-4.65002 "

We can see mostly these are global co-ords (WORLD), but for example PP11BWLL, which is the back wheel well, is placed relative to PP11BDYB, the back body.

And converting those .geo files to obj and assembling the pieces into blender according to the values here gets....


I've tweaked the mirror positions, hidden the light emitters (which seem to hover away from the body) and everything is left/right transposed, but those are fairly minor issues – this looks good enough to be mostly correct.

There are a number of different resolution versions of the vehicle in the subsequent VGEO sections, presumably for LOD switching, and I suppose those details are hidden in the 36 bytes of "other" stuff that trail the geometry label, but that would take a bit more work to pull out.

Sunday, 31 July 2016

Revisiting Activision's Interstate 76

Interstate 76 – A blast from the past

Even for me this is a bit of a pointless exercise, but....

Years ago I used to play Interstate 76; a car combat game set in the 70's. Think classic American muscle cars, add guns, rockets and armour with a funk soundtrack and you're not far off.

The graphics engine was pretty ropey, even by the standards of the time, a re-purposed version of the Mechwarrior engine it was low poly and full of bugs, but the thing I76 had was style – the soundtrack, the voice acting, the single player storyline and cut scenes all fit together to give the game as a whole a wonderful feel. It was the kind of game you'd play over again and again just to listen to the soundtrack while driving around, and watch the cut-scenes play out.

Anyhow, digging around through some old disks I came across the original game, and decided to take a look at how the game data is packed on disk.

I told you this was pointless.

Background Reading

Most of the i76 stuff has dropped off the web, but the wayback machine has archived a few sites, and there's a couple of useful general links

Some initial references:
Hoppo's Custom Paint Job Guide (via the wayback machine)

Xentax formats information

A vehicle hacks site, from a geocities mirror. I never thought I'd miss geocities, but....

Also videoventure has information on Battlezone, which shares some engine details with I76 – useful for picking apart the geometry & map files

And similarly this github page has some code which gave me useful information on the geometry format

And since GoG re-released the original there seem to be some active games still running on

Top Level Stuff

The bulk of the game data is in two files:
  • I76.ZFS
These files are themselves containers for multiple files. I'm dealing with the original I-76 disks, however I've read that the I76 expansion pack (Nitro Riders) version of the ZFS file may use LZO compression – I'd have to dig out my copy to verify that though.
Data is stored in little endian format, so to read out a  32 byte integer we can use code like:

uint32_t getuInt32()
QByteArray ba = ReadData(4);
uint32_t v;
  uint8_t a,b,c,d;
   a =;
   b =;
   c =;
   d =;
  v = (a << 24) | (b << 16) | (c << 8) | d;
return v;
Taking each file in turn:

ZFS file

The file is divided into a number of “directory” sections, each of which contains a collection of files.

Each directory has a directory header which details the offset to the next directory header (the last directory entry has a '0' for this value).

Each directory header is followed by a number of file headers, each of which details the name of a file, the offset inside the ZFS and the size of the data. 
Following the file headers is the data for the files themselves, which continues up until the next directory header.

The file opens with the archive header, which details the version of the file and the number of files “per directory” as well as the total number of files in the archive.

So the layout is:
  Breaking down the individual sections further, the archive header has the following layout:

4 Byte Magic Ident (ZFSF)
uint32 version number
uint32 unknown
uint32 number of files in each directory
uint32 number of files in total
uint32 always NULL
uint32 unknown

And so we can parse this out from the beginning of the file by:

  ar.ident = getString(4);
  ar.version = getuInt32();
  ar.unknown_16 = getuInt32();
  ar.files_per_directory = getuInt32();
  ar.total_files = getuInt32();
  ar.null_marker = getuInt32();
  ar.unknown_28 = getuInt32();

Following this we know the number of files in each directory entry and the total number of files in the archive.

Immediately after this we find the first directory header – we can simply extract the next directory offset with

d.next_offset= getuInt32();

And then we pull out the file headers, based on the files_per_directory value we extracted from the archive header.

Each file header has the structure

16 Byte name
uint32 Offset to this file data (i.e. where in the datablock)
uint32 id
uint32 length
uint32 unknown
uint32 always NULL

And can be parsed with code like: = getString(16);
  fh.offset = getuInt32(); = getuInt32();
  fh.length = getuInt32();
  fh.unknown_3 = getuInt32();
  fh.null = getuInt32();

When we get to the end of the file (and run out of files to process) we'll start to pull out null data in these file header values, but the headers themselves will always be present.

So when we have pulled out the number of file header that files_per_directory count tells us to expect then we're at the data block and can start to load the file data using the offset and length details, and save it to the name given.

When all the files here have been loaded we can pick up the next directory from the next offset value we got from the directory header and repeat the process, until the next directory offset is zero.

MW2 File

This is considerably simpler. The opening 32 bit value from the file gives the count of entries in this file, and then for each file there follows the offset value to the data start.

Once collected then we can simply dump out each section of the MW2 file to an individual output file. In this case we simply have offset numbers, rather than names.

From the .MW2 file we get a few obvious audio clips from the in game menu operations as well as a bunch of alternate data, but the bulk of interesting data is in the ZFS.

What we get

We get a collection of audio clips, maps, car data, credits, etc.

Taking these in turn 

Audio Files

.wav: Audio clips – radio chatter, in game dialog and effects, Taurus' poems, other stuff. 

.gpw : Prefixed wav files. These are various in game effect noises with 28 bytes of prefix starting “GAS0” and padded with 0xff.

More Packed archive files

The ZFS contains yet more packed data files: ".pak" - really there are two files here, the index and the data file.

.pix: these describe the contents of each .pak in terms of file names & data offset

.pak: additional packed data – multiple files as indicated in the .pix

BWD2 Files

A number of other files are prefixed “BWD2” and have a standard 28 byte file header, which includes a character file type (up to 8 characters, of which the first three characters generally match the extensions):

File Extension
Misc component definitions
Per weapon definitions
Wheel type definition
Vehicle definitions
Vehicle Configuration File
Scrounge Texture
Vehicle paint (texture) file

Everything Else

There are also these other files

Palette (affects sky & surface)
Can be either a Sky or Surface texture or Level map (mission maps)
Opens with a pair of 32 bit dimension numbers, representing the size of the image followed by the image map.
text file; a list of car setups (one per trip episode ??)
Luma table
Translucency table
Colour Bank
Geometry File, describing vertices, normals and texture mapping
?? Four files which match available resolutions ??
Map file lists ??
Vehicle Image Textures. For the naming convention see Hoppo's page.
Objective file: Various mission text strings related to objectives
Image files
Opens with a pair of 32 bit dimension numbers (e.g. for 32K this is 640*480)
Then a 16 character name string “.CBK” - the colour index

That Geometry file

Although there isn't much information on this file format directly it's quite similar to several other Activision game data formats of the era, and specifically public information about “Battlezone 1” formats give us enough clues to pull out the geometry. Obviously there's some guesswork here though...

The initial layout of the file header is 

4 Char Magic Number (“GEO.”)
uint32 ??
16 char Name
uint32 Vertex Count
uint32 Face Count
uint32 ??

Next we have all the vertex values. We have 3x4 byte float values (X, Y & Z) per vertex, so this section of the file is 3 x 4 x “Vertex Count” bytes long

Next we have another set of 3x4 byte float values which are the vertex normals – again this covers 3 x 4 x “Vertex Count” bytes

Finally we have the faces. For each available face we have a face header of:

uint32 index
uint32 Face vertex count
byte Red
byte Green
byte Blue
(4 byte) float Surface normal X
(4 byte) float Surface normal Y
(4 byte) float Surface normal Z
(4 byte) float Surface normal W (?)
uint32 ??
byte Surface Flags ??
byte Surface Flags ??
byte Surface Flags ??
13 char Texture name for this object (Can be all NULL)
uint32 ??
uint32 ??

Then for each vertex in this face there are four 32bit fields. The first two are the Vertex and Normal reference, and the next two are float values for U & V mapping.

So, with a quick piece of hacky coding we can dump out the vertices, one XYZ triplet per line prefixed with 'v', and the faces, one face per line prefixed with 'f' (and incrementing the vertex refs so they run from '1' rather than '0') and the resulting output is something we can push into blender as a wavefront OBJ import.

And from “PP31BDYM.geo” we get the main body of the Picard Piranha in 229 verts, although blender reckons it it can actually drop 46 verts as duplicates.


And trying a couple of other models....

Slapping on the extracted textures and putting the various car pieces from the vdf and vcf files together to get a complete model is left as an exercise for the reader...

And just to make me feel old – I played this back in 1997, where the setting of 1976 seemed like ancient history. However from 1997 to 2016 is not far off that 21 year gap...

(Note: edited post to remove some.vqm file speculation that shouldn't have been there)