Skip to content

Instantly share code, notes, and snippets.

@jamesu
Last active August 30, 2026 14:11
Show Gist options
  • Select an option

  • Save jamesu/b0bd824b095b44229a9621022cfd36cf to your computer and use it in GitHub Desktop.

Select an option

Save jamesu/b0bd824b095b44229a9621022cfd36cf to your computer and use it in GitHub Desktop.
Vibed Red Baron Mac Model File Spec
#!/usr/bin/env python3
"""Red Baron (RB.EXE) VOLUME.NNN resource extractor.
Record format (LOCKED, verified against VOLUME.001..007):
[NAME]\0 [FLAGS k bytes] [u32le SIZE] [SIZE payload bytes]
- SIZE (4-byte little-endian) == exact payload byte length.
- k (0..8) is a per-record flag-prefix length; found by the rule that SIZE must carry
you to the next <NAME>\0 (SIZE 4-byte little-endian; for the final record, to EOF).
- Payloads may be self-delimiting tagged blocks: [TAG "XXXX:"][u24le size][u8 flag][data].
"""
import sys, re
NAME_CHARS = set(range(0x20, 0x7F))
def is_name(b, pos):
"""Return length(incl NUL) of a filename at b[pos], else None."""
q = pos
while q < len(b) and b[q] in NAME_CHARS:
q += 1
n = b[pos:q]
if 1 < len(n) < 16 and q < len(b) and b[q] == 0 and re.search(rb'\.[A-Za-z0-9]{1,4}$', n):
return len(n) + 1
return None
def walk(vol):
"""Return [(name, k, size, payload)] for the true record chain."""
out, pos = [], 0
while pos < len(vol):
nl = is_name(vol, pos)
if nl is None:
break
name = vol[pos:pos + nl - 1].decode('latin1')
body = pos + nl
choice = None
for k in range(0, 9):
if body + k + 4 > len(vol):
break
size = int.from_bytes(vol[body + k:body + k + 4], 'little')
nxt = body + k + 4 + size
if size <= 0 or nxt > len(vol):
continue
if is_name(vol, nxt) is not None: # lands on the next NAME
choice = (k, size, nxt)
break
if choice is None:
# terminal record: its SIZE extends to EOF (use k=0, payload = remainder)
out.append((name, 0, len(vol) - body - 4, vol[body + 4:]))
break
k, size, nxt = choice
out.append((name, k, size, vol[body + k + 4:nxt]))
pos = nxt
return out
TAGS = (b'FNT:', b'BMP:', b'PAL:', b'TTM:', b'SNG:', b'SSM:', b'SCR:', b'OVL:', b'VER:', b'BIN:')
def parse_blocks(data):
"""Recursively walk tagged blocks -> [(tag, size24, flag, payload)]."""
out, rest = [], data
while len(rest) >= 8 and rest[:4] in TAGS:
tag = rest[:4]
size = int.from_bytes(rest[4:7], 'little')
flag = rest[7]
payload = rest[8:8 + size]
out.append((tag.decode('latin1', 'replace'), size, flag, payload))
rest = rest[8 + size:]
return out
def main(path, outdir):
import os
os.makedirs(outdir, exist_ok=True)
vol = open(path, 'rb').read()
total = 0
for name, k, size, payload in walk(vol):
safe = re.sub(r'[^A-Za-z0-9_.-]', '_', name)
with open(os.path.join(outdir, safe), 'wb') as f:
f.write(payload)
tags = [t for t, _, _, _ in parse_blocks(payload)]
print(f" {name:16s} k={k} size={size:6d} -> {'raw' if not tags else '+'.join(tags)}")
total += 1
print(f"wrote {total} resources -> {outdir}")
if __name__ == '__main__':
if len(sys.argv) < 2:
print("usage: extract_volume.py VOLUME.NNN [outdir]"); sys.exit(1)
main(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else 'extracted_' + sys.argv[1].split('/')[-1])

Red Baron DOS model format — caveman specification

This document explains the aircraft model files in very simple words. It was generated with AI, so excuse the waffle.

The important rule is simple:

TBL root
  -> detail levels
    -> parts
      -> point pool + frames (primitive variants)
        -> polygon records + selector lists

For consistent export naming, call the raw seven-byte variant entries frames at the model level. The raw field names variant_count and variant_offset remain in the file-layout description because they describe the actual DOS structure. The exporter name is:

detail_XX_part_XXX_frame_XXX

frame is the most specific component: one selected primitive descriptor for one part. This name does not by itself claim that the frame is an animation pose; it may represent a control-surface state, damage state, material/state choice, or another runtime alternative.

Do not scan the file looking for things that resemble faces. The counts and offsets in the hierarchy identify the structures.

1. Files

For an aircraft model, the useful primary file is:

<AIRCRAFT>.TBL

Examples include SPAD_13.TBL, SNIPE.TBL, CAMEL.TBL, and SE5.TBL.

The matching .CLG file is not currently required to locate or decode the TBL geometry. It contains six count/offset lists and is probably involved in colour or material customization, but treating its offsets as the primary geometry directory is not supported by the executable evidence.

Some .TBL files are not aircraft meshes. SMOKE.TBL, EXPLO.TBL, and other effects use the same general shape system but may contain only bitmap primitives. PTPLANE.TBL, terrain files, and miscellaneous resources should not automatically be parsed as aircraft models.

2. Integer encoding

Aircraft TBL integer fields are little-endian at the DOS file boundary:

u16(x) = x[0] | (x[1] << 8)
i16(x) = signed little-endian 16-bit value

The point coordinates are three consecutive signed 16-bit words, six bytes per point:

struct Point {
    int16_t x;
    int16_t y;
    int16_t z;
};                                      /* 6 bytes */

The DOS renderer loads these words directly with ordinary 8086 word loads. No endian swap is performed in the point-consumer path.

3. Root header

The first word of an ordinary aircraft TBL is the file offset of the shape header. It is normally 0x0008:

TBL + 0x00:  u16 root_offset       usually 0x0008
TBL + 0x02:  u16                  unknown/header linkage
TBL + 0x04:  u16                  unknown/header linkage
TBL + 0x06:  u16                  unknown/header linkage

At TBL + root_offset is a 16-byte root header. Only its final two words have been assigned confidently:

struct DosShapeRoot {
    uint8_t  unknown_00[12];
    uint16_t detail_count;       /* +0x0c */
    uint16_t detail_offset;      /* +0x0e, absolute TBL offset */
};                              /* 16 bytes */

detail_offset is a file offset, not an offset relative to the root header. It points to detail_count consecutive six-byte detail records.

Do not assume that every file with a .TBL extension uses this aircraft root. Validate the root and the referenced ranges before decoding.

4. Detail / LOD records

Each detail record is six bytes:

struct DosDetail {
    uint16_t threshold;       /* +0, used during projected-size selection */
    uint8_t  flags;           /* +2, bit 0x40 enables depth sorting */
    uint8_t  part_count;      /* +3 */
    uint16_t part_offset;     /* +4, absolute TBL offset */
};                            /* 6 bytes */

The renderer walks the detail records and chooses one according to projected object size/distance. A normal aircraft commonly has four detail levels, but this is data-driven. SMOKE.TBL, for example, has twelve.

The detail records do not contain complete meshes themselves. They point to part arrays. An exporter should normally export every detail level as a separate named object or file, because combining all LODs produces overlapping geometry.

5. Part records

Each detail points to an array of ten-byte part records:

struct DosPart {
    int16_t  animation_offset; /* +0, -1 means static */
    uint8_t  point_selector;   /* +2, runtime positioning/state input */
    uint8_t  point_count;      /* +3 */
    uint16_t point_pool;       /* +4, absolute TBL offset */
    uint16_t variant_count;    /* +6 */
    uint16_t variant_offset;   /* +8, absolute TBL offset */
};                              /* 10 bytes */

The part's point pool contains point_count six-byte points:

point_address = TBL + part.point_pool + selector * 6;

The selector is local to the part. There is no single global coordinate base for the aircraft. Using one global base is the main cause of the extreme spikes produced when a part-local point base is ignored.

6. Frames / primitive variants

Each part has variant_count seven-byte frame descriptors. In raw DOS terminology these are primitive variants:

struct DosPrimitiveVariant {
    uint8_t type;             /* +0: 0, 1, 2, or 3 */
    uint8_t payload[6];       /* +1..+6, type-specific */
};                            /* 7 bytes */

The variant address is:

variant = TBL + part.variant_offset + variant_number * 7;

Variant selection is runtime-controlled:

if (part.animation_offset == -1)
    variant_number = 0;
else
    variant_number = runtime_animation_byte(part.animation_offset)
                      % part.variant_count;

This is how one part can have alternate geometry states. They may be flap or rudder poses, but may also be material, depth, detail, or other state variants. The file structure supports variants; identifying the exact gameplay meaning requires the animation state that the game supplies at runtime.

7. Type 0: polygon-list primitive

Type 0 is the normal polygon geometry path. Its payload is:

struct DosPolygonBatchPayload {
    uint16_t polygon_count;   /* variant +1 */
    uint16_t polygon_offset;  /* variant +3, absolute TBL offset */
    uint8_t  unknown_05;      /* variant +5 */
    uint8_t  unknown_06;      /* variant +6 */
};                            /* 6 payload bytes */

The polygon list contains polygon_count eight-byte records:

struct DosPolygonRecord {
    uint8_t  flags;            /* +0, culling/render mode bits */
    uint8_t  unknown_01[4];    /* +1..+4 */
    uint8_t  material_state;   /* +5, used by facing/material logic */
    uint16_t selector_offset;  /* +6, absolute TBL offset */
};                             /* 8 bytes */

At selector_offset is a variable-length selector list terminated by 0xff. The terminator is not a point selector:

selector_0 selector_1 ... selector_n ff

