You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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:
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:
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:
if (part.animation_offset==-1)
variant_number=0;
elsevariant_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:
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_tselector=TBL[p];
Pointpoint=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:
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:
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:
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.
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_tn= (bytes[p] << 8) | bytes[p+1];
For signed coordinates:
// bytes = the model byte array; p = the coordinate's byte offset.int16_tx=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:
structFileTblChunkHeader {
chartag[4]; // for example "MAP:", "GID:", or "DAT:"uint8_tsize_le[3];// 3-byte little-endian payload sizeuint8_tflags; // 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.
structFileClg {
uint16_tgroup_count[6]; // six big-endian countsuint16_toffset[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_trelative_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:
structFileRootHead {
uint16_tunk_00; // meaning not provenuint16_tunk_02; // meaning not provenuint16_tseq_count; // +4: number of state/frame channelsuint16_tseq_off; // +6: root-relative u8 frame-count tableuint16_tdetail_count; // +8: number of 6-byte detail recordsuint16_tdetail_off; // +a: root-relative detail-record arrayuint16_tradius_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.
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:
structFilePart14 {
uint8_tstate_channel; // +0: object state-table index; ff = noneuint8_tbyte_01; // +1: unknown/material byteuint8_tbyte_02; // +2: unknown/material byteuint8_tdetail_state; // +3: copied into renderer detail/shift stateuint16_tpoint_off; // +4: root-relative point baseuint16_tcell_period; // +6: number/period of alternate draw recordsuint16_tdraw_off; // +8: root-relative alternate-record baseuint16_tunk_0a; // +a: validity/sort-related use; exact meaning openuint16_tsize_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:
structPoint3 {
int16_tx;
int16_ty;
int16_tz;
};
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:
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.intp=point_base+selector*6;
int16_tx=be_i16(data+p+0);
int16_ty=be_i16(data+p+2);
int16_tz=be_i16(data+p+4);
floatX=x / 256.0f;
floatY=y / 256.0f;
floatZ=z / 256.0f;
Do not read the words little-endian. The same bytes read little-endian would
give completely different values:
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:
structFileDrawGroupHead {
uint32_tcount; // big-endian observed countuint16_tanchor; // big-endian child/record targetuint16_tunk_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:
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:
structFileRawRecord8 {
uint8_ttype; // 80, 81, a0, or a1 in observed raw datauint8_tbyte_01; // unknown raw attributeuint8_tbyte_02; // unknown raw attributeuint8_tbyte_03; // unknown parameteruint8_tbyte_04; // unknown parameteruint8_tbyte_05; // unknown flag/attributeuint16_tlink; // 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_tselector_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.
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:
f123f4567
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.
structRuntimeShapeSlot12 {
void*parts; // pointer to part-pointer arrayint16_tselector; // runtime selectoruint16_tscale_x; // initialized to 0x0100uint16_tscale_y; // initialized to 0x0100uint16_tunused; // 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:
structRuntimePart {
uint8_tstate_table_index; // +0: object state-table slot; 0xff = noneuint8_tunk_01; // +1: not consumed by traced selectoruint8_tunk_02; // +2: not consumed by traced selectoruint8_tdetail; // +3: detail/material state inputuint16_tpoint_base; // +4: point-pool base componentuint16_tvariant_period; // +6: alternate-record reduction bounduint16_tdraw_record_off; // +8: alternate 8-byte draw-record baseuint16_tunk_0a; // +a: not assigned by traced selectoruint16_tsize; // +c: size/distance input
};
The root's six-byte detail records are clear:
structRuntimeDetail {
uint16_tdepth_limit; // compared with runtime depthuint16_tpart_count; // number of contiguous 14-byte part recordsuint16_tfirst_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:
typedefunsigned charbyte;
structRenderState {
byte*runtime_base; // loaded runtime shape-buffer baseintcamera_distance; // made from aircraft and camera positionsintruntime_depth; // depth/order value; executable: a5+$ffffe832intdetail_state; // selected 0..3 state
};
structRenderStateg_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_tcount=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_tn=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.voiddraw_aircraft(DrawObject*obj)
{
byte*root=select_root(obj->shape_state);
RuntimeDetail*detail=choose_detail(root); // six bytes, root-relativefor (inti=0; i<detail->part_count; ++i) {
uint16_trel=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:
After the detail record is selected, each part performs a separate state/frame
selection:
voiddraw_part(byte*root, RuntimePart*part)
{
uint16_tframe=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:
voiddraw_one_record(RuntimeDrawRecord*rec, RuntimePart*part, intdetail)
{
// 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) {
case0:
draw_polygon_or_clip_path(((byte*)rec) +2, part, detail);
break;
case1:
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:
structRuntimeDrawRecord {
uint8_tdraw_type;
uint8_tunk_01; // skipped by dispatcherunion {
uint8_traw[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:
structRuntimeDrawSlot {
uint8_tkind; // selects which payload layout is activeuint8_tunknown_01; // present in the slot; dispatcher skips itunion {
uint8_tbitmap[6];
uint8_tpolygon[6];
uint8_tother[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.
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
Open volume file as raw bytes.
Start at byte zero.
Read a file name until zero byte.
Try K values from 0 to 8.
Read four-byte little-endian size after K.
Jump forward by size.
Check that jump lands on next file name.
Copy the size bytes after the size field into a new file.
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.
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.