Skip to content

Research Report Texdbkey

LopeKinz edited this page Sep 26, 2026 · 1 revision

The TexDB Key — Solved (from the code, not a hash sweep)

Result: The key is fully computable. The asset name does not matter. A renamed or newly added texture can receive a key that resolves—the builder has nothing to guess.

key = ((entry_0x60 & 0x0FFF_FFFF_FFFF_FFFF) << 4) | (mip_base - mip_level)
  • entry_0x60 — the 64-bit field at offset +0x60 of the 144-byte entry in .resources (the "default hash" from re_probes/agent_reports/datahash.md).
  • mip_base — u32 at offset 0x18 of the .bimage header. The measured value is mip_base == log2(min(width, height)) - 1 (996 out of 997; one exception); the slot for a Mip level is thus log2(its own shorter side) - 1 — e.g., 64 px → 5, 2048 px → 10. However, the field itself is decisive, and the tool calculates with it as well.
  • mip_level — 0 for full resolution, ascending downwards.

Verified: 25,930 / 25,930 texture IDs across all 28 .texdb files of the game (24 × base, 4 × DLC) and 5,144 / 5,144 complete keys including payload length across three chunks. Six negative controls, all with 0 hits.


1. The old rule was not wrong — it was off by one nibble

The now disproven formula was texdb key = hash_0x60 << 4 | level and hit approximately 5%. The reason lies in a single instruction:

7a192d  shl  r14, 4

shl on a 64-bit register discards the upper four bits. The key therefore carries only the lower 60 bits of hash_0x60. The sweep in re_probes/datahash/hash60_survey.txt compared the full 64-bit value against key >> 4 with 60 bits — this can only match if the upper nibble is randomly 0, which occurs in 1 out of 16 cases. Measured results:

Sweep (unmasked) expected 1/16 with mask
chunk_8 4/138 = 2.9 % 6.25 % 106/106 of IDs
gameresources 650/12194 = 5.3 % 6.25 % —
chunk_1 38/732 = 5.2 % 6.25 % —
chunk_16 20/357 = 5.6 % 6.25 % —
all 28 .texdb 1582/25930 = 6.1 % 6.25 % 25930/25930

Counter-check for the randomness argument: out of 1,002 drawn $streamed entries, 62 had an upper nibble of 0 — 6.2%, exactly 1/16. The upper nibble is uniformly distributed and discarded; the 5% was precisely this effect.

The speculation in the assignment ("the Mip half might be right") was also correct — but it is not the level, but mip_base - level.

2. Where the code is located

Entry point via the error string Couldn't find mip %d for texture '%s' (.rdata 0x26653D0, Xref 0x7A1E5D). It lies in

.text 0x7A18A0–0x7A1E74 (boundaries from .pdata), the gather path in u:\mercury\code\engine\renderer\imagestreaming_io.cpp. Single caller: 0x79A65F.

7a18ec  imul rcx, rax, 0xf8        ; rax = image index
7a18fb  add  rcx, [rip+0x31988ae]  ; -> .data 0x393A1B0, array of streaming images
7a1906  mov  edi, [r11+r8*4+4]     ; r11 = .data 0x393A1D0, 12-byte set per image
7a1917  mov  rbx, [rcx]            ; rbx = idResource of the image
7a1929  mov  r14, [rbx+0x28]       ; resource+0x28 == entry+0x60
7a192d  shl  r14, 4                ; <-- upper nibble is dropped
7a1964  mov  eax, edi
7a1966  shr  eax, 3
7a1969  and  eax, 0xf              ; 4-bit Mip base
7a196c  sub  eax, r9d              ; minus requested Mip level
7a1980  or   r9, r14               ; key = ID<<4 | Slot
7a1990..7a19bc                     ; lower bound, step size 16 bytes
7a19c7  cmp  [r8], r9              ; key exactly hit?
7a19cc  mov  rcx, [r8+8]           ; offset from table entry

The block runs twice: once over the databases loaded via MountMemoryDatabase (0x7A1948, Array .data 0x3992C70), and once over those mounted from files (0x7A1A1A, Array .data 0x3992CB8, 0x48 Bytes per entry, counter .data 0x3993100 — in the recording 5). Every texture is searched in all mounted .texdb files, not just the one belonging to its own .resources. This explains, incidentally, why chunk-local comparisons show gaps.

resource+0x28 literally maps to entry+0x60 (0xAB6DF7–0xAB6E0A, lea rcx,[rdx+rdx*8]; add rcx,rcx plus *8 = 144 Bytes per entry), and Resource_GetEmbeddedStreamingFile (0xAB83DA–0xAB8407) compares the same value again against entry+0x60. This aligns with the read position list in datahash.md.

The read length is not stored in the .texdb (which has no length field, see docs/texdb.md §3). It comes from the image's own Mip table:

7a1c7b  mov eax, [rsi + r10*4 + 0xc]   ; rsi = Mip table, r10 = level*5

This is the most important side effect for a builder: the size of the TexDB payload data is dictated by the .bimage, not by the .texdb.