Each selector is resolved against the current part's point pool:

for (p = selector_offset; TBL[p] != 0xff; ++p) {
    uint8_t selector = TBL[p];
    Point point = read_i16_xyz(TBL + part.point_pool + selector * 6);
}

The selector-list arity is variable. Lists with three or more selectors can be exported as OBJ faces. Two-selector records are strongly associated with the 0x80 auxiliary/wire records in aircraft data and can be exported as OBJ lines, but the final classification should retain the original polygon flags until all flag branches are documented.

The executable uses the polygon-record flag byte for culling/render behavior. Observed important bits include:

bit 0x80   changes a renderer flag
bit 0x20   enables an additional facing test
low 2 bits select handling/culling behavior

The remaining bytes should be preserved in any intermediate representation. Do not discard them merely because OBJ cannot represent their meaning.

8. Type 1: point-centred sphere/circle primitive

Type 1 is not a polygon batch. The executable reads its payload as a size, point selector, and colour/material information, then calls the circle/sphere raster path:

variant +1..+2   16-bit size/radius-like value
variant +3       point selector
variant +4..+5   colour/material bytes
variant +6       remaining type-specific byte

OBJ cannot represent this directly. A converter may either record it in comments, approximate it with a generated sphere, or export it to a separate primitive format.

9. Types 2 and 3: bitmap primitives

Types 2 and 3 resolve runtime images and therefore do not describe ordinary polygon faces:

variant +1..+2   signed screen offset fields
variant +3..+4   image/runtime-image reference
variant +5       point selector for the point-anchored form
variant +6       remaining type-specific byte

Both use the bitmap drawing path. Type 3 additionally transforms a part-local point before drawing. Exact field names should remain provisional until the image lookup and final raster call are fully traced.

10. Scale and coordinate transforms

The point values are raw signed 16-bit coordinates. The renderer applies a runtime transform and shift after loading them. The executable proves the coordinate width, stride, signedness, and endian order, but the complete aircraft-specific world scale is not yet fixed as a file constant.

For initial OBJ inspection, /256 is a useful provisional display scale:

obj_x = raw_x / 256.0;
obj_y = raw_y / 256.0;
obj_z = raw_z / 256.0;

This is an export convention, not a claim that the TBL stores a literal per-file divisor of 256. Keep a command-line scale option and preserve raw coordinates in metadata.

The renderer's camera transform also determines the final screen axes. A raw orthographic X/Y, X/Z, or Y/Z plot is useful for diagnosis but does not prove which axis is “up” in the game.

11. Normals, colours, and materials

No per-vertex normal data has been identified. The point representation is only three signed 16-bit coordinates.

Polygon records contain flag/material/state bytes, and CLG contains six lists that are likely involved in colour/material assignment. The exact palette and shading rule is not yet documented. A basic exporter should preserve:

polygon flags
polygon byte +5
type-0 variant bytes +5/+6
type-1 colour/material bytes
CLG group/list provenance

Do not invent OBJ materials from these fields until their executable consumer is known.

12. Basic exporter algorithm

The minimum geometry exporter can be written as:

data = read_file(tbl);
root = u16le(data + 0);

header = data + root;
detail_count  = u16le(header + 0x0c);
detail_offset = u16le(header + 0x0e);

for (detail = 0; detail < detail_count; ++detail) {
    d = data + detail_offset + detail * 6;
    part_count  = d[3];
    part_offset = u16le(d + 4);

    for (part_no = 0; part_no < part_count; ++part_no) {
        p = data + part_offset + part_no * 10;
        point_pool   = u16le(p + 4);
        point_count  = p[3];
        variant_count = u16le(p + 6);
        variant_base  = u16le(p + 8);

        for (variant_no = 0; variant_no < variant_count; ++variant_no) {
            v = data + variant_base + variant_no * 7;
            if (v[0] != 0)
                continue;             /* record sphere/bitmap metadata */

            polygon_count  = u16le(v + 1);
            polygon_offset = u16le(v + 3);
            for (i = 0; i < polygon_count; ++i) {
                r = data + polygon_offset + i * 8;
                selectors = read_until_ff(data + u16le(r + 6));
                emit_obj_face_or_line(selectors, point_pool, point_count);
            }
        }
    }
}

When writing inspection objects, use the shared naming hierarchy:

detail_XX_part_XXX_frame_XXX

Here frame is variant_no. The name is only an unambiguous export label; the executable's runtime state decides which frame is visible. Export all frame numbers rather than only the currently selected one.

All offsets in this pseudocode are file offsets. Validate every count and range before dereferencing it. Never substitute a discovered/scanned offset when a declared offset is invalid; report the file as a different or malformed resource instead.

13. SPAD example

The beginning of SPAD_13.TBL demonstrates the structure:

TBL +0x0000: root offset = 0x0008
root +0x000c: detail count = 4
root +0x000e: detail offset = 0x0018

detail  threshold  flags  parts  part offset
0       3          0x40   6      0x0030
1       9          0x40   8      0x013e
2       25         0x40   14     0x0335
3       255        0x40   30     0x0944

The first part at 0x0030 is:

ff ff 00 11 6c 00 01 00 d2 00

It is static, has 17 points at 0x006c, and has one variant at 0x00d2. The variant and its polygon record are:

variant @0x00d2: 00 01 00 d9 00 05 a5
record  @0x00d9: 80 ce ce 13 13 ff e1 00
list    @0x00e1: 01 02 ff

The selector values 1 and 2 resolve as:

point_1 = TBL + 0x006c + 1 * 6
point_2 = TBL + 0x006c + 2 * 6

Because this list has two selectors and flag 0x80, a diagnostic exporter will normally represent it as an OBJ line while retaining the raw flag and offset metadata.

14. What this specification does not yet claim

The following remain incomplete:

  • exact meanings of root-header bytes +0x00..+0x0b;
  • complete names for polygon-record bytes +1..+4;
  • exact winding/back-face rules;
  • definitive mapping of every flag to face, line, or other raster mode;
  • exact runtime scale for each aircraft/view;
  • OBJ representation of spheres and bitmaps;
  • palette/material assignment through CLG;
  • the gameplay value that selects a particular animated variant.

Those gaps do not prevent a basic polygon exporter. They do prevent claiming that an OBJ file reproduces every primitive and every frame rendered by the original game. A complete exporter should still emit every frame, including non-polygon primitive types, even when it cannot represent those types as OBJ geometry.

15. Executable evidence pointers

The main DOS evidence is in PS.EXE:

0x37afa   shape/detail/part/variant geometry dispatch
0x37865   detail selection
0x37dd9   part and primitive-variant traversal
0x30fa5   type-0 polygon-list handler
0x318cf   part-local selector * 6 coordinate resolver
0x370f1   type-1 sphere/circle primitive
0x372e7   type-2 bitmap primitive
0x373e8   type-3 point-anchored bitmap primitive

The expanded evidence and byte-level examples are recorded in disasm/DOS_SHAPE_LOADER_TRACE.md.

Red Baron Mac Model Files — Caveman Spec

This document explains the aircraft model files in very simple words. It was generated with AI, so excuse the waffle.

The short version:

CLG file = list of places to look
TBL file = the actual packed model data
DAT chunk inside TBL = model package
point data = 16-bit X/Y/Z numbers
draw data = small records that say what to draw
0xff = end of a point-index list

Some parts are proven by reading the Mac executable. Some parts are only strong file-pattern evidence. Those two cases are labelled separately.

Loader wording: direct read versus runtime fixup

“Loaded straight into memory” means the Mac loader reads the resource bytes into an allocated buffer; it does not mean the buffer remains byte-for-byte unchanged afterward. The executable proves one immediate mutation:

resource read into buffer       helper 0x0624a9 / copy 0x0622b1
        -> leading u32 offsets  ts_fileload 0x062551..0x06256f
        -> add buffer base in place
        -> zero-terminated pointer list used by ts_init

No whole-file endian swap, general decoder, or mesh compiler is shown in this path. Later routines can make additional narrow runtime mutations (for example CLG group tags), so distinguish raw file bytes from the live buffer.

This does not mean the renderer treats the entire disk file as one C structure. There are two layers:

disk resource bytes
    -> copied resource buffer
    -> relocated pointer fields in that buffer
    -> runtime shape/object descriptors and renderer state

The first arrow and pointer fixups are proven. Runtime draw-object state is a separate layer, but the detail, part, and draw records discussed below are consumed in place from the relocated DAT buffer. “Direct-loaded” still does not mean that the entire tagged TBL wrapper is one C structure: the DAT payload is selected, pointer fields are relocated, and load_clg performs narrow byte patches.

Frame and cell representation

The Mac DAT payload contains per-part alternate frames, also called cells in some source material. Their loaded-root layout is:

root +4/+6                number of state channels and their cell counts
root +8/+a                number and address of detail records
detail +2/+4              part count and first 14-byte part offset
part +0                   state channel used by this part
part +6                   alternate-record count/period for this part
part +8                   root-relative alternate-record table offset
root + part[+8] + frame*8  selected draw record

ts_draw_parts, sort_parts, and build_sort_list consume these records directly from the relocated DAT buffer. There is no vertex-count step and no separate list of frame offsets. A part with part[+6] == 1 has one draw state; a part with part[+6] == 2 has two adjacent eight-byte draw records. The engine does not store a separate total of “animated parts”: it walks every part in the selected detail, and each part declares its own period.

Across the examined aircraft, every part whose period is 2 points to two different command/point-selector sets: CAMEL 9/9, PFALZ 10/10, SNIPE 11/11, SOPPUP 9/9, SOPTRI 8/8, and SPAD 15/15. These are genuine alternate geometry descriptions, not merely duplicate colour records.

There is also a higher-level whole-shape state mechanism:

