Format reference · .gaf

Images & animations

From a bank of named sprites to the pixels on screen. Explore Total Annihilation’s GAF container, decode its bytes, and see the details that change the picture.

Pixel format
8-bit palette indexes
Byte order
Little-endian
Addressing
Absolute file offsets
Storage
Raw · RLE · composed

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.

Explore a GAF

Small frames. One shared anchor.

PULSE / RLE · 6 frames
Original green pulse sprite, frame 0, 12 by 12 pixels
6× nearest neighbor · crosshair = anchor
An original six-frame pulse, decoded from the example GAF. Turn off Apply offsets, then scrub: the growing image drifts away from the crosshair. With offsets applied, its center stays registered. The checkerboard represents the unchanged destination.
Where it appearsWhat an entry representsHow frames are used
anims/Explosions, reclaim effects, cursors, menu graphicsPlayback sequences or gadget states
<unitname>_gadget.gafA 64 × 64 build-menu pictureConventionally normal, highlighted, disabled
Menu GAFs such as ARMALAB.GAFBuild pictures for the menu’s unitsNamed pictures; some entries are empty placeholders
textures/A texture named by a 3DO primitiveStatic or animated textures
textures/LOGOS.GAFTeam-color texturesThe 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.

File anatomy

A pointer-linked container

  1. 01 / 12 bytesFile headerversion · entry_count
  2. 02 / u32 pointersEntry tableabsolute file offsets
  3. 03 / 40 bytesNamed entryframe_count · name
  4. 04 / 8 bytes eachFrame referencesframe pointer · hold
  5. 05 / 24 bytesFrame headersize · origin · data pointer
  6. 06 / variable lengthPixel data or childrenraw · RLE · pointer table
Every stored pointer is an absolute offset from the start of the file, not a relative displacement. Only the entry-offset table and each entry’s frame-reference table immediately follow their respective headers. Select a block to jump to its byte layout.

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 →

OffsetBytesTypeFieldMeaning
0x004u32versionUsually 0x00010100; retail ignores it. TERRAIN.GAF and VISMASKS.GAF use 0 with the same layout.
0x044u32entry_countAuthored count. Retail reads the low 16 bits as a signed value; bit 15 set means no entries load.
0x084u32unknownZero in surveyed retail files. Preserve it.
0x0C4 × countu32[]entry_offsetsAbsolute offsets of entry headers; follows the 12-byte header.

Entry header · 40 bytes

Scroll table horizontally to see all fields →

OffsetBytesTypeFieldMeaning
+0x002u16frame_countNumber of frame references. Zero is valid: placeholder entries exist.
+0x022u16unknown1 / loopThe 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.
+0x044u32unknown2Zero in surveyed retail entries.
+0x0832char[32]nameNUL-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 →

OffsetBytesTypeFieldMeaning
+0x004u32frame_offsetAbsolute offset of the frame header.
+0x044u32hold_ticksPer-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 →

OffsetBytesTypeFieldMeaning
+0x002u16widthPixel width, greater than zero.
+0x022u16heightPixel height, greater than zero.
+0x042i16x_offsetSigned horizontal anchor offset.
+0x062i16y_offsetSigned vertical anchor offset.
+0x081u8color_keyTransparent palette index for the ordinary raw keyed blitter. 9 in all 48,519 frames in the earlier survey. RLE uses skip runs instead.
+0x091u8compressed0: raw; 1: per-row RLE. Only these values occur in surveyed retail data.
+0x0A2u16subframe_countLow byte: child count. On a child header, a nonzero high byte selects the alternate tinted ALP path in the ordinary compositor. See composition.
+0x0C4u32unknown2Zero in surveyed retail frames.
+0x104u32data_offsetAbsolute pixel-data offset, or child-pointer table if the low-byte child count is nonzero.
+0x144u32unknown3Preserved 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.

Interactive example

Transparency is an operation, not a color

Before · storage bytes
Raw mask: a black diamond inside a blue square of palette index 9

Index 9 is still in the file.

After · key applied
Keyed mask: an opaque black diamond with transparent corners

Index 9 skips the destination. Index 0 stays black.

The authored 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 orderOperationOutput lengthExtra bytes read
command & 1Skip transparent pixelscommand >> 1None
Otherwise, command & 2Repeat one palette index(command >> 2) + 1One index
OtherwiseCopy literal palette indexes(command >> 2) + 1One index per output pixel
Interactive example

Eight bytes in. Six pixels across.

06 00payload: 6 bytes05skip 206 04repeat 4 × 204 00 05copy 0, 5
skipskip4405

Read left to right. The black pixel is a literal index 0; the two checkerboard cells leave the destination untouched.

An authored six-pixel row: 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

SituationInspected retail behaviorNanolathe validation policy
Run overshoots the widthClamps output to the remaining width. A literal still advances its source by its full authored count.Rejects the row.
Payload runs out earlyContinues 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 payloadThe row boundary comes from its authored byte count.Requires exact payload consumption and exactly width pixels.
Skip command has length zeroNo corresponding host-style validation established here.Rejects zero-length skip commands.
Payload length is zeroLeaves 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.

Interactive example

A parent is not a clipping rectangle

Destination clipping
Green first child and amber second child extend beyond the dashed parent boundary; amber covers green in the overlap

Ordinary retail composition keeps the extensions.

Parent-sized host canvas
The same children clipped to the dashed parent rectangle, losing both outer 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.

The ordinary retail blitter clips to the destination surface. Its children may extend beyond the parent’s declared dimensions. Converting the operation into a parent-sized image can discard visible pixels, as the right-hand comparison shows.

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.

All six original pulse frames on checkerboard, each centered and labeled from 00, 12 by 12, through 05, 32 by 32
The complete 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 spanDecoded valueFollow it to…
0x0000…0x0003Version 0x00010100Ordinary version convention
0x0004…0x0007Entry count 2Two pointers after the header
0x0008…0x000BReserved 0Preserved header word
0x000C…0x000FEntry pointer 0x07EAPULSE: 6 frames, loop 1
0x0010…0x0013Entry pointer 0x0A9AMASK: 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 storageNanolathe direct view
Raw leafReuses decoded bytes, retaining the authored key byte.
RLE leafPreserves 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.
CompositeRejects 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:

ConsumerWhat it does
Ordinary compositorChooses ALP tint for alternate children.
Tinted compositorPropagates tint to all descendants.
Gray and dithered fog compositorsRecurse in table order without testing the alternate byte; retain their own operation and raw-frame gate.
Nonzero-mode GAF glyph compositorSends every child through ALP, regardless of its alternate byte.
Calculated-flash compositorRecurses with its own LHT operation.
Precomputed visibility masks and projectile-model texture spansRead storage as a row-major raster without expanding children or testing the alternate byte. Child-table storage becomes raster input.
Frame-to-surface adapterWraps data directly.
Dormant scaled keyed compositorDispatches 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 referenceEvidence used here
02 · Content, VFS, formats and data loadingR-MALF-01 §6: loader widths, pointer relocation, malformed rows, host boundaries, and nested-layout limits.
03 · World, visibility, rendering, audio and videoR-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 effectsR-WFX-01 §1: playback cursor, whole-tick holds, and loop flags.
Nanolathe metadata reader and pixel readerHost validation, metadata, decoding and direct-raster policy.
GAF File Format Document 1.0Historical structures and RLE scheme; field interpretations are qualified by the later research.
GAF File Content DescriptionHistorical roles, gadget conventions and LOGOS speculation.

Continue with the palette reference, 3DO model reference, or GUI layout reference.