From d9701c528d3497febf7e41b05bd8c3c19e77a223 Mon Sep 17 00:00:00 2001 From: MarcelineVQ Date: Fri, 13 Mar 2026 11:55:17 -0700 Subject: [PATCH] Add glyph shadow cache: direct-mapped O(1) bypass for game's 4-bucket hash table GetOrCreateCharacterGlyph (0x5ca2d0) is the #2 CPU hotspot at 3.65%. The game's glyph cache uses only 4 hash buckets for ~95 ASCII chars, causing ~24-entry chain walks with pointer chasing on every lookup. Text measurement (99.8% of calls) re-walks these chains per character, thousands of times per frame during UI updates. Shadow cache: 4096-entry direct-mapped array with Murmur2 hash, keyed on (FontObject*, charCode, param2). Cache hit returns the cached float width via FPU ST(0) inline asm, skipping the chain walk entirely. Gated behind ab_use_custom for A/B benchmarking. --- .../decomp_GetOrCreateCharacterGlyph.c | 468 ++++++++++++++++++ src/transform44/transform44.zig | 72 +++ 2 files changed, 540 insertions(+) create mode 100644 src/transform44/decompiled/decomp_GetOrCreateCharacterGlyph.c diff --git a/src/transform44/decompiled/decomp_GetOrCreateCharacterGlyph.c b/src/transform44/decompiled/decomp_GetOrCreateCharacterGlyph.c new file mode 100644 index 0000000..7cac4b9 --- /dev/null +++ b/src/transform44/decompiled/decomp_GetOrCreateCharacterGlyph.c @@ -0,0 +1,468 @@ +/* + * GetOrCreateCharacterGlyph (0x005CA2D0) + * WoW 1.12.1 (build 5875) -- Font/glyph caching function + * #2 CPU hotspot at 3.65% + * + * Decompiled via Ghidra 11.4.2, with manual analysis annotations. + * + * ========================================================================== + * CALLING CONVENTION (verified from assembly) + * ========================================================================== + * + * Convention: __thiscall + * - ECX = this pointer (FontObject*) + * - [EBP+0x08] = param1: charCode (uint32 -- used as hash key, loaded into EBX) + * - [EBP+0x0C] = param2: secondary key / flags (uint32 -- loaded into ESI initially) + * - RET 0x8 => 2 stack params (8 bytes), caller does NOT clean up + * - Returns: float in ST(0) (FPU register) -- the glyph width + * - Also stores width at entry+0x1C in the glyph hash entry + * + * Evidence: + * 0x005ca2d0 PUSH EBP + * 0x005ca2d1 MOV EBP,ESP + * 0x005ca2d7 MOV EBX,dword ptr [EBP + 0x8] ; param1 = charCode + * 0x005ca2e0 MOV ESI,dword ptr [EBP + 0xc] ; param2 + * 0x005ca2ef MOV EAX,dword ptr [ECX + 0x60] ; this->hashMask + * 0x005ca2f9 MOV dword ptr [EBP + -0x4],ECX ; save this + * ... + * 0x005ca431 RET 0x8 ; cleans 2 dword params + * 0x005ca4a0 RET 0x8 ; early-out path also cleans 8 + * + * The Ghidra decompiler shows float10 return types and __return_storage_ptr__ + * which is WRONG. The function returns a float via ST(0) and also returns + * the glyph entry pointer via EDI (used by callers). + * + * ========================================================================== + * STRUCTURE LAYOUT (inferred from assembly) + * ========================================================================== + * + * FontObject (this): + * +0x3C: GlyphAllocator (base of allocator struct, size ~0x28) + * +0x3C+0x00 = +0x3C: vtable ptr (allocator vtable) + * +0x3C+0x04 = +0x40: linked list struct (for KerningListSpliceOperation) + * +0x3C+0x14 = +0x50: kerning hash table base + * +0x3C+0x1C = +0x58: hash bucket array ptr + * +0x3C+0x24 = +0x60: hash mask (0xFFFFFFFF = uninitialized, else power-of-2 minus 1) + * +0x70: text string data index/ptr + * +0x188: float -- texture scale factor (multiplied with texture size) + * + * GlyphHashEntry (returned in EDI, allocated at +0x3C): + * +0x00: charCode (uint32 -- the hash key, matches param1) + * +0x04: next ptr offset (for chaining in hash bucket) + * +0x14: combined key (charCode<<16 ^ param2, masked) + * +0x18: flags (bit 1 = "rendered/valid" flag) + * +0x1C: glyph width (float, stored from ST(0)) + * + * Hash bucket entry (0x0C bytes each): + * +0x00: next-link offset + * +0x04: first entry ptr (low bit set = sentinel/empty) + * +0x08: (used by hash lookup) + * + * ========================================================================== + * ALGORITHM SUMMARY + * ========================================================================== + * + * 1. Compute combined hash key: EDX = (charCode << 16) ^ (param2 & 0xFFFF) ^ (charCode << 16) + * This creates a hash combining the char code and secondary parameter. + * + * 2. If hash table is initialized (mask != -1), search the hash chain: + * - bucket = hashBuckets[mask & charCode] + * - Walk linked list, comparing entry->charCode == charCode AND entry->combinedKey == EDX + * - If found AND flags bit 1 is set: early return with cached width from +0x1C (HOT PATH) + * - If found but not rendered: fall through to rendering + * + * 3. If not found or hash uninitialized: + * - Get font texture data via GetTextStringData(this->+0x70) + * - Get driver function info (getCurrentDriverFunction x2) + * - Optionally get texture size if flag 0x40 set + * - Call FindCharacterInFont(this, charCode) to locate glyph in font + * - Compute width: (float)textureSize * this->+0x188 + FindCharacterInFont_result + * + * 4. If glyph entry doesn't exist yet: + * - Initialize hash table if needed (mask=-1 -> set mask=3, resize to 4 buckets) + * - Allocate new entry via AllocateCharacterHashEntry + * - Insert into hash chain via vtable call and KerningListSpliceOperation + * - Store charCode and combined key in entry + * + * 5. Set rendered flag (|= 2), call roundFloatWithValidation on width, + * store at entry+0x1C, return via ST(0). + * + * ========================================================================== + * PERFORMANCE NOTES + * ========================================================================== + * + * The hot path is the hash lookup (step 2). When the glyph is already cached + * and rendered (flags & 2), it returns immediately at 0x5ca497-0x5ca4a0. + * The hash uses a simple mask-and-chain approach with 0xC-byte bucket entries. + * + * At 3.65% CPU, the bottleneck is likely: + * - Hash chain traversal (cache misses on linked list walk) + * - Called extremely frequently (6 callers, all text measurement/rendering) + * - The hash table starts small (4 buckets) and may have long chains + * + * ========================================================================== + * GHIDRA DECOMPILATION (raw, for reference) + * ========================================================================== + */ + +/* NOTE: Ghidra's decompiled output below is misleading in several ways: + * 1. float10/__return_storage_ptr__ types are artifacts of FPU return + * 2. The "param_1" and "param_2" naming doesn't match stack layout + * 3. Some pointer arithmetic is obfuscated by Ghidra's type inference + * + * Corrected prototype: + * float __thiscall GetOrCreateCharacterGlyph(FontObject* this, uint32 charCode, uint32 param2) + * Returns: float in ST(0), glyph entry ptr in EDI + */ + +float10 * __thiscall +GetOrCreateCharacterGlyph + (void *this,float10 *__return_storage_ptr__,float10 *param_1,float10 *param_2) +{ + float10 **ppfVar1; + float10 *pfVar2; + undefined *puVar3; + undefined *puVar4; + float10 *pfVar5; + undefined4 unaff_ESI; + uint unaff_EDI; + uint uVar6; + float10 extraout_ST0; + float10 extraout_ST0_00; + undefined *local_18; + undefined *local_10; + undefined *local_c; + undefined *local_8; + + pfVar2 = __return_storage_ptr__; + local_10 = (undefined *)((uint)param_1 & 0xffff ^ (int)__return_storage_ptr__ << 0x10); + if (*(uint *)((int)this + 0x60) != 0xffffffff) { + pfVar5 = *(float10 **) + (*(int *)((int)this + 0x58) + + (*(uint *)((int)this + 0x60) & (uint)__return_storage_ptr__) * 0xc + 8); + if ((((uint)pfVar5 & 1) != 0) || (pfVar5 == (float10 *)0x0)) { + pfVar5 = (float10 *)0x0; + } + for (; (((uint)pfVar5 & 1) == 0 && (pfVar5 != (float10 *)0x0)); + pfVar5 = *(float10 **) + ((int)pfVar5 + + *(int *)(*(int *)((int)this + 0x58) + + (*(uint *)((int)this + 0x60) & (uint)__return_storage_ptr__) * 0xc) + 4)) + { + if (*(float10 **)pfVar5 == __return_storage_ptr__) { + ppfVar1 = (float10 **)((int)pfVar5 + 0x14); + if ((ppfVar1 == &__return_storage_ptr__) || (*ppfVar1 == (float10 *)local_10)) { + __return_storage_ptr__ = pfVar5; + if ((*(byte *)((int)pfVar5 + 0x18) & 2) != 0) { + return (float10 *)ppfVar1; + } + goto LAB_005ca30c; + } + } + } + } + __return_storage_ptr__ = (float10 *)0x0; +LAB_005ca30c: + local_8 = (undefined *)this; + puVar3 = GetTextStringData(*(int *)((int)this + 0x70)); + local_c = getCurrentDriverFunction((int)puVar3); + puVar4 = getCurrentDriverFunction((int)puVar3); + local_18 = (undefined *)0x0; + if ((puVar3[8] & 0x40) != 0) { + getTextureSize(puVar3,puVar4,2,(uint *)&local_18); + local_18 = (undefined *)((-1 < (int)local_18) - 1 & (uint)local_18); + } + puVar3 = local_8; + FindCharacterInFont(local_8,pfVar2,unaff_EDI); + pfVar5 = __return_storage_ptr__; + local_c = (undefined *) + (float)((float10)(int)local_18 * (float10)*(float *)(puVar3 + 0x188) + extraout_ST0); + if (__return_storage_ptr__ == (float10 *)0x0) { + if (*(int *)((int)this + 0x60) == -1) { + *(undefined4 *)((int)this + 0x60) = 3; + ResizeKerningHashTable((void *)((int)this + 0x50),4); + param_1 = pfVar5; + uVar6 = 0; + do { + ClearAndResizeKerningList((void *)(*(int *)((int)this + 0x58) + (int)param_1),4); + uVar6 = uVar6 + 1; + param_1 = (float10 *)((int)param_1 + 0xc); + } while (uVar6 <= *(uint *)((int)this + 0x60)); + } + uVar6 = *(uint *)((int)this + 0x60) & (uint)pfVar2; + puVar3 = AllocateCharacterHashEntry((int *)((int)this + 0x3c),uVar6); + if (puVar3 != (undefined *)0x0) { + uVar6 = *(uint *)((int)this + 0x60) & (uint)pfVar2; + } + pfVar5 = (float10 *) + (**(code **)(*(int *)((int)this + 0x3c) + 4)) + (*(int *)((int)this + 0x58) + uVar6 * 0xc,0); + KerningListSpliceOperation((void *)((int)this + 0x40),(int *)pfVar5,2,0); + *(float10 **)pfVar5 = pfVar2; + __return_storage_ptr__ = pfVar5; + if ((float10 **)((int)pfVar5 + 0x14) != ¶m_1) { + *(float10 **)((int)pfVar5 + 0x14) = (float10 *)local_10; + } + } + pfVar2 = __return_storage_ptr__; + *(uint *)((int)__return_storage_ptr__ + 0x18) = *(uint *)((int)__return_storage_ptr__ + 0x18) | 2; + pfVar5 = roundFloatWithValidation + (SUB84((double)(float)local_c,0),(double)CONCAT44(unaff_ESI,unaff_EDI)); + *(float *)((int)pfVar2 + 0x1c) = (float)extraout_ST0_00; + return pfVar5; +} + + +/* + * ========================================================================== + * FULL DISASSEMBLY + * ========================================================================== + */ + +/* +0x005ca2d0 PUSH EBP +0x005ca2d1 MOV EBP,ESP +0x005ca2d3 SUB ESP,0x14 +0x005ca2d6 PUSH EBX +0x005ca2d7 MOV EBX,dword ptr [EBP + 0x8] ; EBX = charCode (param1) +0x005ca2da MOV EAX,EBX +0x005ca2dc SHL EAX,0x10 ; EAX = charCode << 16 +0x005ca2df PUSH ESI +0x005ca2e0 MOV ESI,dword ptr [EBP + 0xc] ; ESI = param2 +0x005ca2e3 MOV EDX,EAX +0x005ca2e5 XOR EDX,ESI ; EDX = (charCode<<16) ^ param2 +0x005ca2e7 AND EDX,0xffff ; EDX = low 16 bits +0x005ca2ed XOR EDX,EAX ; EDX = combinedKey = (charCode<<16) | (param2 ^ charCode) & 0xFFFF +0x005ca2ef MOV EAX,dword ptr [ECX + 0x60] ; EAX = this->hashMask +0x005ca2f2 CMP EAX,-0x1 ; if hashMask == -1 (uninitialized) +0x005ca2f5 LEA ESI,[ECX + 0x3c] ; ESI = &this->glyphAllocator +0x005ca2f8 PUSH EDI +0x005ca2f9 MOV dword ptr [EBP + -0x4],ECX ; local_this = ECX +0x005ca2fc MOV dword ptr [EBP + -0xc],EDX ; local_combinedKey = EDX +0x005ca2ff JNZ 0x005ca434 ; if initialized, jump to hash lookup + +; --- Hash not initialized or entry not found: compute glyph --- +0x005ca305 MOV dword ptr [EBP + 0x8],0x0 ; foundEntry = NULL +0x005ca30c MOV ECX,dword ptr [EBP + -0x4] ; ECX = this +0x005ca30f MOV ECX,dword ptr [ECX + 0x70] ; ECX = this->textStringDataIdx +0x005ca312 CALL 0x005d0370 ; GetTextStringData(ECX) -> EAX = textData +0x005ca317 MOV EDI,EAX ; EDI = textData +0x005ca319 MOV EDX,EBX ; EDX = charCode (2nd param for fastcall) +0x005ca31b MOV ECX,EDI ; ECX = textData (this for thiscall) +0x005ca31d CALL 0x007ce960 ; getCurrentDriverFunction(textData, charCode) -> EAX +0x005ca322 MOV EDX,dword ptr [EBP + 0xc] ; EDX = param2 +0x005ca325 MOV ECX,EDI ; ECX = textData +0x005ca327 MOV dword ptr [EBP + -0x8],EAX ; local_driverResult1 = EAX +0x005ca32a CALL 0x007ce960 ; getCurrentDriverFunction(textData, param2) -> EAX +0x005ca32f MOV dword ptr [EBP + -0x14],0x0 ; textureSize = 0 +0x005ca336 TEST byte ptr [EDI + 0x8],0x40 ; if textData->flags & 0x40 +0x005ca33a JZ 0x005ca35d ; skip texture size query + +; --- Get texture size (optional) --- +0x005ca33c LEA EDX,[EBP + -0x14] +0x005ca33f PUSH EDX ; &textureSize (out param) +0x005ca340 MOV EDX,dword ptr [EBP + -0x8] ; driverResult1 +0x005ca343 PUSH 0x2 ; dimension=2 +0x005ca345 PUSH EAX ; driverResult2 +0x005ca346 MOV ECX,EDI ; textData +0x005ca348 CALL 0x007ce830 ; getTextureSize(textData, dr2, 2, &textureSize) +0x005ca34d MOV EAX,dword ptr [EBP + -0x14] +0x005ca350 XOR ECX,ECX +0x005ca352 TEST EAX,EAX +0x005ca354 SETGE CL ; CL = (textureSize >= 0) ? 1 : 0 +0x005ca357 DEC ECX ; ECX = (>=0) ? 0 : -1 +0x005ca358 AND ECX,EAX ; ECX = (>=0) ? 0 : textureSize (clamp negative to 0... wait) + ; Actually: SETGE+DEC gives 0 if >=0, -1 if <0 + ; AND with EAX: >=0 -> 0&val=0... NO: + ; SETGE CL: >=0 -> CL=1, DEC -> 0, AND -> 0 + ; <0 -> CL=0, DEC -> -1(0xFFFFFFFF), AND -> EAX + ; So this CLAMPS POSITIVE TO ZERO, keeps negative? + ; That seems wrong. Let me re-read... + ; Actually SETGE = set if SF==OF. For TEST EAX,EAX: + ; EAX >= 0 -> SF=0,OF=0 -> GE true -> CL=1 + ; EAX < 0 -> SF=1,OF=0 -> GE false -> CL=0 + ; So: >=0: CL=1, DEC->0, AND EAX -> 0. WRONG. + ; <0: CL=0, DEC->0xFFFFFFFF, AND EAX -> EAX. + ; This clamps positive values to 0 and keeps negative?? + ; More likely: the intent is max(0, textureSize). + ; Re-examining: TEST EAX,EAX / SETGE CL / DEC ECX / AND ECX,EAX + ; If EAX >= 0: ECX = (1-1) & EAX = 0 & EAX = 0 -- WRONG for max(0,x) + ; If EAX < 0: ECX = (0-1) & EAX = 0xFFFFFFFF & EAX = EAX -- keeps negative + ; This is actually: (EAX < 0) ? EAX : 0 i.e. min(0, EAX) + ; Which means textureSize = min(0, textureSize). Clamp positive to 0. + ; Likely the texture size is a signed delta/offset that should be <= 0. +0x005ca35a MOV dword ptr [EBP + -0x14],ECX + +; --- Find character in font --- +0x005ca35d MOV EDI,dword ptr [EBP + -0x4] ; EDI = this +0x005ca360 PUSH EBX ; charCode +0x005ca361 MOV ECX,EDI ; this +0x005ca363 CALL 0x005ca240 ; FindCharacterInFont(this, charCode) -> ST(0) = glyph advance +0x005ca368 FILD dword ptr [EBP + -0x14] ; push (float)textureSize onto FPU +0x005ca36b FMUL float ptr [EDI + 0x188] ; * this->textureScale +0x005ca371 MOV EDI,dword ptr [EBP + 0x8] ; EDI = foundEntry (from hash lookup or NULL) +0x005ca374 TEST EDI,EDI +0x005ca376 FADDP ; ST(0) = FindCharacterInFont_result + textureSize * textureScale +0x005ca378 FSTP float ptr [EBP + -0x8] ; local_width = ST(0) +0x005ca37b JNZ 0x005ca40e ; if foundEntry != NULL, skip allocation + +; --- Allocate new hash entry --- +0x005ca381 CMP dword ptr [ESI + 0x24],-0x1 ; this->hashMask == -1? +0x005ca385 JNZ 0x005ca3c0 ; if initialized, skip init +0x005ca387 PUSH 0x4 +0x005ca389 LEA ECX,[ESI + 0x14] ; &this->kerningHashTable +0x005ca38c MOV dword ptr [ESI + 0x24],0x3 ; hashMask = 3 (4 buckets) +0x005ca393 CALL 0x005cc730 ; ResizeKerningHashTable(&hashTable, 4) +0x005ca398 MOV dword ptr [EBP + 0xc],EDI ; counter = 0 (EDI was 0) +0x005ca39b NOP +0x005ca39c LEA ESP,[ESP] ; alignment padding + +; --- Initialize each bucket --- +0x005ca3a0 MOV ECX,dword ptr [ESI + 0x1c] ; bucketArray +0x005ca3a3 MOV EAX,dword ptr [EBP + 0xc] ; offset +0x005ca3a6 PUSH 0x4 +0x005ca3a8 ADD ECX,EAX ; &bucketArray[offset] +0x005ca3aa CALL 0x005cbd50 ; ClearAndResizeKerningList(bucket, 4) +0x005ca3af MOV ECX,dword ptr [EBP + 0xc] +0x005ca3b2 MOV EAX,dword ptr [ESI + 0x24] ; hashMask +0x005ca3b5 INC EDI ; counter++ +0x005ca3b6 ADD ECX,0xc ; offset += 0xC (bucket size) +0x005ca3b9 CMP EDI,EAX ; counter <= hashMask +0x005ca3bb MOV dword ptr [EBP + 0xc],ECX +0x005ca3be JBE 0x005ca3a0 ; loop + +; --- Allocate entry in hash --- +0x005ca3c0 MOV EDI,dword ptr [ESI + 0x24] ; hashMask +0x005ca3c3 AND EDI,EBX ; bucketIdx = hashMask & charCode +0x005ca3c5 PUSH EDI +0x005ca3c6 MOV ECX,ESI ; &glyphAllocator +0x005ca3c8 CALL 0x005cc290 ; AllocateCharacterHashEntry(&allocator, bucketIdx) +0x005ca3cd TEST EAX,EAX +0x005ca3cf JZ 0x005ca3d6 +0x005ca3d1 MOV EDI,dword ptr [ESI + 0x24] ; re-read hashMask (may have changed due to resize) +0x005ca3d4 AND EDI,EBX ; bucketIdx = hashMask & charCode + +0x005ca3d6 MOV ECX,dword ptr [ESI + 0x1c] ; bucketArray +0x005ca3d9 MOV EDX,dword ptr [ESI] ; vtable +0x005ca3db PUSH 0x0 +0x005ca3dd LEA EAX,[EDI + EDI*0x2] ; EAX = bucketIdx * 3 +0x005ca3e0 LEA EAX,[ECX + EAX*0x4] ; EAX = &bucketArray[bucketIdx * 0xC] +0x005ca3e3 PUSH 0x0 +0x005ca3e5 PUSH EAX ; bucket ptr +0x005ca3e6 MOV ECX,ESI ; &glyphAllocator +0x005ca3e8 CALL dword ptr [EDX + 0x4] ; vtable[1](allocator, bucket, 0, 0) -> new entry +0x005ca3eb PUSH 0x0 +0x005ca3ed MOV EDI,EAX ; EDI = newEntry +0x005ca3ef PUSH 0x2 +0x005ca3f1 PUSH EDI +0x005ca3f2 LEA ECX,[ESI + 0x4] ; &this->linkedList +0x005ca3f5 CALL 0x005cc4d0 ; KerningListSpliceOperation(&list, newEntry, 2, 0) +0x005ca3fa LEA EAX,[EDI + 0x14] ; &newEntry->combinedKey +0x005ca3fd LEA ECX,[EBP + 0xc] ; ¶m2 on stack (self-pointer check) +0x005ca400 CMP EAX,ECX +0x005ca402 MOV dword ptr [EDI],EBX ; newEntry->charCode = charCode +0x005ca404 JZ 0x005ca40b ; skip if self-referencing +0x005ca406 MOV EDX,dword ptr [EBP + -0xc] ; combinedKey +0x005ca409 MOV dword ptr [EAX],EDX ; newEntry->combinedKey = combinedKey +0x005ca40b MOV dword ptr [EBP + 0x8],EDI ; foundEntry = newEntry + +; --- Store width and return --- +0x005ca40e MOV EDX,dword ptr [EDI + 0x18] ; entry->flags +0x005ca411 FLD float ptr [EBP + -0x8] ; load computed width +0x005ca414 OR EDX,0x2 ; flags |= RENDERED +0x005ca417 SUB ESP,0x8 +0x005ca41a FSTP double ptr [ESP] ; push as double (for roundFloatWithValidation) +0x005ca41d MOV dword ptr [EDI + 0x18],EDX ; entry->flags = flags | RENDERED +0x005ca420 CALL 0x0073fdf5 ; roundFloatWithValidation(width_as_double) +0x005ca425 FST float ptr [EDI + 0x1c] ; entry->width = rounded result +0x005ca428 ADD ESP,0x8 ; clean up double param +0x005ca42b POP EDI +0x005ca42c POP ESI +0x005ca42d POP EBX +0x005ca42e MOV ESP,EBP +0x005ca430 POP EBP +0x005ca431 RET 0x8 ; clean 2 dword params, return ST(0) + +; --- Hash lookup (jumped to from 0x5ca2ff when hash is initialized) --- +0x005ca434 MOV ECX,dword ptr [ESI + 0x1c] ; bucketArray +0x005ca437 AND EAX,EBX ; bucketIdx = hashMask & charCode +0x005ca439 LEA EAX,[EAX + EAX*0x2] ; * 3 +0x005ca43c LEA EAX,[ECX + EAX*0x4 + 0x4] ; &bucket[idx].firstEntry (skip +0 next-link offset) + ; Actually: bucket base + idx*0xC + 0x4 +0x005ca440 MOV ECX,dword ptr [EAX + 0x4] ; ECX = bucket[idx]+0x8 = first entry ptr +0x005ca443 TEST CL,0x1 ; sentinel check (low bit) +0x005ca446 JNZ 0x005ca44c +0x005ca448 TEST ECX,ECX ; NULL check +0x005ca44a JNZ 0x005ca450 +0x005ca44c XOR ECX,ECX ; entry = NULL +0x005ca44e MOV EDI,EDI ; NOP (alignment) + +; --- Walk hash chain --- +0x005ca450 TEST CL,0x1 ; sentinel? +0x005ca453 JNZ 0x005ca305 ; -> not found, go compute +0x005ca459 TEST ECX,ECX ; NULL? +0x005ca45b JZ 0x005ca305 ; -> not found, go compute +0x005ca461 CMP dword ptr [ECX],EBX ; entry->charCode == charCode? +0x005ca463 JNZ 0x005ca473 ; no match, next entry +0x005ca465 LEA EAX,[ECX + 0x14] ; &entry->combinedKey +0x005ca468 LEA EDI,[EBP + 0x8] ; ¶m1 on stack (self-ref check) +0x005ca46b CMP EAX,EDI +0x005ca46d JZ 0x005ca48a ; self-ref -> match +0x005ca46f CMP dword ptr [EAX],EDX ; entry->combinedKey == combinedKey? +0x005ca471 JZ 0x005ca48a ; match! + +; --- Next entry in chain --- +0x005ca473 MOV EAX,dword ptr [ESI + 0x24] ; hashMask +0x005ca476 MOV EDI,dword ptr [ESI + 0x1c] ; bucketArray +0x005ca479 AND EAX,EBX ; bucketIdx +0x005ca47b LEA EAX,[EAX + EAX*0x2] ; * 3 +0x005ca47e LEA EAX,[EDI + EAX*0x4] ; &bucket[idx] +0x005ca481 MOV EAX,dword ptr [EAX] ; bucket[idx].nextOffset +0x005ca483 ADD EAX,ECX ; entry + nextOffset +0x005ca485 MOV ECX,dword ptr [EAX + 0x4] ; next entry ptr +0x005ca488 JMP 0x005ca450 ; continue chain walk + +; --- Found cached entry --- +0x005ca48a TEST byte ptr [ECX + 0x18],0x2 ; entry->flags & RENDERED? +0x005ca48e MOV dword ptr [EBP + 0x8],ECX ; foundEntry = entry +0x005ca491 JZ 0x005ca30c ; not rendered yet, go compute width +0x005ca497 FLD float ptr [ECX + 0x1c] ; ST(0) = entry->width (CACHED -- HOT PATH) +0x005ca49a POP EDI +0x005ca49b POP ESI +0x005ca49c POP EBX +0x005ca49d MOV ESP,EBP +0x005ca49f POP EBP +0x005ca4a0 RET 0x8 ; return cached width +*/ + + +/* + * ========================================================================== + * XREFS TO 0x005CA2D0 (callers -- 6 call sites) + * ========================================================================== + * + * 0x005c73b9 from MeasureTextLine [UNCONDITIONAL_CALL] + * 0x005c6be6 from MeasureTextWithFormatting [UNCONDITIONAL_CALL] + * 0x005c75cd from WrapTextWithLineBreaks [UNCONDITIONAL_CALL] + * 0x005ccda4 from RenderTextToVertexBuffer [UNCONDITIONAL_CALL] + * 0x005c6a74 from CalculateTextDimensions [UNCONDITIONAL_CALL] + * 0x005c6e44 from MeasureWrappedText [UNCONDITIONAL_CALL] + */ + + +/* + * ========================================================================== + * XREFS FROM 0x005CA2D0 (callees -- function calls only) + * ========================================================================== + * + * 0x005ca312 -> GetTextStringData (0x005d0370) [CALL] + * 0x005ca31d -> getCurrentDriverFunction (0x007ce960) [CALL] -- called twice (0x31d, 0x32a) + * 0x005ca348 -> getTextureSize (0x007ce830) [CALL] + * 0x005ca363 -> FindCharacterInFont (0x005ca240) [CALL] + * 0x005ca393 -> ResizeKerningHashTable (0x005cc730) [CALL] + * 0x005ca3aa -> ClearAndResizeKerningList (0x005cbd50) [CALL] + * 0x005ca3c8 -> AllocateCharacterHashEntry (0x005cc290) [CALL] + * 0x005ca3e8 -> vtable[1] indirect call (allocator virtual method) + * 0x005ca3f5 -> KerningListSpliceOperation (0x005cc4d0) [CALL] + * 0x005ca420 -> roundFloatWithValidation (0x0073fdf5) [CALL] + */ diff --git a/src/transform44/transform44.zig b/src/transform44/transform44.zig index f163451..eb5150c 100644 --- a/src/transform44/transform44.zig +++ b/src/transform44/transform44.zig @@ -91,6 +91,8 @@ const ProfState = struct { clip_cycles: u64 = 0, glyph_calls: u64 = 0, // GetOrCreateCharacterGlyph (0x5ca2d0) 3.65% glyph_cycles: u64 = 0, + glyph_hits: u64 = 0, // shadow cache hits (custom mode only) + glyph_misses: u64 = 0, // shadow cache misses (custom mode only) particle_calls: u64 = 0, // RenderParticleSprites (0x7b2a50) 1.73% particle_cycles: u64 = 0, collision_calls: u64 = 0, // processLinkedListCollision (0x6abc40) 1.57% @@ -155,6 +157,24 @@ const ProfState = struct { textline_cycles: u64 = 0, }; +// ============================================================================= +// Glyph shadow cache — direct-mapped, bypasses game's 4-bucket hash table. +// Key: (font_ptr, char_code, param2). O(1) lookup, no pointer chasing. +// ============================================================================= + +const GLYPH_CACHE_SHIFT = 12; +const GLYPH_CACHE_SIZE = 1 << GLYPH_CACHE_SHIFT; // 4096 entries +const GLYPH_CACHE_MASK = GLYPH_CACHE_SIZE - 1; + +const GlyphCacheEntry = struct { + font_ptr: u32 = 0, + char_code: u32 = 0, + param2: u32 = 0, + width_bits: u32 = 0, // f32 stored as u32 bits +}; + +var glyph_cache: [GLYPH_CACHE_SIZE]GlyphCacheEntry = [_]GlyphCacheEntry{.{}} ** GLYPH_CACHE_SIZE; + inline fn rdtsc() u64 { var lo: u32 = undefined; var hi: u32 = undefined; @@ -749,7 +769,48 @@ fn clipDetour(a: u32, b: u32, c: u32) callconv(hook.cc.fastcall) ?*anyopaque { return ret; } fn glyphDetour(a: u32, b: u32, c: u32, d: u32) callconv(hook.cc.fastcall) ?*anyopaque { + asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true }); + const s = rdtsc(); + + if (ab_use_custom) { + // a=ECX=FontObject*, b=EDX=unused, c=charCode, d=param2 + const hash = std.hash.Murmur2_32.hashUint32WithSeed(c, a ^ d) & GLYPH_CACHE_MASK; + const entry = &glyph_cache[hash]; + + if (entry.font_ptr == a and entry.char_code == c and entry.param2 == d) { + // Cache hit — load cached width into ST(0) for caller + asm volatile ("flds (%[p])" + :: [p] "r" (&entry.width_bits) + ); + prof.glyph_hits +|= 1; + prof.glyph_cycles +|= rdtsc() - s; + prof.glyph_calls +|= 1; + return null; // EAX unused by callers, they read ST(0) + } + + // Cache miss — call original (sets ST(0)), then capture the result + const ret = glyph_hook.callOriginal(.{ a, b, c, d }); + + // Read ST(0) without popping — original's float return is still on FPU stack + var width_bits: u32 = undefined; + asm volatile ("fsts (%[p])" + :: [p] "r" (&width_bits) + ); + + entry.* = .{ + .font_ptr = a, + .char_code = c, + .param2 = d, + .width_bits = width_bits, + }; + + prof.glyph_misses +|= 1; + prof.glyph_cycles +|= rdtsc() - s; + prof.glyph_calls +|= 1; + return ret; + } + const ret = glyph_hook.callOriginal(.{ a, b, c, d }); prof.glyph_cycles +|= rdtsc() - s; prof.glyph_calls +|= 1; @@ -1190,6 +1251,17 @@ fn dumpStats() void { } } + // Glyph cache stats (custom mode only) + const glyph_total = prof.glyph_hits +| prof.glyph_misses; + if (glyph_total > 0) { + const hit_pct = pct(prof.glyph_hits, glyph_total); + log.fmt(" glyph_cache: {d}.{d}% hit ({d}hit/{d}miss)\n", .{ + hit_pct / 10, hit_pct % 10, + prof.glyph_hits, + prof.glyph_misses, + }); + } + // Flip A/B mode for next period ab_use_custom = !ab_use_custom; prof = ProfState{};