several separate shape resources loaded
  -> separate runtime shape-table indices
  -> object[0] chooses the active shape

In convert_to_hulk, the executable sets a damage flag and increments object[0]. ts_get_shp_ptr then uses that value to select a runtime shape entry. This is the executable-backed explanation for damaged meshes such as the V-shaped wings observed in DOS exports.

object[0] is not damage-only. aim_plane also sets visual-state flags and increments the same word. The field is therefore a model/visual-state shape selector. Damage is one confirmed producer; it is not a damage counter or a sequence-cell number.

damage_zepp independently performs the same damage-state transition: it sets bit 0x40 in object +0x22 and increments object +0. This confirms that the selector behavior is shared by multiple damage handlers.

The runtime has linked objects too. set_shp_version creates runtime objects for version values 0..5 and stores the value at object +0x26; the value is used as an actor/type mask, not proven to be a mesh frame. aim_plane obtains linked objects from parent +0x0e and changes each object's +0 selector. This makes independently selected per-part state plausible, but does not prove that those linked objects are aircraft surfaces.

The whole-shape selector and per-part frame selector are separate mechanisms. A damage effect may use a whole replacement shape, a per-part frame, or both.

The renderer's relevant traversal is:

root detail directory
  -> one 6-byte detail record by runtime depth
  -> 0x0e-byte runtime part entries
  -> per-part state byte selects an 8-byte draw record
  -> draw polygon, bitmap, or other primitive

The six-byte records are alternate detail/distance versions of the root's part list. They are not animation poses. After that selection, the executable uses a runtime part descriptor whose channel byte indexes a separate object-owned state table; its value is reduced by that part's variant bound and selects an 8-byte draw record. This is a proven alternate-record mechanism. The serialized entries are directly located by the detail count and 0x0e stride; their b00/w06/w08 fields supply the channel, alternate bound, and draw-table offset used by the runtime part. The source-level meaning of each channel remains unresolved.

Individual polygon or auxiliary records inside one selected part are draw records, not frames. A separate root selector can replace the whole visual object, as demonstrated by the damage/hulk path.

The Mac root's sequence table gives one byte-sized cell count per channel. The shape API queries those bounds, and generic object update routines advance the object-owned current-cell bytes against them. Across the aircraft files, the root counts and part channels agree: CAMEL, SNIPE, SOPPUP, and SOPTRI use [2,2]; PFALZ uses [2,2,1]; SPAD uses [2,2,2]. Only the gameplay names of channels 0, 1, and 2 remain unresolved, so do not name one “flap”, “rudder”, or “damage” solely from the file. Format/export terminology:

  • DOS format: each primitive variant is represented as a frame for export.
  • Mac format: each per-part frame contains its polygon, line/auxiliary, bitmap, and other draw records.
  • A common broad-to-specific export name is: detail_XX_part_XXX_frame_XXX. A Mac frame can contain many draw records; a DOS frame contains one seven-byte primitive descriptor.
  • Mac per-part cell storage and selection are decoded: root sequence bounds, object current-cell bytes, part channels, per-part bounds, and 8-byte draw records. Only the aircraft-specific semantic name of each channel remains open.

File structs versus runtime structs

This document uses two kinds of C-like struct:

FILE struct     bytes stored in TBL/CLG/DAT
RUNTIME struct  bytes/objects made in RAM by the executable

They are not automatically interchangeable. A runtime pointer is not a file offset, and a file entry must not be cast directly to a runtime struct unless the loader proves that it does exactly that. C syntax here describes layout; it is not a claim that the original source code used these exact names.

The current executable-backed runtime reference is disasm/MAC_RUNTIME_CONSOLIDATED.md, section 3. It defines MacRuntimePart (0x0e-byte entries), MacRuntimeDetail (6-byte depth records), and MacRuntimeDrawRecord (8-byte alternate draw records). The serialized 14-byte MacRawPartEntry is documented separately there and must remain separate until the loader constructor is fully recovered.

1. What files are needed?

For one aircraft, use the matching pair:

SPAD.TBL
SPAD.CLG

The names must match. PFALZ.TBL goes with PFALZ.CLG, and so on.

The small aircraft SPAD.DAT files are aircraft settings. They are not the main shape package used by the proven model renderer. TTM files are text/page resources. They are not needed for reading the model geometry.

2. Byte order

The Mac 68000 CPU uses big-endian numbers.

For model data, read normal 16-bit and 32-bit numbers like this:

// bytes = the byte array being decoded.
// p = byte offset of the signed 16-bit value inside that array.
uint16_t n = (bytes[p] << 8) | bytes[p + 1];

For signed coordinates:

// bytes = the model byte array; p = the coordinate's byte offset.
int16_t x = read_big_endian_i16(bytes + p);

Important exception: the size of a TBL tagged chunk is a 3-byte little-endian number. Do not use one byte order for every part of the file.

3. TBL file: a box containing chunks

Important loading fact from the Mac executable: the resource payload is read into one allocated buffer without a general-purpose unpack or endian- conversion pass. The loader then walks the leading 32-bit relative-pointer chain and adds the buffer address to those pointer fields in place. The bytes are available in the live buffer until later code interprets or mutates them. Therefore direct loading is established, but it does not by itself prove that every renderer record is an in-place disk record; the executable also has a separate runtime representation.

There is one proven mutation after this direct load: load_clg resolves each CLG target and writes its group number into target bytes +1 and +2 in the loaded TBL buffer. So “direct-loaded” does not mean “never modified”; it means there is no general model unpacker between disk and memory. Any format notes must distinguish raw file bytes from this post-load state.

The TBL is not just one long vertex list. It is a sequence of tagged chunks.

Each chunk starts like this:

struct FileTblChunkHeader {
    char tag[4];       // for example "MAP:", "GID:", or "DAT:"
    uint8_t size_le[3];// 3-byte little-endian payload size
    uint8_t flags;     // not assigned a useful model meaning yet
    // size bytes of payload follow
};

The next chunk starts after:

8 bytes of header + size bytes of payload

The important chunk is DAT:. The CLG offsets point into the DAT: payload, not into the TBL file from byte zero.

MAP: and GID: exist, but their complete model meaning is not established. Keep them when copying a file; do not treat them as vertices.

4. CLG file: six groups of offsets

The CLG says where interesting pieces are inside the TBL DAT: payload.

struct FileClg {
    uint16_t group_count[6];       // six big-endian counts
    uint16_t offset[sum_of_counts];// big-endian offsets
};

There are exactly:

12 + (2 * sum(group_count)) bytes

The first count belongs to group 0. The next count belongs to group 1, and so on. After the six counts come all the offsets, in group order.

Example:

counts = [2, 1, 0, 2, 0, 1]
offsets = [A, B, C, D, E, F]

group 0 uses [A, B]
group 1 uses [C]
group 2 uses []
group 3 uses [D, E]
group 4 uses []
group 5 uses [F]

An offset is a location in DAT:. It is not automatically a vertex number. It can point at a group header, a part, or another model record.

5. DAT beginning: pointer list and root

The Mac shape loader expects the beginning of its loaded data to contain a list of relative 32-bit pointers:

uint32_t relative_pointer[];

The list ends at a zero pointer. The executable adds the loaded buffer address to each pointer in this leading list.

The aircraft packages examined have this simple beginning:

DAT + 0: 00 00 00 08   pointer to DAT+8
DAT + 4: 00 00 00 00   end of pointer list
DAT + 8:                root object begins here

The root is therefore normally at DAT+8.

6. FILE / LOADED BUFFER: root header

The root begins at the first relocated DAT pointer (normally DAT+8). Its header fields are native big-endian values on 68k:

struct FileRootHead {
    uint16_t unk_00;       // meaning not proven
    uint16_t unk_02;       // meaning not proven
    uint16_t seq_count;    // +4: number of state/frame channels
    uint16_t seq_off;      // +6: root-relative u8 frame-count table
    uint16_t detail_count; // +8: number of 6-byte detail records
    uint16_t detail_off;   // +a: root-relative detail-record array
    uint16_t radius_raw;   // +c: radius/size value used by shape code
};

The common aircraft value is detail_count == 4 and detail_off == 0x0e. The four detail thresholds are 3, 9, 25, and 255. The value 255 is a threshold, not a directory terminator.

struct FileDetail {
    uint16_t depth_threshold;  // +0
    uint16_t part_count;       // +2
    uint16_t first_part_off;   // +4, root-relative
};

For each detail, the target contains:

part_count consecutive 14-byte part entries

The executable compares runtime depth directly with these thresholds, so they are detail/LOD records. Preserve the actual threshold values rather than assuming a modern renderer's LOD numbering or distance units.

7. FILE / LOADED BUFFER: 14-byte part entries

These are the part descriptors consumed directly by build_sort_list:

struct FilePart14 {
    uint8_t  state_channel;   // +0: object state-table index; ff = none
    uint8_t  byte_01;         // +1: unknown/material byte
    uint8_t  byte_02;         // +2: unknown/material byte
    uint8_t  detail_state;    // +3: copied into renderer detail/shift state
    uint16_t point_off;       // +4: root-relative point base
    uint16_t cell_period;     // +6: number/period of alternate draw records
    uint16_t draw_off;        // +8: root-relative alternate-record base
    uint16_t unk_0a;          // +a: validity/sort-related use; exact meaning open
    uint16_t size_metric;     // +c: shifted and used in distance/size tests
};

point_off and draw_off are offsets from the selected root, not offsets from the part and not absolute host pointers. Since the normal aircraft root is at DAT+8, a file viewer converts one to a DAT payload offset with 8 + field_value. The engine instead adds the relocated root address.

8. POINT DATA: file candidate and runtime form

The executable's runtime point lookup is definite:

point = runtime_part_base + point_base + selector * 6

Each point is three signed 16-bit big-endian words:

struct Point3 {
    int16_t x;
    int16_t y;
    int16_t z;
};

Point3 is the six-byte XYZ payload in both places. In the FILE it is found through a package point-target candidate. In RUNTIME it is found through runtime_part_base + point_base + selector * 6. The bytes are the same kind of record; the surrounding address calculation is different.

The game uses fixed-point coordinates. In the normal scale:

real_x = x / 256.0
real_y = y / 256.0
real_z = z / 256.0

The point stride is 6 bytes. It is not 12 bytes. The 12-byte records seen in the executable are runtime pointer/scale table entries, not three-dimensional points.

In the raw package, a repeated pattern is:

[8-byte point prefix] [Point3] [Point3] [Point3] ...

Starting the candidate point array after that 8-byte prefix gives the best cross-aircraft alignment found so far. The executable now proves the root-relative part-list bridge and the direct 14-byte part descriptors, but it still does not prove that this candidate point-prefix interpretation is the exact point-base calculation for every raw package.

8.1 Worked point-coordinate examples

All examples in this section are invented. They show the arithmetic only.

Suppose a 14-byte entry contains this point target:

point_off = 0x0120

The observed raw layout has an 8-byte prefix at that target, so the candidate point-array start is:

point_base_candidate = point_off + 8
                     = 0x0120 + 0x0008
                     = 0x0128

The executable's point stride is 6 bytes. Therefore selector 3 points at:

address(selector 3) = point_base + (3 * 6)
                    = 0x0128 + 18
                    = 0x013a

At 0x013a, imagine the six bytes are:

01 80  FE 00  00 C0

Split them into three big-endian signed words:

x raw = 0x0180 = 384
y raw = 0xfe00 = -512
z raw = 0x00c0 = 192

Divide each by 256:

x =  384 / 256 =  1.5
y = -512 / 256 = -2.0
z =  192 / 256 =  0.75

So selector 3 gives this model point:

point[3] = (1.5, -2.0, 0.75)

The same calculation in C-like pseudocode is:

// data = start of the loaded DAT/package byte buffer.
// point_base = point-array start found from the selected part.
// selector = one byte read from a 0xff-terminated draw stream.
int p = point_base + selector * 6;
int16_t x = be_i16(data + p + 0);
int16_t y = be_i16(data + p + 2);
int16_t z = be_i16(data + p + 4);

float X = x / 256.0f;
float Y = y / 256.0f;
float Z = z / 256.0f;

Do not read the words little-endian. The same bytes read little-endian would give completely different values:

01 80 -> 0x8001 -> -32767     (wrong X)
FE 00 -> 0x00fe -> 254        (wrong Y)
00 C0 -> 0xc000 -> -16384    (wrong Z)

That kind of byte reversal creates the large spikes seen in bad previews.

8.2 Several points and a polygon

Suppose the point array begins at 0x0128 and the selector stream is:

03 04 07 ff

The addresses are:

selector 3 -> 0x0128 + (3 * 6) = 0x013a
selector 4 -> 0x0128 + (4 * 6) = 0x0140
selector 7 -> 0x0128 + (7 * 6) = 0x0152

Read six bytes at each address, decode each as signed big-endian XYZ, divide by 256, then draw:

polygon(point[3], point[4], point[7])

The ff byte is not a point and must not be looked up.

For a line stream:

02 09 ff

the calculation is:

start = point_base + (2 * 6)
end   = point_base + (9 * 6)
draw a segment from point[2] to point[9]

The line and polygon use the same coordinate arithmetic. Only the final primitive differs.

8.3 Raw offset versus runtime address

There are two different kinds of address. Keep them separate:

raw package candidate:
    DAT offset = point_off + 8 + selector * 6

runtime renderer:
    RAM address = runtime_part_base + point_base + selector * 6

The first formula describes the repeated package pattern found in the files. The second formula is the one directly shown by the executable. The loader may copy, unwrap, or relocate the raw point block before the renderer uses it. Never add a raw DAT offset to a runtime pointer unless the loader has explicitly established that relationship.

9. FILE: draw-data group header

A 14-byte root entry's draw_off does not point directly at the group header. The observed package layout is:

draw_off target
    -> 8-byte draw prefix/envelope
    -> FileDrawGroupHead at draw_off + 8
    -> group anchor points at the first FileRawRecord8

So if:

draw_off = 0x0120

then the FileDrawGroupHead starts at:

0x0120 + 8 = 0x0128

The group header is:

struct FileDrawGroupHead {
    uint32_t count;        // big-endian observed count
    uint16_t anchor;       // big-endian child/record target
    uint16_t unk_06;       // always zero in examined headers; padding assumed
};

The count normally means how many linked 8-byte records belong to this group. The anchor tells the reader where to begin looking for those records.

9.1 Fake draw-data layout

This is an invented example. It shows locations and relationships, not real aircraft bytes:

DAT+0120: 11 22 33 44 55 66 77 88   draw prefix/envelope

DAT+0128: 00 00 00 02 01 38 00 00   FileDrawGroupHead
          |---------| |----| |----|
          count=2    anchor=0x0138  zero/padding

DAT+0138: a1 ce ce 10 10 ff 01 48   FileRawRecord8 #0
DAT+0140: <not part of this example's chain>
DAT+0148: a1 ce ce 10 10 ff 01 60   FileRawRecord8 #1

DAT+0168: 03 04 07 ff               selector stream

Read it like this:

root_entry.draw_off = 0x0120
group_head = DAT + 0x0120 + 8 = DAT + 0x0128
group count = 2
first record = DAT + 0x0138
second record = DAT + 0x0148

For the second record, its link is 0x0160, so the selector stream starts at:

DAT + 0x0160 + 8 = DAT + 0x0168

The stream is then:

03 04 07 ff

which selects points 3, 4, and 7 and ends at ff.

This is a structure like:

[count] [anchor] [count linked 8-byte records] [selector streams]

The records are found by following the anchor and each record's link; they are not required to sit immediately after the group header. The exact special rules for every anchor variant are not fully proven. Keep the original offsets when decoding.

10. FILE: eight-byte raw records

Many linked raw records are 8 bytes long:

struct FileRawRecord8 {
    uint8_t  type;         // 80, 81, a0, or a1 in observed raw data
    uint8_t  byte_01;      // unknown raw attribute
    uint8_t  byte_02;      // unknown raw attribute
    uint8_t  byte_03;      // unknown parameter
    uint8_t  byte_04;      // unknown parameter
    uint8_t  byte_05;      // unknown flag/attribute
    uint16_t link;          // big-endian link to more DAT data
};

The final link has a repeatable raw-file rule:

stream_address = link + 8

The stream then continues until the first 0xff byte.

The useful file-level distinction is:

80/81 records = auxiliary segment records
a0/a1 records = surface/polygon records

This is a role in the model package, not the runtime dispatch byte. The runtime renderer receives translated records, so raw 80/81 must not be mistaken for a runtime draw_type value.

11. Selector streams

Once a linked stream has been found, read it as bytes:

uint8_t selector_stream[];

The format is:

[selector] [selector] [selector] ... 0xff

0xff ends the stream. Every value from 0x00 through 0xfe is allowed as a selector. Do not treat 0x80 as an opcode after entering a stream.

The runtime polygon path uses a selector like this:

selector 0 -> point 0
selector 4 -> point 4
selector 9 -> point 9
0xff       -> stop

The executable supports data-driven polygons. The stream can describe a triangle, quad, or longer polygon. It is not always exactly three points.

11.1 How polygons are drawn

For a normal surface stream, the game does this:

1. Read selector 0.
2. Find point 0 in the current part's point pool.
3. Read selector 1.
4. Find point 1.
5. Keep doing this until 0xff.
6. Send all found points to the polygon drawing path.

The first point connects to the second, the second to the third, and so on. The final point connects back to the first point because a polygon is closed.

Examples:

04 07 09 ff        triangle: points 4, 7, 9
02 03 08 06 ff     quad: points 2, 3, 8, 6
01 02 03 04 05 ff  five-sided polygon

The executable does not require every polygon to have three points. The number of points comes from the stream length. The point coordinates are transformed, clipped, and then sent to the polygon renderer. The record's colour/material state is handled separately.

The following is only an example of how another program might write the result. It is not part of the Red Baron file format:

f 1 2 3
f 4 5 6 7

The important model rule is simply “draw one closed polygon through the selected points”. Any modern renderer may represent that polygon differently.

11.2 How auxiliary lines are drawn

The 80/81 records are the model's auxiliary pieces. They are used for thin parts such as wires, struts, braces, and other pieces that are not normal filled surfaces.

Their basis is not a hidden line coordinate. It is the same point pool used by the polygon records:

80/81 record
    -> record.link + 8
    -> selector bytes until ff
    -> point_base + selector * 6
    -> two selected XYZ points
    -> draw a segment between those points

For the normal auxiliary case the stream has two selectors:

07 12 ff

Meaning:

get point 7
get point 12
draw one line from point 7 to point 12

The line gets its colour/material state from the associated record and CLG group state, just as surface records do. It is therefore a real model part, not an unconnected pair of coordinates. Its endpoints move whenever the selected part/point set changes.

If an auxiliary stream contains more than two selectors, keep all selectors and mark the exact primitive interpretation as unresolved; the proven two-selector case is the segment case.

The low-level rasterizer name is not required to describe the model relationship: the two selected points provide the line endpoints.

For comparison, a two-selector stream can be written by another program as a segment example:

segment(point_7, point_12)

This notation is only an example and is not file syntax.

11.3 Maximum polygon size

The executable does not contain a clear instruction saying “a polygon may have N points”. The polygon loop at 0x05e69f reads selector bytes until it sees 0xff. It appends one point for every byte and does not compare the point count against a visible maximum.

Therefore:

polygon point count = number of selector bytes before 0xff

