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+0x60of the 144-byte entry in.resources(the "default hash" fromre_probes/agent_reports/datahash.md).mip_base— u32 at offset0x18of the.bimageheader. The measured value ismip_base == log2(min(width, height)) - 1(996 out of 997; one exception); the slot for a Mip level is thuslog2(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:
- Rename with identical pixels. Take
entry+0x60, done. The key does not change, the Retail.texdbcontinues to work. That was exactly the open question fromre_probes/agent_reports/texdb_rewrite.md("not proven that old keys still work") — it is now proven: they work because the name enters nowhere. - New texture. Choose
entry+0x60freely (no reader calculates it, seedatahash.md§4), then write your own.texdbwith lines at((h & 0x0FFF…) << 4) | slot. Block lengths must matchstored_sizein your own.bimagebecause the engine takes the read length from there.
Keep in mind:
- Only 60 bits are addressable. Two entries whose
hash_0x60differs 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 fullhash_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_basemust 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/.texdbactually renders is therefore not shown, only that the Lookup must find it. mip_baseas 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 default0x07F9E078(both 4-bit fields = 15) upon registration and later set by the Gather path. That it carries exactlybimage[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_sizeis 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 inMountMemoryDatabase("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/5144re_probes/texdbkey/negative_controls.txt— 6 controls, all 0re_probes/texdbkey/key_reuse.txt— shared keys in Retailre_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 isentry+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 aslog2(Edge)-1.re_probes/agent_reports/texdb_rewrite.mdconcludes that renaming it toc3v1_customwould make the old texture keys insecure. This is refuted: the name does not enter the key. The Retail TexDB remains valid as long asentry+0x60of theimageentries is preserved.wolfsdk/idcl.pycan now treatEntry.hash_0x60forimageentries as what it is: the texture's identity within the TexDB. Whoever changes it must bring the.texdbalong.