Wolfenstein Modloader Buy me a coffeeKaffee spendieren

Wiki / The New ColossusThe New Colossus

The .texdb Format (Wolfenstein II: The New Colossus)

Partly solvedTeilweise gelöst Original page in the GitHub wikiOriginalseite im GitHub-Wiki

In plain wordsIn einfachen Worten

Wolfenstein II: The New Colossus stores its texture data in separate .texdb files next to the game archives. This page decodes their layout: a sorted table of 64-bit keys pointing to Oodle-compressed blocks, with no file names inside, and every block can be read. Each map's textures.cache uses the same layout and is patched too; a custom BC1 pistol skin written this way was confirmed in game on 25 September 2026, while its showing at medium distance through the caches is derived from the code. Since WolfSDK 0.2.0 BC5 normal maps can be replaced as well, not yet confirmed in game.

  • Verified against all 24 .texdb files: 21.5 GB and 437,706 table entries.
  • Keys repeat often: 437,706 rows share only 127,418 distinct keys, so the table must not be read as a simple dictionary.
  • Large textures are chains of tiles; one tested entry split into 32 tiles that all unpacked correctly.
  • The 40 per-map textures.cache files have the same layout; the engine only checks an 8-byte magic value, with no hash or size check.
  • Normal maps are written as plain BC5 blocks, so the undecoded tile format is not needed; 60 of 60 retail BC5 maps confirm the DirectX green channel, and 50 checks pass.
  • Open on this page: which key belongs to which texture, and the internal format of a tile.

Wolfenstein II: The New Colossus speichert seine Texturdaten in eigenen .texdb-Dateien neben den Spielarchiven. Diese Seite entschlüsselt deren Aufbau: eine sortierte Tabelle mit 64-Bit-Schlüsseln, die auf Oodle-komprimierte Blöcke zeigen, ganz ohne Dateinamen, und jeder Block lässt sich lesen. Der textures.cache jeder Karte ist gleich aufgebaut und wird ebenfalls gepatcht; ein so geschriebener eigener BC1-Pistolen-Skin wurde am 25.09.2026 im Spiel bestätigt, dass er über die Caches auch auf mittlere Entfernung erscheint, ist aus dem Code abgeleitet. Seit WolfSDK 0.2.0 lassen sich auch BC5-Normal-Maps ersetzen, im Spiel noch nicht bestätigt.

  • Geprüft an allen 24 .texdb-Dateien: 21,5 GB und 437.706 Tabelleneinträge.
  • Schlüssel wiederholen sich oft: 437.706 Zeilen teilen sich nur 127.418 verschiedene Schlüssel. Die Tabelle darf daher nicht als einfaches Wörterbuch gelesen werden.
  • Große Texturen sind Ketten aus Kacheln; ein getesteter Eintrag zerfiel in 32 Kacheln, die sich alle korrekt entpacken ließen.
  • Die 40 textures.cache-Dateien der Karten sind gleich aufgebaut; die Engine prüft nur einen 8-Byte-Kennwert, ohne Hash- oder Größenprüfung.
  • Normal Maps werden als einfache BC5-Blöcke geschrieben, das unentschlüsselte Kachelformat wird also nicht gebraucht; 60 von 60 BC5-Karten des Spiels bestätigen den Grünkanal nach DirectX-Art, und 50 Prüfungen bestehen.
  • Auf dieser Seite noch offen: welcher Schlüssel zu welcher Textur gehört und das innere Format einer Kachel.

Technical detailsTechnische Details

Der technische Teil ist auf Englisch – so, wie er im Wiki steht. Unterstrichene Begriffe haben eine Erklärung auf Deutsch.

All information here is derived from reverse engineering the retail files (Steam, NewColossus_x64vk.exe, id Tech 6). Verified against all 24 base/*.texdb files, totaling 21,541,175,814 bytes and 437,706 table entries. Tool used: tools/probe_texdb.py. Any statements without evidence are explicitly marked as unverified.

Read-only. Nothing in this document describes a write operation. The game files were opened exclusively using seek/read — primarily because gameresources.texdb is 8 GB in size and never fits entirely into memory. The sole exception is §8 (writing to textures.cache), which was also executed only on copies.


1. What a .texdb Is

Each chunkN.resources file has exactly one corresponding chunkN.texdb. The .resources file holds the assets (id Tech 6, Magic IDCL, Version 12), while the .texdb holds the associated texture data. This separation is likely due to streaming: textures are loaded on demand and should not reside in memory alongside their declarations — unverified, but consistent with the tiles described in section 4b.

A .texdb is a flat database: a header, followed by a key table, and then only user data. There are no filenames within it. Access occurs exclusively via a 64-bit key.

2. Header

u8[8]    Magic  4F A5 C2 29 2E F3 C7 61
u8[16]   Build-ID, unique per file, otherwise unverified
LE u64   Number of table entries
--- Table begins at 0x20 ---

The Magic value is identical across all 24 files. The 16 bytes following it appear to be a GUID or content hash of the build; they do not repeat in any other file, and their first quarter is missing even from the paired .resources file (e.g., patch_4.resources). Their purpose remains unverified.

All integers are stored in Little Endian. This is the most notable difference compared to id Tech 5 archives from The New Order, which store sizes in Big Endian (see docs/formats.md).

The file patch_4_chunkbase_6.texdb is exactly 32 bytes: a header with a count of 0 and nothing else. This serves as the best evidence that the header truly ends at offset 0x20.

3. Entry Table

Each entry is 16 bytes, with no padding:

LE u64   Key
LE u64   Offset to user data, absolute from file start

The table is sorted by key — in all 24 files, not descending, thus built for binary search. User data begins immediately after the table at 0x20 + count * 16, and the first entry points exactly there in every file.

Entry Length Is Never Stated

There is no length field. The length is derived from the distance to the next entry in offset order — and because the table is sorted by key, this is not necessarily the immediate neighbor in the table.

The validity of this calculation is measurable: if all offsets are sorted and the distances between them are summed, it yields exactly the size of the user data area in every verified file, with zero gaps. The blocks lie contiguously without alignment padding, and no offset is duplicated.

Keys

Bits 63..4   Texture ID (Hash, origin unknown)
Bits  3..0   Level, observed 1..12, focus 5..8

The lower four bits increment sequentially within a texture ID (…65, …66, …67, …68), and the associated user data is approximately four times larger in each case. This represents a Mip chain; the exact meaning of the level number remains unverified (absolute level or index within the streamed sub-chain).

Keys are not unique, and this is the norm. Across all 24 files, there are 437,706 rows for only 127,418 distinct keys — averaging about 3.4 rows per key. The rows appear directly adjacent and point to separate, equally sized blocks. These are likely array planes or cubemap faces — unverified.

Anyone reading the table as a dict silently discards two-thirds of the entries. That is why tools/probe_texdb.py stores it as a list.

4. Payload Data: Yes, Oodle

The payload data is compressed with Oodle, almost exclusively Kraken. This was not a guess: wolfsdk/oodle.py links against the bundled oo2core_5_win64.dll (Oodle 2.5.5), and tools/probe_texdb.py actually unpacks samples. OodleLZ_GetChunkCompressor confirms the header additionally, but serves only as a hint: for a block stored uncompressed by Oodle, it reports LZB16, and for non-Oodle data, it invents an answer.

There are two forms.

4a. Single Oodle Stream

The payload data begins directly with the Oodle block header (typically 8C 06 or CC 06 for Kraken). No intermediate header, no length field.

The uncompressed size is not in the .texdb. It must come from the texture metadata of the paired .resources — the game knows the format and side length before it loads them.

Almost all measured sizes are powers of two (4096, 16384, 65536, 131072, 262144, 524288 …), which is exactly what a block-compressed mip level weighs. But not all: patch_3.texdb contains entries with 124,200 and 1,036,800 bytes. 1,036,800 is exactly BC1 for 1920x1080 (480 x 270 blocks x 8 bytes), so presumably a full screen — loading image or UI. Anyone trying to guess the raw size should therefore not rely on powers of two.

tools/probe_texdb.py first tries a short list of plausible sizes, from large to small. The order is not a detail: Oodle unpacks willingly even just a prefix of a stream, so an attempt with 4 bytes succeeds against a 2-KB block. Only the largest still-successful attempt can be the true length, and attempts below the compressed size are simply not allowed.

If the list is off, --exact finds the length exactly (Oodle.raw_length): first count the number of whole 256-KB blocks, then search downwards for the remainder. This is brute force — around 65,000 unpack attempts and several seconds per entry — and therefore not the normal path. There is no shortcut: only the exact length can be unpacked, and no computational path to it exists in the format.

4b. Tile Chain

Large entries are a sequence of self-describing tiles:

repeated until end of block:
  LE u32   Length of following Oodle data
  LE u32   Uncompressed size
  LE u32   Position: (Y << 24) | X in texels
  LE u16   512     presumably tile width
  LE u16   256 or 512   presumably tile height
  u8[8]    Unknown, constant within a chain
           (e.g. 19 01 02 02 03 04 04 05)
  u8[]     Oodle stream

This form is self-verifying, and that makes it robust: the chain continues over 24 + Length bytes and must land exactly at the end of the block. For an entry from chunk_11.texdb that is 3,566,247 bytes long, this results in 32 tiles; all 32 unpack successfully, and the end position matches the block length exactly. The position fields run over 4 columns (X = 0, 512, 1024, 1536) and 8 rows — a 2048x2048 grid.

The interpretation of the two u16 as width and height is unconfirmed: the uncompressed tile size does not follow them. 512x256 results in 280,960 bytes, while 512x512 results in 274,624 — the larger tile is therefore smaller, which does not make sense with pure geometry. The tile content is thus itself a container (multiple channels or planes within a block). Unpacked, it looks like byte-separated planes — first a block with wide value range, then long stretches of 0x7F, like a flat normal map. The format of this content is not decrypted. (Update 2026-10-04: still not decoded, and not needed for writing: a normal map replacement writes every mip as plain BC5 under codec 1, see section 10.)

5. Verified Numbers

A pass over all 24 files with 25 randomly selected entries each (--deep 25, Seed 1):

Check Result
Magic correct 24 of 24
First entry == Table end 24 of 24
Gap between blocks 0 Bytes in 24 of 24
Sample unpacked 531 of 554
Of individual stream / tile chain 484 / 47
Raw size not guessed 23

The 23 failures are not a format problem: the block header was an Oodle header in all cases; only the raw size was located next to the rate list (see 4a). They resolve with --exact. The tool's return value therefore depends on the first three lines of the table, not on the sample.

6. What follows from this (and what does not)

Reading is solved: the table, lengths, compression, and tile chain are sufficiently complete to unpack every block.

What is currently missing for replacing a texture:

  • The mapping Key → Asset name. Without it, one knows that a block is a texture, but not which one.
  • The internal format of a tile (Section 4b).
  • The uncompressed size for individual streams. The prerequisite for this now exists — wolfsdk/idcl.py reads the .resources of the TNC build type (IDCL v12), and layout has been calculated against all 24 Retail files —, but it is not yet connected.

Writing itself would be unpleasant but feasible: since there is no length field and the blocks lie contiguously, every size change affects all subsequent offsets — the file would have to be completely rewritten again. For gameresources.texdb, that is 8 GB. (Superseded: a block that does not fit is appended and its row repointed (§8); a BC1 pistol skin written so was confirmed in game on 2026-09-25. Normal maps: sections 9–12.)

7. Tool

python tools/probe_texdb.py                        # Overview of all files
python tools/probe_texdb.py --deep 12              # unpack 12 entries per file
python tools/probe_texdb.py --file patch_4.texdb --deep 4
python tools/probe_texdb.py --file patch_3.texdb --deep 3 --exact   # slow

Example:

patch_4.texdb                    266903 B        4 entries    Table to 0x60
     Build-ID 98321332643afd43af6a6111482b8732
     Payload data 0x60..0x41297, first entry 0x60, Sum of lengths 266807 (Gap: 0)
     key=f239f20d871bdf85      3991 B ->      4096 B  oodle    individual
     key=f239f20d871bdf86     14528 B ->     16384 B  oodle    individual
     key=f239f20d871bdf87     52861 B ->     65536 B  oodle    individual
     key=f239f20d871bdf88    195427 B ->    262144 B  oodle    individual

Four Mip levels of a single texture, factor 4 per stage — the entire content of patch_4.texdb.


8. textures.cache — the texture database in memory

Every map folder carries a textures.cache: 40 files under base/maps/** and dlc/*/base/maps/**, each around 67 MB, totaling 2 690 529 433 Bytes. dlc/dlc_0/…/c02/c2v1/textures.cache is a Hardlink to the one from dlc_2 (39 different files, 40 names). Tool: tools/probe_texcache.py, output in re_probes/texcache/.

Format: a .texdb under a different Magic

u8[8]    Magic  06 4D 5E 3B 22 99 07 AB   (LE u64 0xab0799223b5e4d06)
u8[16]   Build-ID, different per file (39 different), never compared
LE u64   Number of table entries (the engine reads only the lower u32)
--- from 0x20 ---
Count x { LE u64 Key, LE u64 Offset }    sorted by key
Blocks, contiguous, from table end to file end

Exactly the layout from §2–§3, also here without a length field: a block ends at the next offset, the last at the file end. Keys repeat (196 to 2 178 duplicate lines per file). Verified on all 40 files: Magic, sorted table, first block directly after the table end, no duplicate offsets — and a byte-accurate round trip: file split into header, rows, and blocks, reassembled in the measured block order, result identical to the file. 40 of 40.

The blocks are the .texdb blocks of the same keys, byte for byte: Sample 979 of 979 (25 per file, keys without mod). The slots (lower 4 bits) lie almost exclusively at 4–8 (32–512 px): the cache holds the middle stages that a map needs immediately upon loading.

What the Engine Checks — Nothing But the Magic

From re_probes/runtime_87400/module.bin, as facts in python tools/probe_texcache.py --disasm (17 of 17, listing in re_probes/texcache/disasm.txt):

Address Instruction Meaning
0xAC40B6 lea → "%smaps/%s/textures.cache" Path derived from the map name, when loading the map
0xAC40C9 call 0x793090 MountMemoryDatabase
0x793142, 0x7931C0 Open file, get length Buffer = entire file
0x793226 Read in 1-MB chunks Entire file into memory
0x7932C9 movabs rdx, 0xab0799223b5e4d06 only content check
0x793397 cmp eax, 1 MAX_MEMORY_DATABASES = 1
0x7A1948 Memory databases first Mip search
0x7A196F, 0x7A1972 Count [+0x18] (u32), table from +0x20 Lower bound, 16 bytes per line
0x7A19CC, 0x7A19D0 Offset + buffer start No boundary check
0x7A19E8, 0x7A19F2 Hit → 0x7A1C24 the .texdb is skipped
0x7A1C38 Read length [rsi+r10*4+0xc] stored_size from the .bimage

No hash, no size, no build ID, no count check against file length. A file that grows is read entirely; an appended block thus lies in the buffer, and a wrapped offset points there. UnmountMemoryDatabase (0x793460) frees the buffer; at most one map is mounted.

Why This Matters for Skins

The key is the same as in §3 (hash_0x60, slot mip_base - level), and the search takes the first line carrying it. All 40 caches carry the keys for pistole_61 and pistole_61_blood for slots 5 and 6 (64/128 px), plus 61 of the 80 image/map pairs additionally slot 7 (256 px). A mod that only writes the .texdb therefore shows the Retail Pistol from medium distance — and reads stored_size of the new Mip from the old block.

Writing (wolfsdk/tncpatch.py)

An asset texdb:<key> is now written into every textures.cache that carries the key, following the same rules as in the .texdb: if the block fits within its window, write there (original in texdb.orig); otherwise append to the file end and wrap the line. Journal entries, stamps (size + table hash) against game updates, bit-exact reset. In the journal, caches are named after their path from base/ (maps/game/wolf/c01/c01p1/textures.cache, ../dlc/dlc_2/base/maps/…/textures.cache); a base/*.texdb retains its bare filename, older journals remain readable.

Hardlinks are never rewritten. A file with multiple names gets a second name for its original in the backup folder (os.link, costs no space); changes go into a new copy beside the live name, and os.replace sets them in place. dlc_0 and dlc_2 each have their own patched file after this; the original remains unchanged. Resetting moves the backup name back — content, file ID, and link count are then as before. This decision was made because dlc_0 is the user's own map: omitting it would leave the Retail Pistol there, while overwriting in dlc_2 would silently alter dlc_0.

A cache missing on reset (map uninstalled) is skipped rather than rejected: there is nothing more to incorrectly overwrite there, and the rest must still be restored. A missing .texdb continues to be rejected.

Costs (measured, Pistol Skin tnc_skin_eigene_pistole)

  • Each block in the mod is longer than those in the cache (e.g., 2,050/7,522/27,235 versus 1,679/5,002/17,988 bytes) — they are only appended: 19,144 to 73,614 bytes per cache, totaling 2,427,095 bytes across all 40.
  • 38 caches are described in place; the two hardlink names each cost a 67-MB copy as long as the mod is present.
  • Reading all 40 tables: 6.8 MB.
  • Execution times for applying/resetting to the copies: re_probes/skins/eigene/verify.json (cache_seconds).

Proof

python tools/verify_skin_eigene.py (on copies, never in the game folder): all 40 caches copied and verified against re_probes/texcache/retail_sha256.json, with dlc_0/dlc_2 as hardlinks like in-game; the mod applied via the loader path; for each map, every streamed pistol mip collected exactly as the engine does (first the map's memory cache, then the .texdb) — each is a block of the mod. Counter-check: unpatched yields the Retail cache. No writing through the hardlink (a third name of the original as witness), reset bit-for-bit, dlc_0/dlc_2 identical to the same file afterwards. tools/verify_tncpatch.py (scenario_cache) verifies the same on small copies, with crash-, foreign-change-, and backup-counter checks.

Structurally valid, not confirmed in-game. That the intermediate stages now show the custom skin in-game is derived from the code, not observed.


9. Replacing TNC Normal Maps (BC5) in WolfSDK Studio

WolfSDK 0.2.0 replaces BC5 normal maps through the Studio's ordinary texture Replace (tncimage.normal_map, encoder wolfsdk/bcenc.py). The PNG must be:

  • exactly the texture's size; normal maps are not rescaled;
  • a power of two on both sides (such as 1024 × 1024);
  • green in DirectX convention: +Y points down the image. From Blender or another OpenGL tool, invert the green channel first.

How the mips are made: the top mip reads R, G and B as a vector and makes it unit length (a two-channel export with B = 0 uses R and G alone). Every smaller mip is the renormalised mean of the one above. Only single BC5 images are written this way.

Not yet confirmed in game.

Keywords: normal map, BC5, replace normal map, bump map, PNG size, DirectX green, Normal-Map ersetzen, Normalenkarte

10. How Normal Map Mips Are Written: Plain BC5 Instead of Tile Chains

Retail ships the mips of 512 px and up of most BC4/BC5 maps as tile chains (codec 2, §4b), whose inner layout is not decoded. That is not needed: according to research round 2 the engine takes codec 1, one Kraken stream of plain BC blocks, at any size, and retail mixes both within one image. So every written mip is a plain BC5 block chain:

  • the mip record's codec goes 2 → 1; raw_size stays (for a codec-2 mip it already is the plain BC size); stored_size becomes the new block's length; the header and every other field stay;
  • an inline mip (codec 0, stored raw) is overwritten in the .bimage;
  • a streamed mip becomes an Oodle (Kraken) compressed block under its .texdb key. tncpatch writes every row of that key, the map textures.cache files included (§8), and appends a block longer than its old place.

Keywords: tile chain, codec 2, codec 1, mip, texdb block, Oodle, Kraken, Kachelkette

11. The BC5 Encoder in wolfsdk/bcenc.py

wolfsdk/bcenc.py encodes BC1, BC4 and BC5 in pure Python, as the inverses of the decoders in tncimage. BC5 is two BC4 blocks: X (red), then Y (green).

Per BC4 block: the minimum and maximum become the endpoints of the 8-value ramp, every value goes to its nearest ramp entry, then one least-squares refit of both endpoints is kept where it errs less. A flat block is a0 == a1 with every index 0. The 6-value mode (explicit 0 and 255) is never written.

The ramp is the decoder's own palette, so a decode/encode round trip measures what the decoder gives back.

Keywords: BC5 encoder, BC4, block compression, bcenc, texture compression, Kompression, Encoder

12. Normal Map Green Channel and Verification Status

Measured: the green channel is DirectX. A normal map baked from a height field is curl-free, and of the two pairings of X with Y only one is: 60 of 60 retail BC5 maps are curl-free as X right / Y down.

Check: tools/verify_normalmaps.py, 50 checks plus the copy test pass. It covers the encoder against a brute-force endpoint search, round trips on retail mips, the green channel on retail maps, the payload against retail (codec 2 → 1, sizes, pixels), and apply and revert through the loader on copies (md5-identical after revert). Each part has a control that must fail.

Not yet confirmed in game.

Keywords: green channel, DirectX, OpenGL, flip green, verify_normalmaps, normal map test, Grünkanal, getestet