The selector itself is one byte. Values 0x00..0xfe select points, and 0xff is reserved as the end marker. This means the renderer can directly name at most 255 different point numbers, but that is not the same as a 255-point polygon limit: a stream could repeat a selector.

The renderer has fixed 256-entry lookup tables indexed by selector * 2, which strongly suggests that point numbers are limited to byte values. The per-polygon append loop has no visible overflow check, however. A stream much longer than the normal model data could overrun renderer workspace rather than being cleanly rejected.

Safe decoder rule:

accept any stream length found in the file
stop only at 0xff
do not invent a triangle/quad limit
do not assume streams longer than the renderer's workspace are safe to draw

12. RUNTIME: shape table

The executable creates a separate runtime table. This is not the same thing as the raw 14-byte disk entry.

struct RuntimeShapeSlot12 {
    void    *parts;        // pointer to part-pointer array
    int16_t  selector;     // runtime selector
    uint16_t scale_x;      // initialized to 0x0100
    uint16_t scale_y;      // initialized to 0x0100
    uint16_t unused;       // initialized to 0xffff
};

The 0x0100 values mean 256 in fixed-point terms. They are initialization values in this runtime table. They do not prove that every raw model uses one fixed scale or that they are raw file offsets.

13. RUNTIME: part record

The renderer uses a part descriptor like this:

struct RuntimePart {
    uint8_t  state_table_index; // +0: object state-table slot; 0xff = none
    uint8_t  unk_01;            // +1: not consumed by traced selector
    uint8_t  unk_02;            // +2: not consumed by traced selector
    uint8_t  detail;            // +3: detail/material state input
    uint16_t point_base;        // +4: point-pool base component
    uint16_t variant_period;    // +6: alternate-record reduction bound
    uint16_t draw_record_off;   // +8: alternate 8-byte draw-record base
    uint16_t unk_0a;            // +a: not assigned by traced selector
    uint16_t size;              // +c: size/distance input
}; 

The root's six-byte detail records are clear:

struct RuntimeDetail {
    uint16_t depth_limit;   // compared with runtime depth
    uint16_t part_count;    // number of contiguous 14-byte part records
    uint16_t first_part_off;// first part record, relative to root
};

So the runtime shape is roughly:

root + 0x08/+0x0a -> RuntimeDetail array
RuntimeDetail     -> contiguous 14-byte part records at first_part_off
part offset       -> root + offset -> RuntimePart (0x0e bytes)
RuntimePart       -> root + draw_record_off + frame * 8

The root detail list and the per-part alternate draw-record table are different things. The former chooses distance/LOD; the latter is where the Mac per-part frame selector operates. “Cell” is the older/source terminology for the state value; “frame” is the exporter/documentation terminology.

For SPAD, this linkage can be checked directly in the file. The root detail directory contains the detail record (threshold=25, part_count=14, first_part_off=0x0372). With the root at DAT+8, the first part is at root + 0x0372; subsequent parts are at +0x0e, +0x1c, and so on. The same construction occurs for the other detail records and their observed part counts. This confirms the root-relative contiguous part-array linkage; it does not require or contain an intermediate list of 16-bit part offsets.

13.1 Choosing a detail level

The renderer keeps shared state while drawing. In C-like form, the relevant pieces can be represented as one global context:

typedef unsigned char byte;

struct RenderState {
    byte *runtime_base;      // loaded runtime shape-buffer base
    int   camera_distance;   // made from aircraft and camera positions
    int   runtime_depth;     // depth/order value; executable: a5+$ffffe832
    int   detail_state;      // selected 0..3 state
};

struct RenderState g_render;

These names are for explanation. The executable stores the values in separate globals, but they act like shared renderer state.

The game does not always draw every available detail list. It calculates runtime size/depth state and then chooses one root detail record:

Then it compares runtime size/depth values and chooses one of the detail records. The exact surrounding thresholds depend on the current view, so this is intentionally simplified pseudocode:

// root = the selected loaded shape root.
RuntimeDetail *choose_detail(byte *root)
{
    uint16_t count = read_native_u16(root + 0x08);
    RuntimeDetail *list = (RuntimeDetail *)
        (root + read_native_u16(root + 0x0a));

    // These tests stand for the executable's distance/clip comparisons.
    for (uint16_t n = 0; n < count; ++n) {
        if (g_render.runtime_depth <= list[n].depth_limit)
            return &list[n];
    }
    return &list[count - 1];
}

The important point is that detail is selected at runtime. A file may contain several versions of an aircraft part, and an external reader should retain all of them instead of merging their points into one giant cloud.

The raw directory keys 3, 9, 25, and 0xff are the values used by the detail-selection data. Keep both the raw threshold and the runtime detail number; do not turn the threshold into a guessed distance unit.

13.2 Choosing parts and frames

Simplified runtime control flow looks like this:

// RUNTIME/LOADED BUFFER. root is selected from the shape-table entry.
// The root's +8/+a fields describe its six-byte detail directory.
void draw_aircraft(DrawObject *obj)
{
    byte *root = select_root(obj->shape_state);
    RuntimeDetail *detail = choose_detail(root); // six bytes, root-relative

    for (int i = 0; i < detail->part_count; ++i) {
        uint16_t rel = detail->first_part_off + i * 0x0e;
        RuntimePart *part = (RuntimePart *)(root + rel);
        draw_part(root, part);
    }
}

The root + rel operation is executable-proven. ts_draw_parts selects the root, chooses one six-byte detail record using runtime depth, and passes that record's count and first offset to sort_parts. sort_parts advances the offset by 0x0e for each part before consuming the entry. There is no intermediate 16-bit offset list in this path.

The real executable also performs sorting and material-index adjustment. The short version of the depth choice is:

RuntimeDetail *choose_detail(byte *root)
{
    uint16_t count = read_native_u16(root + 0x08);
    byte *list = root + read_native_u16(root + 0x0a);

    for (int n = 0; n < count; ++n) {
        RuntimeDetail *detail = (RuntimeDetail *)(list + n * 6);
        if (g_render.runtime_depth <= detail->depth_threshold)
            return detail;
    }

    return (RuntimeDetail *)(list + (count - 1) * 6);
}

After the detail record is selected, each part performs a separate state/frame selection:

void draw_part(byte *root, RuntimePart *part)
{
    uint16_t frame = 0;
    if (part->state_table_index != 0xff) {
        frame = state_values[part->state_table_index];
        while (frame >= part->variant_period)
            frame -= part->variant_period;
    }

    RuntimeDrawRecord *rec = (RuntimeDrawRecord *)
        (root + part->draw_record_offset + frame * 8);
    draw_one_record(rec, part);
}

This is the Mac executable's actual per-part frame mechanism: state_values[channel], reduced by the part's bound, selects one 8-byte draw record. The executable proves the mechanism, but not the semantic name of each channel. A channel may represent a material, bitmap, control-surface pose, or damage cell. The serialized inputs are part[+0], part[+6], and part[+8]; they are read directly from the loaded DAT part record.

Across the available aircraft, variant_period is always 1 in the key-3 and key-9 detail groups. Period-2 entries appear only in the closer key-25 and key-255 groups. This is evidence that alternate frames are tied to higher-detail representations; it is not proof that every period-2 part is a flap or damage part.

A draw record then chooses the primitive:

void draw_one_record(RuntimeDrawRecord *rec, RuntimePart *part, int detail)
{
    // rec = one 8-byte runtime draw record from the selected frame.
    // part/detail = the already-selected runtime context for this draw.
    switch (rec->draw_type) {
    case 0:
        draw_polygon_or_clip_path(((byte *)rec) + 2, part, detail);
        break;
    case 1:
        draw_bitmap(((byte *)rec) + 2, part, detail);
        break;
    default:
        draw_transformed_point_or_other(((byte *)rec) + 2, part, detail);
        break;
    }
}

The resulting hierarchy is:

aircraft
  -> selected detail
      -> selected part
          -> selected per-part frame
              -> runtime draw records
                  -> polygon, line/auxiliary, bitmap, or other primitive

This is runtime pseudocode. It is not a claim that the raw 14-byte entry can be cast directly to RuntimePart.

14. RUNTIME: draw records

The renderer finally sees 8-byte-spaced runtime draw records:

struct RuntimeDrawRecord {
    uint8_t  draw_type;
    uint8_t  unk_01;       // skipped by dispatcher
    union {
        uint8_t raw[6];    // always fills the rest of the 8-byte slot
        // bitmap/polygon/other payload layouts go here
    } payload;
};

Here “8-byte-spaced” means a fixed table stride, not a unit conversion:

record 0 starts at base + (0 * 8)
record 1 starts at base + (1 * 8)
record 2 starts at base + (2 * 8)

So the runtime table reserves an 8-byte slot for every draw record. The slots are the same size, but their contents are not used the same way: the first byte selects the path, byte +1 is skipped, and bytes +2..+7 are interpreted according to that path. A path may use only some of those bytes or use them as an index/offset to more data. This runtime 8-byte table must also not be confused with the separate raw 8-byte TBL records.

This is best understood as a tagged union:

struct RuntimeDrawSlot {
    uint8_t kind;           // selects which payload layout is active
    uint8_t unknown_01;     // present in the slot; dispatcher skips it
    union {
        uint8_t bitmap[6];
        uint8_t polygon[6];
        uint8_t other[6];
    } data;
};

The names inside the union are explanatory. The executable proves the fixed slot size and the tag dispatch, but not every individual payload layout. There is no record-level end bit. The 0xff byte belongs to a separate selector stream and tells that stream to stop.

The first byte chooses the path:

draw_type == 0  -> internal polygon/clip path
draw_type == 1  -> bitmap drawing path
other           -> point-transform/other path

The raw TBL types 80/81/a0/a1 are not these runtime 0/1/other values. Something loads or translates the package before this dispatch happens.

