One file, many pictures
A GAF is a bank of named entries. Each entry contains zero or more frames: a still image, an animation, or a set of alternatives selected by the engine. Each pixel is an 8-bit index into an external palette; the GAF does not contain RGB colors.
Small frames. One shared anchor.
12 × 12 px · offset (6, 6) · origin = anchor − offset
Playback uses a slowed teaching clock of 10 ticks/s. Hold and loop controls change this preview; the download stores 3 ticks per frame and loop = 1.
| Where it appears | What an entry represents | How frames are used |
|---|---|---|
anims/ | Explosions, reclaim effects, cursors, menu graphics | Playback sequences or gadget states |
<unitname>_gadget.gaf | A 64 × 64 build-menu picture | Conventionally normal, highlighted, disabled |
Menu GAFs such as ARMALAB.GAF | Build pictures for the menu’s units | Named pictures; some entries are empty placeholders |
textures/ | A texture named by a 3DO primitive | Static or animated textures |
textures/LOGOS.GAF | Team-color textures | The owner’s color index selects a complete frame |
Follow the pointers
The only fixed starting point is the 12-byte file header. Its entry table leads to named entries, then frame-reference tables, then frame headers. A frame’s data pointer leads to pixels or a child-pointer table. Do not assume these blocks are adjacent or ordered.
A pointer-linked container
- 01 / 12 bytesFile header
version · entry_count - 02 / u32 pointersEntry table
absolute file offsets - 03 / 40 bytesNamed entry
frame_count · name - 04 / 8 bytes eachFrame references
frame pointer · hold - 05 / 24 bytesFrame header
size · origin · data pointer - 06 / variable lengthPixel data or children
raw · RLE · pointer table
Byte layouts
Offsets in the file header are file-relative. A + offset in the remaining tables is relative to that record’s start. Sizes are in bytes; u means unsigned and i means signed.
File header · 12 bytes
Scroll table horizontally to see all fields →
| Offset | Bytes | Type | Field | Meaning |
|---|---|---|---|---|
0x00 | 4 | u32 | version | Usually 0x00010100; retail ignores it. TERRAIN.GAF and VISMASKS.GAF use 0 with the same layout. |
0x04 | 4 | u32 | entry_count | Authored count. Retail reads the low 16 bits as a signed value; bit 15 set means no entries load. |
0x08 | 4 | u32 | unknown | Zero in surveyed retail files. Preserve it. |
0x0C | 4 × count | u32[] | entry_offsets | Absolute offsets of entry headers; follows the 12-byte header. |
Entry header · 40 bytes
Scroll table horizontally to see all fields →
| Offset | Bytes | Type | Field | Meaning |
|---|---|---|---|---|
+0x00 | 2 | u16 | frame_count | Number of frame references. Zero is valid: placeholder entries exist. |
+0x02 | 2 | u16 | unknown1 / loop | The low byte is the playback loop flag. Nonzero wraps to frame 0; zero finishes after the last frame. All 11,881 surveyed entries store 1 in this word. |
+0x04 | 4 | u32 | unknown2 | Zero in surveyed retail entries. |
+0x08 | 32 | char[32] | name | NUL-terminated and NUL-padded. Lookup folds ASCII A…Z to a…z; the first matching entry wins. |
Retail clears the loop byte in memory for one-shot sequences such as weapon explosions and feature burns. The loop flag in the file therefore does not determine every consumer’s final playback behavior. The 40-byte entry is immediately followed by frame_count frame references.
Frame references · 8 bytes each
Scroll table horizontally to see all fields →
| Offset | Bytes | Type | Field | Meaning |
|---|---|---|---|---|
+0x00 | 4 | u32 | frame_offset | Absolute offset of the frame header. |
+0x04 | 4 | u32 | hold_ticks | Per-frame countdown in whole simulation ticks. The loader leaves it untouched. |
The playback cursor advances when its countdown is below 2. A stored 0 or 1 holds for one advance; a value h ≥ 2 holds for exactly h advances. Surveyed values range from 1 to 10 and are constant within an entry. For example, cursorairstrike stores 1, cursorpickup stores 2, and cursornormal stores 10. Timing evidence: 06 R-WFX-01 §1.
Frame header · 24 bytes
Scroll table horizontally to see all fields →
| Offset | Bytes | Type | Field | Meaning |
|---|---|---|---|---|
+0x00 | 2 | u16 | width | Pixel width, greater than zero. |
+0x02 | 2 | u16 | height | Pixel height, greater than zero. |
+0x04 | 2 | i16 | x_offset | Signed horizontal anchor offset. |
+0x06 | 2 | i16 | y_offset | Signed vertical anchor offset. |
+0x08 | 1 | u8 | color_key | Transparent palette index for the ordinary raw keyed blitter. 9 in all 48,519 frames in the earlier survey. RLE uses skip runs instead. |
+0x09 | 1 | u8 | compressed | 0: raw; 1: per-row RLE. Only these values occur in surveyed retail data. |
+0x0A | 2 | u16 | subframe_count | Low byte: child count. On a child header, a nonzero high byte selects the alternate tinted ALP path in the ordinary compositor. See composition. |
+0x0C | 4 | u32 | unknown2 | Zero in surveyed retail frames. |
+0x10 | 4 | u32 | data_offset | Absolute pixel-data offset, or child-pointer table if the low-byte child count is nonzero. |
+0x14 | 4 | u32 | unknown3 | Preserved word; the bounded retail loader, cursor, and blitter traces do not read it. Do not derive animation timing from it. |
Placement offsets
The pixel at (x_offset, y_offset) in the frame lands at the animation’s anchor, or pen. A leaf frame’s top-left destination is:
left = pen_x - x_offset
top = pen_y - y_offset
In the pulse example, frame 0 is 12 × 12 with offset (6, 6); frame 5 is 32 × 32 with offset (16, 16). Both centers land at the same pen. Offsets are signed, so a frame can lie entirely to one side of its anchor. GUI gadgets can carry unrelated offsets because their consumer places them using GUI coordinates.
Pixel encoding
Raw pixels and the color key
With compressed = 0, read width × height bytes at data_offset, row by row. The ordinary keyed blitter skips every source index equal to color_key. The bytes still exist in storage; skipping means leave the destination unchanged.
Transparency is an operation, not a color
Index 9 is still in the file.
Index 9 skips the destination. Index 0 stays black.
MASK entry contains only indexes 0 and 9. With its key applied, the blue corners disappear and the black diamond remains opaque. Retail visibility-mask families also use these two indexes, but their direct-storage consumers have their own contracts.RLE rows and commands
With compressed = 1, each row begins with a little-endian u16 payload byte count, followed by that many bytes of commands and values. A zero count leaves the entire row transparent. For a nonempty row, expand commands to the frame width.
| Test, in this order | Operation | Output length | Extra bytes read |
|---|---|---|---|
command & 1 | Skip transparent pixels | command >> 1 | None |
Otherwise, command & 2 | Repeat one palette index | (command >> 2) + 1 | One index |
| Otherwise | Copy literal palette indexes | (command >> 2) + 1 | One index per output pixel |
Eight bytes in. Six pixels across.
Read left to right. The black pixel is a literal index 0; the two checkerboard cells leave the destination untouched.
06 00 gives the six-byte payload length. 05 skips two pixels; 06 04 repeats index 4 twice; 04 00 05 copies indexes 0 and 5. The command count formulas include the +1 for repeats and literals, but not for skips.Malformed rows: retail versus a checked reader
| Situation | Inspected retail behavior | Nanolathe validation policy |
|---|---|---|
| Run overshoots the width | Clamps output to the remaining width. A literal still advances its source by its full authored count. | Rejects the row. |
| Payload runs out early | Continues reading following bytes until it produces the width. The next row still begins at the authored payload boundary. | Rejects truncated commands or incomplete output. |
| Nonempty row leaves unused payload | The row boundary comes from its authored byte count. | Requires exact payload consumption and exactly width pixels. |
| Skip command has length zero | No corresponding host-style validation established here. | Rejects zero-length skip commands. |
| Payload length is zero | Leaves the row untouched. | Accepts a fully transparent row. |
These stricter checks apply to both Nanolathe’s metadata and pixel readers. They are host safety policy, not extra rules enforced by the retail executable. 02 R-MALF-01 §6 owns the malformed-input behavior.
Composed frames
When subframe_count & 0xFF is nonzero, data_offset points to that many u32 absolute child-frame offsets. The ordinary compositor draws the children in table order, all at the same pen. Each leaf uses its own offsets; later opaque pixels cover earlier pixels.
A parent is not a clipping rectangle
Ordinary retail composition keeps the extensions.
A parent-sized intermediate raster loses the outer pixels.
Schematic: dashed line = parent extent; crosshair = shared pen. Green is drawn first, amber second. Ordinary children only; no ALP tint is simulated.
A host that chooses a parent-sized raster places a child at:
child_left = parent.x_offset - child.x_offset
child_top = parent.y_offset - child.y_offset
That is a useful representation, but it does not reproduce destination-only clipping in general. Roughly half the surveyed retail frames are composed. The later census of 958 GAFs and 123,294 frames found a maximum of 12 children and a zero high byte throughout.
A file you can inspect
Download the 2,762-byte example.gaf, its palette, and its offset manifest. It contains PULSE (six RLE frames) and MASK (one raw frame). The manifest records every header and pixel pointer. The file deliberately puts pixel data and frame headers before their entry headers.
PULSE sequence. Nearest-neighbor enlargement preserves the individual palette-indexed pixels. Each frame stores a hold of 3 ticks.The first 20 bytes are the header and two entry pointers:
00 01 01 00 02 00 00 00 00 00 00 00
EA 07 00 00 9A 0A 00 00
| File span | Decoded value | Follow it to… |
|---|---|---|
0x0000…0x0003 | Version 0x00010100 | Ordinary version convention |
0x0004…0x0007 | Entry count 2 | Two pointers after the header |
0x0008…0x000B | Reserved 0 | Preserved header word |
0x000C…0x000F | Entry pointer 0x07EA | PULSE: 6 frames, loop 1 |
0x0010…0x0013 | Entry pointer 0x0A9A | MASK: 1 frame, loop 1 |
At 0x0812 (0x07EA + 40), the first frame reference is 8B 00 00 00 03 00 00 00: frame header at 0x008B, hold 3. That 24-byte header is:
0C 00 0C 00 06 00 06 00 09 01 00 00
00 00 00 00 14 00 00 00 00 00 00 00
Read it as 12 × 12, offset (6, 6), key 9, compression 1, no children, pixel payload at 0x0014, and zero preserved words. The displayed pulse frames are decoded from these pointers. The asset generator lives in the website source as scripts/gaf-example.py; its --check mode independently decodes the fixture and verifies the generated assets.
Loading and host boundaries
Retail reads the whole file and rewrites offsets into pointers in place, adding the loaded block address to entry, frame, pixel, and child offsets without bounds checks. Truncated or corrupt data can fault during loading. A missing or empty file is null: the effects/anims cache treats that as fatal with the path, while a feature’s filename load is silent.
Entry lookup is a linear scan; first match wins. A missing name produces a null sequence without a message. The established comparison folds only ASCII uppercase letters. Bytes 128…255 stay unchanged; this is not Unicode case folding. The helper has a locale-dependent branch, but the inspected executable’s locale selector starts at zero and has no identified writer or address-taking reference.
Metadata and safety budgets
LoadGAFMetadata retains file and entry fields, frame-reference pointers and delay words, geometry and signed origins, and the complete composite child graph, including the alternate-child selector. It allocates no decoded pixel or transparency planes. It still validates payloads.
LoadGAF uses the same checked index before decoding pixels. Both readers share signed-count handling, first-match lookup, alias handling, malformed-payload rules, aggregate reference/pixel-geometry budgets, and depth limits. A malformed payload rejects the whole bank in both readers. Repeated entry pointers share an immutable reference table and do not spend the reference budget again.
Direct-raster consumers
Some consumers read storage directly instead of using the ordinary blitter. GAFFrame.DirectRaster exposes a checked immutable view for those callers:
| Frame storage | Nanolathe direct view |
|---|---|
| Raw leaf | Reuses decoded bytes, retaining the authored key byte. |
| RLE leaf | Preserves an additional, at-most width × height span starting at the authored data location: encoded row counts and commands, not expanded pixels. An insufficient span rejects the direct consumer while leaving ordinary decode available. |
| Composite | Rejects direct access. On-disk offsets cannot substitute for relocated retail pointer bytes. A portable direct-raster result remains unknown. |
The visibility-mask compiler reports unsupported composites with resource provenance. The projectile model adapter omits the unsupported textured face. These are host decisions; they do not establish that retail silently skips those inputs.
Unknowns and caveats
Choose the consumer before choosing the image
The alternate-child byte is a consumer dispatch choice, not a universal property of a flattened sprite. The closed traces establish these differences:
| Consumer | What it does |
|---|---|
| Ordinary compositor | Chooses ALP tint for alternate children. |
| Tinted compositor | Propagates tint to all descendants. |
| Gray and dithered fog compositors | Recurse in table order without testing the alternate byte; retain their own operation and raw-frame gate. |
| Nonzero-mode GAF glyph compositor | Sends every child through ALP, regardless of its alternate byte. |
| Calculated-flash compositor | Recurses with its own LHT operation. |
| Precomputed visibility masks and projectile-model texture spans | Read storage as a row-major raster without expanding children or testing the alternate byte. Child-table storage becomes raster input. |
| Frame-to-surface adapter | Wraps data directly. |
| Dormant scaled keyed compositor | Dispatches alternate children to its scaled ALP partner; the inspected census found no live callers for either scaled helper. |
Unknown: remaining feature-mask and structure-texture consumers need their own traces. Nanolathe’s compatibility raster retains ordinary children only, clips them to the parent canvas, and leaves alternate-only coverage transparent. That fallback does not establish retail behavior for other consumers. Fog and glyph paths now traverse their own graphs; direct masks and projectile textures use DirectRaster. Modern fog can use ordered child operations to preserve destination reads and alternate black-child tinting. See the owning research for the implementation’s remaining TODO(question).
Blue rectangles can be a keying mistake
Only 274 of 6,068 raw frames in the later 958-GAF survey contain key index 9. Three mask families account for 99.9% of those key pixels: fog.gaf, fogtiles.gaf, and vismasks.gaf. Ignoring the key paints blue (84, 84, 252) into the transparent region and fills the mask’s bounding box. This observation concerns ordinary keyed decoding; direct-storage consumers still require their own contract.
A correctly decoded panel can look like static
ARMINT.GAF entries PANELSIDE and PANELSIDE2 are 129 × 480 RLE panels, authored as dark per-pixel dither. Roughly 90% of their pixels use the darkest member of different palette ramps; all 41 observed indexes are darker than RGB (23, 19, 39). At 1:1 they are nearly black. Brightening them reveals multicolored noise.
This is established art behavior, supported by exact row/payload alignment for all 480 rows, a coherent ARM emblem in PANELSIDE2, and retail rendering’s matching index distribution. Replacing the noise with a smooth “metal panel” invents art. The engine draws minimap frames and buttons over this background; missing widgets are a separate problem.
Team colors select complete frames
Established, 03 R-RAST-01 §3: the owner’s color index selects a frame in a ten-frame LOGOS.GAF entry, independently of player slot. This is not one global palette-index substitution derived from frame 0. Frames can contain entry-specific spatial differences and even different dimensions, such as 32 × 32 versus 32 × 33.
The reference LOGOS.GAF has 18 entries: all are ten-frame team textures except the ordinary two-frame onoff01. The shaded structure branch applies its interpolated PALETTE.SHD row; the unshaded branch uses the selected frame’s indexes directly. This behavior is retail-measured, not documented by Cavedog.
Sources and evidence
This page adapts the Nanolathe GAF research at commit 11ae6ad. A plain-text snapshot preserves the complete document, citations, and publication-omission notices. The original research omits retail-derived byte examples in this edition; the worked bytes on this page come from the new authored fixture.
Established identifies observed reference-asset facts or bounded executable traces, as stated alongside each claim. Policy identifies Nanolathe host decisions. Unknown retains unresolved behavior; no illustrative image should be treated as additional retail evidence.
| Owning reference | Evidence used here |
|---|---|
| 02 · Content, VFS, formats and data loading | R-MALF-01 §6: loader widths, pointer relocation, malformed rows, host boundaries, and nested-layout limits. |
| 03 · World, visibility, rendering, audio and video | R-COMP-01 §2: composition; R-REN-03D §4: ALP tint; R-FONT-01 §6: glyph consumers; R-FX-01 §4: calculated flashes; R-RAST-01 §3 and R-RND-02A: team textures and shading. |
| 06 · Weapons, projectiles, damage and effects | R-WFX-01 §1: playback cursor, whole-tick holds, and loop flags. |
| Nanolathe metadata reader and pixel reader | Host validation, metadata, decoding and direct-raster policy. |
| GAF File Format Document 1.0 | Historical structures and RLE scheme; field interpretations are qualified by the later research. |
| GAF File Content Description | Historical roles, gadget conventions and LOGOS speculation. |
Continue with the palette reference, 3DO model reference, or GUI layout reference.