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 becausegameresources.texdbis 8 GB in size and never fits entirely into memory. The sole exception is §8 (writing totextures.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
dictsilently discards two-thirds of the entries. That is whytools/probe_texdb.pystores 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.pyreads the.resourcesof the TNC build type (IDCLv12), 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_sizestays (for a codec-2 mip it already is the plain BC size);stored_sizebecomes 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
.texdbkey.tncpatchwrites every row of that key, the maptextures.cachefiles 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