The renderer does not read these fields from the runtime part record:

RuntimePart +0x01..+0x02
RuntimePart +0x0e..+0x13
RuntimeDrawRecord +0x01

They remain unknown or opaque. Do not label them as normals or scale.

15. Bitmap records

The bitmap path receives a primitive payload, not a raw TBL record. It uses fields inside that payload for a point selector, size/position information, and colour/state information. It then selects an image/frame from nested angle tables and calls the bitmap drawing routine.

The executable therefore proves that some model pieces can be bitmaps placed at model points. It does not prove the complete meaning of every bitmap payload byte because the final imported bitmap routine is outside the model code traced here.

16. Normals and lighting

No per-vertex normal format has been proven.

The known point path reads three coordinate words per point. It does not read an additional normal vector. Colour/material bytes definitely exist, but the full polygon lighting rule is still unknown.

17. Detail levels and moving parts

17.0 Frame and draw-record preservation

The DOS renderer proves that a part can contain multiple primitive variants, selected at runtime. The Mac aircraft payloads show the same design at a different serialization level: four repeated detail thresholds (3, 9, 25, 255) and matching part counts occur in the Mac TBL payloads. Each detail record then addresses a contiguous array of 14-byte part entries.

Each 14-byte part entry contains a per-part frame count/bound at +6 and an 8-byte draw-record table offset at +8. A decoder should preserve every frame. It is not safe to export only the first frame for each part and call it the complete aircraft.

Individual polygon draw records inside a part's draw group are not frames. For example, the 34 records in the relevant CAMEL group are individual polygons forming one part, rather than 34 alternate poses. An export may name the containing objects detail_##_part_##_frame_##; the individual records remain draw commands inside that object.

Paired DOS/Mac files have matching detail-part counts for CAMEL (3,5,11,22), PFALZ (1,6,14,24), SNIPE (3,5,11,23), SOPPUP (3,5,11,21), and SOPTRI (1,7,13,23). SPAD is nearly identical (6,8,14,30 DOS versus 6,8,14,29 Mac). The matching counts strongly support a common aircraft hierarchy; they do not prove that individual polygon records are animation frames.

The shared structure is supplemented by a proven Mac per-part selector: the part's +0 byte indexes the object state table, its +6 word bounds the value, and its +8 word locates an 8-byte draw-record table. The unresolved issue is the semantic identity of each channel. Use state/frame for the selected alternate record and draw record for an individual primitive record; do not call every polygon record a frame.

The runtime selects among multiple parts and per-part frames. It also has four runtime detail states based on distance/size tests.

This hierarchy accounts for several point clouds and draw groups in one aircraft file. They may represent:

near model
far model
bitmap part
fuselage part
flap/rudder part
wheel or gear part

The executable does not prove that the disk directory keys directly mean these names. It does prove that the renderer can select different runtime parts and different draw lists.

For moving flaps, the safest possibilities are separate parts, prebuilt position variants, or a different shape object selected by animation code. The traced renderer does not show a per-flap rotation matrix.

18. Fake annotated example

This example is invented. The offsets and values are only here to show the shape of the data.

Fake CLG

0000: 0002 0001 0000 0001 0000 0001   six counts
000c: 0020 0040                       group 0 has two targets
0010: 0080                             group 1 has one target
0012: 00c0                             group 3 has one target
0014: 0100                             group 5 has one target

Meaning:

group 0 -> DAT offsets 0x0020, 0x0040
group 1 -> DAT offset  0x0080
group 2 -> nothing
group 3 -> DAT offset  0x00c0
group 4 -> nothing
group 5 -> DAT offset  0x0100

Fake DAT root

DAT+0000: 00000008              relative pointer -> DAT+8
DAT+0004: 00000000              pointer-list end
DAT+0008: 0000 0000 0004 0010   root header
DAT+0010: 0004 0003 0020        root directory: kind 4
DAT+0016: 0003 0002 0040        root directory: kind 3, 2 entries
DAT+001c: 0009 0001 0060        root directory: kind 9, 1 entry
DAT+0022: 00ff 0000 0000        last directory record

The kind-3 directory says:

2 entries, each 14 bytes
DAT+0040: 12 34 ff 02  00a0 0000 0120 0000 0000   entry 0
DAT+004e: 12 35 ff 03  00a0 0000 0140 0000 0000   entry 1

Read the first entry as:

byte 0..3 = package state bytes; not fully named
word +4    = point-data target candidate 0x00a0
word +6    = zero; presumed padding
word +8    = draw-data target candidate 0x0120
word +a    = zero; presumed padding
word +c    = unknown word, here zero

Fake point block

DAT+00a0: 0000 0100 0000 0000   8-byte point prefix
DAT+00a8: 0100 0000 0080        point 0: (1.0, 0.0, 0.5)
DAT+00ae: ff00 0000 0080        point 1: (-0.0039, 0.0, 0.5)

The point bytes are signed big-endian integers divided by 256.

Fake draw block and stream

DAT+0120: 0000 0001 0130 0000   draw-group header
DAT+0128: a0 ce ce 10 10 ff 01 40 raw linked record
DAT+0130: 0000 0000 0000 0000   example stream envelope/prefix
DAT+0138: 00 01 00 ff            selector stream: point 0, point 1, point 0

The important stream part is:

00 01 00 ff

That means:

use point 0
use point 1
use point 0
stop

The final ff is not a point. It ends the stream.

19. Safe decoder recipe

1. Read TBL chunks.
2. Extract the DAT payload.
3. Read the CLG as six big-endian counts plus big-endian offsets.
4. Treat each CLG offset as a DAT location, not automatically as a vertex.
5. Follow group headers and 8-byte linked records.
6. For a linked record, inspect the stream at link + 8.
7. Read stream bytes until 0xff.
8. Treat stream bytes as point selectors.
9. Read candidate points as signed big-endian XYZ words, 6 bytes each.
10. Keep every section, part, frame, and draw stream separate.
11. Do not merge all point clouds into one aircraft.
12. Do not call unknown bytes normals, opcodes, or coordinates without proof.

The most important warning is this:

Raw package records and runtime renderer records are related,
but they are not proven to be the same C struct in the same place.
#!/usr/bin/env python3
"""Export DOS Red Baron TBL geometry using the PS.EXE table hierarchy.
This exporter intentionally does not scan for records and does not use CLG to
discover geometry. Offsets and counts are taken from the TBL itself:
root -> detail entries -> part entries -> variants -> polygon records
Only type-0 polygon primitives are representable as native OBJ geometry.
Type-1 spheres and type-2/3 bitmap primitives are reported in the OBJ
comments and skipped as surface geometry.
"""
from __future__ import annotations
import argparse
import struct
from pathlib import Path
def u16(data: bytes, offset: int) -> int:
return struct.unpack_from("<H", data, offset)[0]
def i16(data: bytes, offset: int) -> int:
return struct.unpack_from("<h", data, offset)[0]
def checked_range(data: bytes, offset: int, size: int, label: str) -> None:
if offset < 0 or size < 0 or offset + size > len(data):
raise ValueError(f"{label} outside file: 0x{offset:x}+0x{size:x}")
def parse(tbl: Path):
data = tbl.read_bytes()
checked_range(data, 0, 2, "root pointer")
root = u16(data, 0)
checked_range(data, root, 16, "shape header")
detail_count = u16(data, root + 0x0c)
detail_offset = u16(data, root + 0x0e)
checked_range(data, detail_offset, detail_count * 6, "detail array")
details = []
for detail_index in range(detail_count):
at = detail_offset + detail_index * 6
threshold = u16(data, at)
flags = data[at + 2]
part_count = data[at + 3]
part_offset = u16(data, at + 4)
checked_range(data, part_offset, part_count * 10,
f"detail {detail_index} part array")
parts = []
for part_index in range(part_count):
p = part_offset + part_index * 10
animation_offset = i16(data, p)
point_selector = data[p + 2]
point_count = data[p + 3]
point_pool_offset = u16(data, p + 4)
variant_count = u16(data, p + 6)
variant_offset = u16(data, p + 8)
checked_range(data, point_pool_offset, point_count * 6,
f"detail {detail_index} part {part_index} point pool")
checked_range(data, variant_offset, variant_count * 7,
f"detail {detail_index} part {part_index} variants")
parts.append({
"animation_offset": animation_offset,
"point_selector": point_selector,
"point_count": point_count,
"point_pool_offset": point_pool_offset,
"variant_count": variant_count,
"variant_offset": variant_offset,
})
details.append({
"threshold": threshold,
"flags": flags,
"part_offset": part_offset,
"parts": parts,
})
return data, root, details
def selector_stream(data: bytes, offset: int):
if offset >= len(data):
raise ValueError(f"selector stream outside file: 0x{offset:x}")
end = data.find(b"\xff", offset)
if end < 0:
raise ValueError(f"unterminated selector stream: 0x{offset:x}")
return list(data[offset:end])
def export(tbl: Path, out: Path, scale: float) -> tuple[int, int, int, int, int]:
data, root, details = parse(tbl)
lines = [
f"# Structured DOS TBL export: {tbl.name}",
"# Hierarchy: root -> details -> parts -> variants -> polygon records",
"# Coordinates: signed little-endian i16 XYZ from each part-local pool",
f"# Coordinate scale: {scale:g}",
f"# root=0x{root:04x} details={len(details)}",
]
vertex_count = face_count = line_count = skipped = 0
vertex_ids = {}
def vertex(part, selector):
nonlocal vertex_count
key = (part["point_pool_offset"], selector)
if key in vertex_ids:
return vertex_ids[key]
if selector >= part["point_count"]:
raise ValueError(f"selector {selector} exceeds point count "
f"{part['point_count']}")
at = part["point_pool_offset"] + selector * 6
xyz = (i16(data, at), i16(data, at + 2), i16(data, at + 4))
vertex_count += 1
vertex_ids[key] = vertex_count
lines.append("v %.6f %.6f %.6f # pool=0x%04x selector=0x%02x" %
(xyz[0] * scale, xyz[1] * scale, xyz[2] * scale,
part["point_pool_offset"], selector))
return vertex_count
for detail_index, detail in enumerate(details):
for part_index, part in enumerate(detail["parts"]):
for variant_index in range(part["variant_count"]):
at = part["variant_offset"] + variant_index * 7
primitive_type = data[at]
name = (f"detail_{detail_index:02d}_part_{part_index:03d}_"
f"frame_{variant_index:03d}")
lines.append(f"o {name}")
lines.append(
"# threshold=%d flags=0x%02x part_offset=0x%04x "
"point_pool=0x%04x point_count=%d animation_offset=%d" %
(detail["threshold"], detail["flags"], detail["part_offset"],
part["point_pool_offset"], part["point_count"],
part["animation_offset"]))
lines.append(f"# frame_variant={variant_index} variant_offset=0x{at:04x} type={primitive_type}")
if primitive_type != 0:
skipped += 1
kind = {1: "sphere", 2: "bitmap", 3: "point_bitmap"}.get(
primitive_type, "unknown")
lines.append(f"# skipped non-OBJ primitive: {kind}")
continue
polygon_count = u16(data, at + 1)
polygon_offset = u16(data, at + 3)
checked_range(data, polygon_offset, polygon_count * 8,
f"variant 0x{at:x} polygon list")
lines.append(
f"# polygon_count={polygon_count} polygon_offset=0x{polygon_offset:04x}")
for polygon_index in range(polygon_count):
p = polygon_offset + polygon_index * 8
flags = data[p]
selectors_offset = u16(data, p + 6)
selectors = selector_stream(data, selectors_offset)
if len(selectors) < 2:
skipped += 1
continue
indices = [vertex(part, selector) for selector in selectors]
lines.append(
f"# polygon={polygon_index} record=0x{p:04x} "
f"flags=0x{flags:02x} selectors=" +
" ".join(f"0x{x:02x}" for x in selectors))
if len(indices) == 2:
lines.append("l " + " ".join(map(str, indices)))
line_count += 1
else:
lines.append("f " + " ".join(map(str, indices)))
face_count += 1
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text("\n".join(lines) + "\n")
return len(details), vertex_count, face_count, line_count, skipped
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("tbl", type=Path)
ap.add_argument("-o", "--out", type=Path, required=True)
ap.add_argument("--scale", type=float, default=1.0 / 256.0,
help="scale raw XYZ values for OBJ (default: 1/256)")
args = ap.parse_args()
try:
details, vertices, faces, lines, skipped = export(
args.tbl, args.out, args.scale)
except (OSError, ValueError, struct.error) as exc:
ap.error(str(exc))
print(f"{args.tbl.name}: details={details} vertices={vertices} "
f"faces={faces} lines={lines} skipped={skipped} -> {args.out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Self-contained Mac Red Baron package-to-OBJ diagnostic exporter.
This file intentionally imports no project modules. It contains the small
amount of TBL/DAT/CLG parsing needed by the package exporter, including the
detail/part/frame/draw names, filters, and metadata report.
"""
from __future__ import annotations
import argparse
import struct
from pathlib import Path
RAW_TYPES = {0x80, 0x81, 0xA0, 0xA1}
def be16(data, off):
return int.from_bytes(data[off:off + 2], "big")
def tbl_dat(path):
raw = Path(path).read_bytes()
q = 0
while q + 8 <= len(raw):
tag = raw[q:q + 4]
size = int.from_bytes(raw[q + 4:q + 7], "little")
end = q + 8 + size
if end > len(raw):
raise ValueError("truncated TBL chunk")
if tag == b"DAT:":
return raw[q + 8:end]
q = end
raise ValueError("TBL has no DAT chunk")
def validate_clg(path):
"""Validate the matching six-group CLG framing and byte order."""
raw = Path(path).read_bytes()
if len(raw) < 12:
raise ValueError("CLG is shorter than its six group counts")
counts = struct.unpack_from(">6H", raw, 0)
expected = 12 + 2 * sum(counts)
if len(raw) != expected:
raise ValueError("CLG size does not match its six big-endian counts")
def raw_record(data, off):
if off < 0 or off + 8 > len(data) or data[off] not in RAW_TYPES:
return None
return {"offset": off, "type": data[off],
"bytes": data[off:off + 8].hex(" "),
"link": be16(data, off + 6)}
def streams(data, records):
out = []
seen = set()
for rec in records:
start = rec["link"] + 8
if not 0 <= start < len(data):
continue
end = data.find(b"\xff", start)
if end < 0 or (start, end) in seen:
continue
seen.add((start, end))
out.append({"source_record": rec["offset"],
"record_type": "0x%02x" % rec["type"],
"offset": start, "values": list(data[start:end]),
"terminator_offset": end})
return out
def is_group(data, off):
return 0 <= off and off + 8 <= len(data) and data[off:off + 3] == b"\0\0\0"
def raw_group(data, off):
"""Read the observed group-header/linked-record forms."""
if not is_group(data, off):
return None
count = be16(data, off + 2)
anchor = be16(data, off + 4)
if anchor != off and (is_group(data, anchor) or
raw_record(data, anchor) is not None):
child = anchor
else:
child = off + 8
# Most groups are a contiguous record chain. Some encode one initial
# record plus count more records, so try that form first.
for amount, layout in ((count + 1, "initial_plus_count_contiguous"),
(count, "contiguous")):
if amount <= 0:
continue
recs = [raw_record(data, child + i * 8) for i in range(amount)]
if all(recs):
return {"count": count, "anchor": anchor, "child": child,
"records": recs, "record_layout": layout,
"streams": streams(data, recs)}
# Another observed form has one record whose link points back to itself,
# followed by a terminated inline stream and then the remaining records.
first = raw_record(data, child)
if count and first is not None and first["link"] == child:
end = data.find(b"\xff", child + 8)
after = len(data) if end < 0 else end + 1
if after < len(data) and data[after] == 0:
after += 1
trailing = [raw_record(data, after + i * 8) for i in range(count)]
if end >= 0 and all(trailing):
recs = [first] + trailing
return {"count": count, "anchor": anchor, "child": child,
"records": recs,
"record_layout": "initial_inline_plus_count_contiguous",
"streams": streams(data, recs)}
# Otherwise follow links. Stop when the declared count is reached.
recs = []
q = child
seen = set()
for _ in range(count + 1):
if q in seen:
break
seen.add(q)
rec = raw_record(data, q)
if rec is None:
break
recs.append(rec)
nxt = rec["link"]
if len(recs) >= count:
break
if nxt == q:
end = data.find(b"\xff", q + 8)
q = len(data) if end < 0 else end + 1
if q < len(data) and data[q] == 0:
q += 1
else:
q = nxt
if not recs:
return None
return {"count": count, "anchor": anchor, "child": child,
"records": recs, "record_layout": "linked_or_inline",
"streams": streams(data, recs)}
def group_for_target(data, target):
"""Resolve a draw target using declared offsets only.
There is deliberately no nearby-byte search here. The executable-style
walk uses the known target, the fixed +8 envelope, the group anchor, the
declared count, and each record's link.
"""
group = raw_group(data, target + 8)
if group is not None:
return group
return None
def frame_streams(data, entry, root, frame, include_auxiliary):
"""Read one executable-selected per-part frame from the loaded DAT."""
period = entry["cell_period"]
draw_base = root + entry["draw_target"]
if frame < 0 or frame >= period or draw_base + frame * 8 + 8 > len(data):
return None, []
draw_off = draw_base + frame * 8
if data[draw_off] != 0:
return draw_off, []
count = be16(data, draw_off + 2)
command_base = root + be16(data, draw_off + 4)
result = []
for command_number in range(count):
command_off = command_base + command_number * 8
if command_off + 8 > len(data):
break
command_type = data[command_off]
start = root + be16(data, command_off + 6)
if not 0 <= start < len(data):
continue
end = data.find(b"\xff", start)
if end < 0 or (not include_auxiliary and command_type not in (0xa0, 0xa1)):
continue
result.append({"frame": frame, "draw_offset": draw_off,
"command_number": command_number,
"source_record": command_off,
"record_type": "0x%02x" % command_type,
"offset": start, "values": list(data[start:end])})
return draw_off, result
def package_entries(data):
"""Read the repeated root directory and its 14-byte entries."""
if len(data) < 16 or int.from_bytes(data[0:4], "big") != 8:
raise ValueError("DAT does not begin with the expected root pointer")
root = 8
entries_by_detail = []
detail_count = be16(data, root + 8)
detail_offset = be16(data, root + 0xa)
q = root + detail_offset
descriptors = []
for _ in range(detail_count):
if q + 6 > len(data):
break
kind = be16(data, q)
count = be16(data, q + 2)
descriptors.append((kind, count, root + be16(data, q + 4)))
q += 6
for kind, count, target in descriptors:
if target < 0 or target + count * 14 > len(data):
continue
entries = []
for index in range(count):
off = target + index * 14
words = [be16(data, off + i) for i in range(0, 14, 2)]
entries.append({"index": index, "offset": off,
"state_channel": data[off],
"point_target": words[2],
"cell_period": words[3],
"draw_target": words[4]})
entries_by_detail.append({"raw_key": kind, "entries": entries})
return entries_by_detail
def point_values(data, base, selectors):
if not selectors or base < 0:
return None
end = base + 6 * max(selectors) + 6
if end > len(data):
return None
return [struct.unpack_from(">hhh", data, base + 6 * value)
for value in selectors]
def selected_streams(data, entry, include_aux):
group = group_for_target(data, entry["draw_target"])
if group is None:
return None, []
result = streams(data, group["records"])
if not include_aux:
result = [s for s in result if s["record_type"] in ("0xa0", "0xa1")]
return group, result
def metadata(data, sections, include_aux):
lines = ["Model metadata", "===============",
"detail levels: %d" % len(sections)]
for detail, section in enumerate(sections):
lines.append("detail %d (raw key 0x%02x): %d parts" %
(detail, section["raw_key"], len(section["entries"])))
for part, entry in enumerate(section["entries"]):
lines.append(" part %d: %d frames, channel=%d, point=0x%04x frame_table=0x%04x" %
(part, entry["cell_period"], entry["state_channel"],
entry["point_target"], entry["draw_target"]))
for frame in range(entry["cell_period"]):
draw_off, frame_items = frame_streams(data, entry, 8, frame,
include_aux)
lines.append(" frame %d: draw=0x%04x commands=%d" %
(frame, draw_off or 0, len(frame_items)))
for draw, stream in enumerate(frame_items):
lines.append(" draw %d: type=%s selectors=%d source=0x%04x stream=0x%04x" %
(draw, stream["record_type"], len(stream["values"]),
stream["source_record"], stream["offset"]))
return lines
def main():
ap = argparse.ArgumentParser()
ap.add_argument("tbl", type=Path)
ap.add_argument("--clg", type=Path,
help="matching CLG; accepted for validating the pair")
ap.add_argument("-o", "--out", type=Path)
ap.add_argument("--scale", type=float, default=256.0)
ap.add_argument("--point-header", type=int, default=8)
ap.add_argument("--include-auxiliary", action="store_true")
ap.add_argument("--section-key", type=lambda value: int(value, 0))
ap.add_argument("--detail-level", "--detail", type=int)
ap.add_argument("--part-number", "--part", type=int)
ap.add_argument("--subpart-number", "--subpart", "--draw-record",
dest="subpart_number", type=int,
help="export only this zero-based draw command within each frame")
ap.add_argument("--frame-number", "--frame", type=int,
help="export only this zero-based per-part frame")
ap.add_argument("--metadata", action="store_true")
args = ap.parse_args()
clg = args.clg or args.tbl.with_suffix(".CLG")
if not clg.exists():
raise ValueError("CLG file does not exist: %s" % clg)
validate_clg(clg)
data = tbl_dat(args.tbl)
sections = package_entries(data)
if args.metadata:
print("\n".join(metadata(data, sections, args.include_auxiliary)))
print()
lines = ["# Red Baron Mac package export",
"# coordinates: signed big-endian i16 XYZ, runtime scale / %g" % args.scale,
"# object name: detail_<level>_part_<number>_frame_<number>",
"# frame is a selectable per-part visual state; its draw commands follow"]
vcount = fcount = lcount = ocount = skipped = 0
for detail, section in enumerate(sections):
if args.detail_level is not None and detail != args.detail_level:
continue
if args.section_key is not None and section["raw_key"] != args.section_key:
continue
for part, entry in enumerate(section["entries"]):
if args.part_number is not None and part != args.part_number:
continue
base = entry["point_target"] + args.point_header
for frame in range(entry["cell_period"]):
if args.frame_number is not None and frame != args.frame_number:
continue
draw_off, frame_items = frame_streams(
data, entry, 8, frame, args.include_auxiliary)
if draw_off is None:
skipped += 1
continue
geometry = []
for draw, stream in enumerate(frame_items):
if args.subpart_number is not None and draw != args.subpart_number:
continue
values = tuple(stream["values"])
if len(values) < (2 if args.include_auxiliary else 3):
continue
xyz = point_values(data, base, values)
if xyz is None:
skipped += 1
continue
geometry.append((draw, stream, xyz, values))
if not geometry:
continue
name = "detail_%02d_part_%03d_frame_%03d" % (
detail, part, frame)
lines.append("o " + name)
lines.append("# detail=%d raw_section_key=0x%02x part=%d frame=%d draw_commands=%d point_base=0x%04x" %
(detail, section["raw_key"], part, frame,
len(geometry), base))
for draw, stream, xyz, values in geometry:
lines.append("# draw=%d record_type=%s record=0x%04x stream=0x%04x selectors=%s" %
(draw, stream["record_type"], stream["source_record"],
stream["offset"], ",".join("%02x" % value for value in values)))
for x, y, z in xyz:
lines.append("v %.6f %.6f %.6f" %
(x / args.scale, y / args.scale, z / args.scale))
first = vcount + 1
indices = [str(first + i) for i in range(len(xyz))]
if len(indices) == 2:
lines.append("l " + " ".join(indices))
lcount += 1
else:
lines.append("f " + " ".join(indices))
fcount += 1
vcount += len(xyz)
ocount += 1
out = args.out or args.tbl.with_suffix(".package.obj")
out.write_text("\n".join(lines) + "\n")
print("wrote %s" % out)
print("objects=%d faces=%d lines=%d vertices=%d skipped=%d" %
(ocount, fcount, lcount, vcount, skipped))
if __name__ == "__main__":
main()

Red Baron Volume Files

Intro

Volume file = big bag of game files.

Volume has many records. Each record has:

file name
zero byte
small hidden bytes
file size
file data
next file record

Record shape

[NAME bytes] [00] [K unknown bytes] [SIZE, 4 bytes] [DATA bytes]

Example:

53 50 41 44 5f 31 33 2e 54 42 4c 00
xx xx
xx xx xx xx
... file data ...

Meaning:

  • NAME = normal DOS-style name, like SPAD_13.TBL.
  • 00 = name ending marker.
  • K = small per-record prefix. It can have different length for each record.
  • SIZE = four-byte little-endian number.
  • DATA = exactly SIZE bytes.

The Amiga files tested use this same record idea as the other Red Baron volumes.

What is K?

K bytes sit between the name and the size.

K is not always the same. One record can have two bytes. Another can have five.

Do not treat K bytes as part of the file data.

The format evidence shows K can be different for different records. Its meaning is not known yet. It behaves like a per-record prefix before the size.

To read a record, try K = 0 through K = 8.

For each K, it reads the four bytes after K as SIZE.

Then it calculates:

next record position =
    position after name
  + K
  + 4
  + SIZE

If that position starts a valid next file name, K is probably correct.

This is how a reader follows the record chain. The reader must not scan the whole file for names, because file data can contain name-like text.

Little-endian size

Four size bytes go low byte first.

Example:

74 1A 00 00

Means:

0x00001A74 = 6772 bytes

Do not reverse the size bytes unless testing proves another volume uses another format.

Extraction steps

  1. Open volume file as raw bytes.
  2. Start at byte zero.
  3. Read a file name until zero byte.
  4. Try K values from 0 to 8.
  5. Read four-byte little-endian size after K.
  6. Jump forward by size.
  7. Check that jump lands on next file name.
  8. Copy the size bytes after the size field into a new file.
  9. Repeat until end of volume.

Caveman pseudocode:

pos = 0

while pos < volume length:
    name = read name at pos
    body = byte after name zero

    for K from 0 to 8:
        size = read little-endian u32 at body + K
        next = body + K + 4 + size

        if next is valid next name:
            save bytes from body + K + 4 to next
            pos = next
            break

Standalone Python reader

This is a complete small reader. It needs no Red Baron tool or library.

Save it as extract_volume.py, then run:

python3 extract_volume.py volume.003 output_directory
from pathlib import Path
import re
import sys

NAME_CHARS = set(range(0x20, 0x7f))
NAME_RE = re.compile(rb"\.[A-Za-z0-9]{1,4}$")

def name_length(data, pos):
    end = pos
    while end < len(data) and data[end] in NAME_CHARS:
        end += 1
    name = data[pos:end]
    if (1 < len(name) < 16 and end < len(data)
            and data[end] == 0 and NAME_RE.search(name)):
        return len(name) + 1
    return None

def records(data):
    pos = 0
    while pos < len(data):
        length = name_length(data, pos)
        if length is None:
            raise ValueError(f"no file name at offset {pos}")

        name = data[pos:pos + length - 1].decode("latin1")
        body = pos + length
        found = None

        for k in range(9):
            if body + k + 4 > len(data):
                continue
            size = int.from_bytes(data[body + k:body + k + 4], "little")
            next_pos = body + k + 4 + size
            if size > 0 and next_pos <= len(data):
                if next_pos == len(data) or name_length(data, next_pos):
                    found = (k, size, next_pos)
                    break

        if found is None:
            raise ValueError(f"cannot find size/next record for {name}")

        k, size, next_pos = found
        payload_start = body + k + 4
        yield name, k, data[payload_start:next_pos]
        pos = next_pos

def main(volume_name, output_name):
    data = Path(volume_name).read_bytes()
    output = Path(output_name)
    output.mkdir(parents=True, exist_ok=True)

    for name, k, payload in records(data):
        (output / name).write_bytes(payload)
        print(f"{name}: K={k}, size={len(payload)}")

if __name__ == "__main__":
    if len(sys.argv) != 3:
        raise SystemExit("usage: extract_volume.py VOLUME OUTPUT_DIR")
    main(sys.argv[1], sys.argv[2])

The code writes only the payload. It removes the volume record's name, K bytes, and four-byte size field.

Important warning

Do not search whole volume for names and guess records.

A file's data can contain text that looks like a file name.

Follow the size chain. Size tells where next record starts.

The next name check is needed because K is not stored as an explicit K value. If two K values appear valid, the reader needs more format evidence; it must not silently pretend that the choice is certain.

What volume does not tell us

Volume wrapper only stores files.

It does not explain what TBL, CLG, DAT, or TTM bytes mean.

After extraction, those files need their own format parser.

volume = box
TBL/CLG/DAT/TTM = things inside box
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment