Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

DDS (DirectDraw Surface)

The .dds extension in KOTOR does not represent a standard Microsoft DirectDraw Surface file. Instead, the engine strictly expects a proprietary format consisting of a bespoke 20-byte configuration prefix followed by raw DXT compression blocks. The vanilla parsing logic completely ignores standard 124-byte DDS magic headers.

At a Glance

PropertyValue
Extension(s).dds
Magic SignatureNone (Proprietary 20-Byte Prefix)
TypeBioWare DirectDraw Wrapper
Rust ReferenceView rakata_formats::Dds in Rustdocs

File Layout

Two container variants share the extension, and which one you have is decided by the first four bytes.

VariantDiscriminatorStructure
Standard DDSBegins with the DDS magic4-byte magic, then a 124-byte D3D9-era header, then the surface payload
CResDDS prefixedNo magic at allA 20-byte proprietary prefix, then the surface payload directly

The prefixed variant is the one vanilla resource paths use, and it does not contain a DDS header in any form: the twenty bytes replace it rather than preceding it.

The 20-byte prefix

OffsetFieldTypeNotes
0x00widthi32
0x04heighti32
0x08format codeu83 for DXT1, 4 for DXT5. A genuine one-byte read; see below.
0x09reserved3 bytesUntouched by the primary reader, read as part of a dword by a sibling. See below.
0x0Cbase_sizei32Byte count of the level-0 compressed payload, trusted as written. See below.
0x10alpha_meanf32Read and handed downstream. No consumer found; see below.

Each width comes from the load instruction rather than from the field’s apparent size: the format code targets a single-byte register while the other four are plain four-byte copies. The payload begins at 0x14 with no further header.

Important

This is the one page in the manual with no file behind it Every other format here has been checked against real bytes. DDS has not, because none was found to check: not in chitin.key, not in any module archive, not in the four ERF archives under TexturePacks/, and not among the mod assets that once supplied a corpus for TPC. The prefix field map and the reserved +0x09-+0x0B gap rest wholly on decompilation.

That matters because decompilation-only claims are the ones that have failed when checked: this page’s audit table and the walkmesh page’s are the two that lost rows, both recorded with the provenance ladder. Two independent readings of the read path agree on the five-field prefix map exactly as written, which is the most this format can offer, but it is still traced rather than measured.

The TexturePacks/ archives are named explicitly because they are the family a sweep starting from chitin.key walks straight past, which is how TPC’s own shipped corpus went unopened for so long. They hold no .dds. Saves were not searched, and nothing here rules out a .dds appearing in one.

Rakata reads both and writes only the standard form. That is a deliberate limit rather than an oversight: emitting the prefixed variant would mean reproducing a container the engine reads but no other tool does.

Read from CResDDS::GetDDSAttrib at 0x00710ee0 in swkotor.exe. Provenance: derived, not attested unless a claim says otherwise: the rows have not been separately re-derived, so they sit on the reverse-engineering queue. Individual claims below may carry a level of their own, and where one does it overrides this line for that claim.

Pipeline EventGhidra Provenance & Engine Behavior
Prefix StrippingThe engine’s parser explicitly expects and strips a proprietary 20-byte magic header wrapper prepended to the DDS buffer: width (+0x00), height (+0x04), byte code (+0x08), base-size (+0x0C), and an alpha_mean FLOAT (+0x10). Each width was confirmed by reading the load instruction: the byte code is a genuine single-byte read, the other four are plain 4-byte copies.
No intermediate structCResDDS::OnResourceServiced (0x00710f30) assigns the raw loaded-resource buffer directly, copying nothing, and derives the payload pointer as that same address plus 20. The twenty-byte prefix is the start of the resource bytes rather than a header parsed into anything.
Null data pointerGetDDSAttrib is linear apart from one test: if the object’s stored data pointer is null, because the resource has not been serviced yet, it returns 0 and writes none of the five outputs. A caller that ignores the return value reads whatever its output variables already held.

The format byte at +0x08