MountDatabase (0x792E00–0x792FAF) checks exclusively the Magic 4FA5C2292EF3C761 and the two read sizes. The 16 Bytes Build-ID are copied into the object (+0x08..+0x17) and then never compared — neither during mount nor in lookup. A self-built .texdb may carry arbitrary bytes there. The filename is derived from the container name (0xABD9F0 attaches .texdb), so a new chunk_N.resources automatically mounts its corresponding chunk_N.texdb.

3. Side finding: the Mip table of the TNC .bimage is decrypted

This was necessary to be able to check the slot half at all, and it closes two open points from docs/texdb.md §6.

Header BIM\x0e (Version 14), little endian, 0x32 Bytes:

0x00 u32   'BIM' 0x0e
0x08 u32   mtlkind  (1 albedo, 2 specular, 3 normal, 4 smoothness)
0x0C u32   Breite
0x10 u32   Hoehe
0x18 u32   mip_base   <-- the 4 bits from the Lookup
           measured log2(min(Breite,Hoehe))-1, 996/997
0x1D u32   Pixelformat (33 BC1, 25/24 BC3 variants)  -- unbestaetigt
0x2E u16   Anzahl gestreamter Mip-Ebenen  (998/1002)

Starting at 0x32 follow entries of 32 Bytes, one per Mip level:

u32 level, u32 (0), u32 width, u32 height,
u32 raw_size, u32 streamed(1)/inline(0), u32 stored_size, u32 offset

stored_size is exactly the length of the block in the .texdb, raw_size is the uncompressed size. Thus, the guessing about powers of two described in docs/texdb.md §4a is unnecessary: both sizes of every TexDB entry are present in the paired .bimage. For inline Mips, offset points into the .bimage itself.

Practically, exactly those Mips with edge ≥ 64 are streamed; everything below that lies inline in the .bimage. Therefore, 5 is the most common smallest slot (21,266 of 25,930 IDs). The distribution of the smallest slot per ID: 1: 1138, 2: 245, 3: 661, 4: 2612, 5: 21266, 7: 8 — values below 5 belong to textures that are inherently smaller than 64 px. Images without a power-of-two edge (e.g., 324×152) have exactly one level and mip_base = 1, thus Slot 1.

4. Positive controls — re_probes/texdbkey/derive_retail.txt

A) Texture IDs -> image entries, 28 .texdb, 28 .resources
   (hash_0x60 & 0x0FFFFFFFFFFFFFFF) covers   25930/25930 IDs
   Control: hash_0x60 unmasked covers      1582/25930 IDs

B) Key calculated from Resource, looked up in the .texdb
   chunk_1 ok                   3291
   chunk_16 ok                  1286
   chunk_8 ok                    567
   not delivered                358
   Hits with matching length: 5144/5144

A is the direction that counts: each individual of the 25,930 different texture IDs in all .texdb files of the game — including the four DLC files, inclusive gameresources.texdb (8 GB) — corresponds to the masked hash_0x60 of an image entry. No exception, no remainder.

B is the stricter check because two conditions must be met simultaneously: The key calculated from .resources must exist and the block immediately below it must have exactly as many bytes as the Mip set of .bimage specifies. A randomly correct key would land on an incorrect length. 5,144 out of 5,144.

The 358 "not delivered" are not a rule violation: their distribution is level 0: 225, level 1: 105, level 2: 24, level 3: 4 — exclusively the top levels, with none from level 4 onwards. This is a Mip cap of the build tool (the peak Mip was not delivered), not a key question; the engine then takes the next best available level.

5. Negative Controls — re_probes/texdbkey/negative_controls.txt

Each line is constructed so that it should fail if the derivation were incorrect.

hash_0x60 unmasked (the refuted rule)     0/4831 hits
Mip level +1                               0/4072 hits
Mip level -1                               0/4075 hits
data_hash (+0x50) instead of hash_0x60    0/5144 hits
hash_0x60 with a flipped bit               0/5144 hits
Raw size instead of stored size            0/5144 hits

python tools/probe_texdbkey.py --negative returns an exit code ≠ 0 as soon as one of them passes.

Two denominators are smaller than 5,144, and this is intentional, not cosmetic:

  • Unmasked, 4,831 instead of 5,144. With a top nibble of 0, masked and unmasked keys are the same number; these 313 cases cannot refute anything and are therefore excluded from the count rather than counted as hits.
  • Mip ±1, ~4,070 instead of 5,144. A solid color texture compresses every Mip level to the same 42 bytes (textures/system/constant_color/… and similar). Where a neighbor slot offers a length matching one's own, the length test is blind — without this exception it reports 78/5,144 "hits", which are in reality only indistinguishability. With the exception: 0. That is the honest number, not the pretty one.

6. The Practical Answer

Can a renamed or new texture receive a resolving key?

Yes, and without any restriction depending on the name.

The name does not enter the key — neither directly nor via a hash. There are two ways:

  1. Rename with identical pixels. Take entry+0x60, done. The key does not change, the Retail .texdb continues to work. That was exactly the open question from re_probes/agent_reports/texdb_rewrite.md ("not proven that old keys still work") — it is now proven: they work because the name enters nowhere.
  2. New texture. Choose entry+0x60 freely (no reader calculates it, see datahash.md §4), then write your own .texdb with lines at ((h & 0x0FFF…) << 4) | slot. Block lengths must match stored_size in your own .bimage because the engine takes the read length from there.

Keep in mind:

  • Only 60 bits are addressable. Two entries whose hash_0x60 differs only in the top nibble land on the same key. Retail never pays for this: 0 out of 29,824 60-bit IDs carry more than one full hash_0x60. A builder should still set the top nibble to 0, then the stored value is identical to the ID.
  • The table is sorted ascending by key (binary search, 0x7A1990) — an unsorted table finds nothing.
  • mip_base must match the actual edge length (log2(min(width,height))-1), otherwise all slots of a picture will be off by the same amount.

What breaks if two entries share a key?

Nothing — Retail does it itself, in large numbers. re_probes/texdbkey/key_reuse.txt:

Image entries with $streamed: 33805 Various hash_0x60: 26060 hash_0x60, sharing multiple NAMEN: 570 ed5c1ec6dc4930f7 223 entries (allied46_trousers_s, vase_01_s, vase_02_s, …) a8fec4ecc4b28135 213 entries 39ba9f62c49c39ee 200 entries 64-Bit IDs with two different hash_0x60 falling on them: 0 of 29824 Multiple users in chunk_8/chunk_1/chunk_16: same Mip signature 44, differing 0

hash_0x60 is clearly an content hash for image entries: 570 Values are shared by multiple names, the largest being 223 entries. This is deduplication — all specular maps that are flat constants share a block.

The condition under which this works is also clear: co-users must have the same Mip signature (side length, mip_base, stored_size per level). In the sample: 44 multiple users, 44 identical signatures, 0 deviations. If a custom entry reuses a Retail key while specifying a different size or a different Mip chain, the engine reads the incorrect byte count from the Retail block — Oodle fails or delivers garbage pixels. The keys do not collide; the lengths do.

Short: Reusing a Retail key is allowed and yields the Retail pixels. If you want your own pixels, you need your own hash_0x60 and your own .texdb lines — which is fully possible per Section 6.

7. What was not tested (honestly labeled)

  • No runtime test. The assignment prohibits game startup; all statements come from disassembly and Retail files. That a self-built pair .resources/.texdb actually renders is therefore not shown, only that the Lookup must find it.
  • mip_base as file field. In the Lookup, the four bits come from a runtime field (.data 0x393A1D0, 12 bytes per image), which is pre-filled with the default 0x07F9E078 (both 4-bit fields = 15) upon registration and later set by the Gather path. That it carries exactly bimage[0x18] is measured (5 144/5 144 including length), not read from the assignment site — we did not find that. For the builder, this makes no difference; for the formulation "according to code," it does.
  • Tile chains. Large Mips exist as tile chains (docs/texdb.md §4b). The length test covers them (stored_size is the length of the whole chain), but the internal format of a tile remains ununderstood.
  • Pixel format codes. bimage[0x1D] (33/25/24) correlates with BC1/BC3; the mapping is extrapolated from 3 cases and unverified.
  • Upper limit of mounted file databases. In the recording 5; a MAX_… limit like in MountMemoryDatabase ("No more database slots available, raise MAX_MEMORY_DATABASES") was not localized for the file path.

8. Artifacts

  • tools/probe_texdbkey.py — texdb_key(), bim_mips(), positive, negative, and reuse controls, --selftest (Py 3.11, stdlib + wolfsdk.idcl/wolfsdk.oodle)
  • re_probes/texdbkey/derive_retail.txt — 25930/25930 and 5144/5144
  • re_probes/texdbkey/negative_controls.txt — 6 controls, all 0
  • re_probes/texdbkey/key_reuse.txt — shared keys in Retail
  • re_probes/texdbkey/disasm_texdbkey.txt — Disassembly evidence (Lookup, Mount, entry+0x60 → resource+0x28, read length)

9. For other owners

  • docs/texdb.md (foreign ownership) states in §3 that "Bits 63..4 are the Texture ID (Hash, origin unknown)" and in §6 that it is missing "the mapping Key → Asset Name" as well as "the uncompressed size for individual streams." Both issues are resolved: the ID is entry+0x60 & 0x0FFF…, and both the raw and memory size of each block are listed in the Mip set of the paired .bimage (Section 3). The interpretation of the lower 4 bits as "Level, observed 1..12" is correct and more precisely defined as log2(Edge)-1.
  • re_probes/agent_reports/texdb_rewrite.md concludes that renaming it to c3v1_custom would make the old texture keys insecure. This is refuted: the name does not enter the key. The Retail TexDB remains valid as long as entry+0x60 of the image entries is preserved.
  • wolfsdk/idcl.py can now treat Entry.hash_0x60 for image entries as what it is: the texture's identity within the TexDB. Whoever changes it must bring the .texdb along.

Clone this wiki locally