DDS’s on-disk convention is 3 for DXT1 and 4 for DXT5. GetDDSAttrib copies the byte out completely unmodified: no mask, no comparison, no branch anywhere in the function, so whatever is on disk is exactly what downstream code receives.

Important

This is not TPC’s encoding, despite the shared dispatch The two encodings are easy to conflate because they converge.

TPC’s on-disk bytes are 1, 2 and 4, and CAuroraProcessedTexture::ReadProcessedTextureHeader (0x0070f590) remaps them to an internal code before anything downstream sees them: on-disk 2 becomes internal 3, on-disk 4 stays 4. DDS does no remapping at all. The two formats therefore carry different on-disk conventions that arrive at the same internal values by different routes.

Where they genuinely do meet is one branch further down, in AurGetImageWrapper (0x0041eb27): a single code == 3 gives the DXT1 GL enum, code == 4 gives DXT5. That is the same instructions for both formats rather than parallel logic, which is a stronger claim than “mimics TPC” and a narrower one than “same encoding”.

Note

The 8-byte and 16-byte block sizes are general DXT facts, not something traced here DXT1 blocks are 8 bytes and DXT5 blocks are 16, and that is true of the compression formats generally. It is not an arithmetic operation this engine performs: no byte-count computation for block size exists anywhere in the DDS or TPC raster call chain. The engine reads the total compressed size out of the header field at +0x0C and trusts it, and AurGetImageWrapper only ever selects a GL enum, leaving the driver to interpret block geometry from that.

A formula of the shape (pixel_type == 4) * 8 + 8 circulates for this field, attributed to GetDDSAttrib. Both halves of that attribution are wrong: the function contains no arithmetic or branching of any kind beyond the null check, and the formula appears nowhere in the binary. It describes DXT correctly and describes the engine not at all, which is why the block sizes are given above as a property of the compression rather than as something computed here.

Reserved gaps

Tip

Reserved gaps, and the scope of “ignored” The bytes spanning +0x09 to +0x0B are untouched by GetDDSAttrib: the instruction stream goes straight from the single-byte read at +0x08 to the dword at +0x0C, referencing none of the three.

That claim holds for that function only. A second, independently coded reader of the same twenty bytes, CAuroraCompressedTexture::ReadTextureHeader (0x00710430), reached through CAuroraInterface::ReadCompressedRasterHeader (0x0070cdd0), declares the field at +0x08 as a full four-byte value and passes all four bytes through to its caller as one word. That caller is reached only through a function-pointer slot in a global interface table with no static caller in the binary, so what becomes of the upper three bytes on that path is untraced.

We preserve them for round-trip fidelity.

alpha_mean at +0x10 is a genuine float on two independent routes: GetDDSAttrib moves it through a general-purpose register, and the sibling reader above loads and stores the same offset with x87 FLD/FSTP, which a compiler emits only for a source-level float.

base_size and alpha_mean

Note

base_size sizes the first level only, and the rest are computed It is the byte count of mip level 0, and it goes straight to the GPU as the length argument of the compressed-texture upload for that level. Every level after it is derived rather than read: dimensions halve and the size scales by the DXT block size, eight or sixteen bytes per four-by-four tile depending on the format the byte code selects.

So the field bounds one level, not the payload. A reader treating it as the whole compressed run gets level 0 and calls it the texture, which is the same trap TPC’s data_size sets in a different way.

The consumer and the field’s role in it are traced. Not traced: the virtual call that returns the value to that consumer, since neither of the two functions that own the header uses it themselves.

Note

alpha_mean is populated and goes nowhere this pass could follow Its type is settled twice over, as above. Its purpose is not. The two nearest consumers both take it and never refer to it again, which is the same shape TPC’s alpha_test shows.

This is a bounded negative rather than a dead field: no consumer found within the depth traced, with the trail ending at an indirection rather than at a conclusion. That is a weaker claim than the dead fields elsewhere in this manual, which rest on enumerating every accessor and its callers, and it should not be read as licence to write anything there. Preserve it.