diff --git a/src/transform44/RESEARCH.md b/src/transform44/RESEARCH.md index 01b3e1a..054237e 100644 --- a/src/transform44/RESEARCH.md +++ b/src/transform44/RESEARCH.md @@ -144,3 +144,89 @@ From `this->unknown_0x80` array. Contains: 3. **Recursive for attachments**: Child objects (weapons, shoulders, etc.) recurse through this same function 4. **Two animation blend sources**: Primary animation + blend target with crossfade weight at puVar20[0x43] 5. **Billboard support**: Flags-based billboard types for UI/particle-facing bones + +## Inner Function Analysis (decompiled 2026-03-12) + +### findInterpolationIndices (0x713d50) — 334 bytes, 58 calls +**Signature**: `__thiscall(ECX=SceneObject*, stack: searchValue, trackIndex, AnimationData*, outputIndices*)` +**RET 0x10** + +Three-tier search strategy with temporal coherence: +1. **Forward linear scan** (hot path): If `searchValue - lastTimestamp < 500`, scan forward from cached position. This is the common case during sequential animation playback — typically 0-4 iterations. +2. **Backward linear scan**: If delta is negative (unsigned wrap > 0xFFFFFF0C), scan backward. +3. **Binary search** (fallback): Standard bisection on timestamp array. + +Output: `outputIndices[0]` = lower keyframe index, `[1]` = upper keyframe index, `[2]` = interpolation factor (float stored as uint bits). + +The cached index at `outputIndices[0]` is reused across calls — exploits the fact that animation time advances monotonically between frames. + +**Optimization potential**: Limited — the linear scan hot path is already tight (1-4 iterations for most bones). SSE4-wide timestamp comparison might help for binary search fallback, but that path is rarely hit during normal playback. + +### interpolateAnimationKeyframes (0x713ea0) — 337 bytes, 2 calls +**Signature**: `__fastcall(ECX=animObj, EDX=animState, stack: keyframeData*, outputBuffer*)` +**RET 0x8** + +Calls findInterpolationIndices, then does 4-component lerp (vec4/quaternion). If crossfade is active (blend weight != 0 and timeIndex == -1), does a secondary findInterpolationIndices + lerp + blend. + +Keyframes are 16 bytes (4 floats). Interpolation: `result[i] = a[i] + (b[i] - a[i]) * t`. + +**Optimization**: The 4-component lerp is a textbook SSE target — one load, one sub, one mul, one add replaces 4 scalar x87 operations. + +### getInterpolatedFloat (0x71af20) — 199 bytes, 4 calls +**Signature**: `__fastcall(ECX=animObj, EDX=animState, stack: keyframeData*, outputBuffer*)` +**RET 0x8** + +Same pattern as interpolateAnimationKeyframes but for scalar (single float) tracks. Also supports crossfade blending. + +### getIndexOffset (0x71aff0) — 16 bytes, 12 calls +Trivial: `return *(this+4) + param_1 * 2`. Returns pointer to short value in timestamp index array. + +### setShortValue (0x71b010) — 18 bytes, 12 calls +Trivial: `*(short*)this = *(short*)param_1`. Copies a 16-bit value. + +### scaleMatrix3x3ByVector (0x7bdca0) — 82 bytes, 2 calls +**Signature**: `__thiscall(ECX=matrix, stack: scaleVec3*)` +Scales each row of the 3x3 rotation portion of a 4x4 matrix by the corresponding scale component: +``` +row0 *= scale.x (3 muls) +row1 *= scale.y (3 muls) +row2 *= scale.z (3 muls) +``` +Uses x87 FPU. **SSE candidate**: 3 shuffled multiplies instead of 9 scalar. + +### ApplyTranslationMatrix (0x7bdc40) — 90 bytes, 5 calls +**Signature**: `__thiscall(ECX=matrix, stack: translationVec3*)` +Applies translation through the rotation matrix: +``` +mat[3][0] += dot(mat[0], translation) +mat[3][1] += dot(mat[1], translation) +mat[3][2] += dot(mat[2], translation) +``` +Uses x87 FPU. **SSE candidate**: 3 dot products → SSE dp_ps or manual mul+hadd. + +### rotateMatrixByQuaternion (0x7bddb0) — 333 bytes, 1 call +**Signature**: `__thiscall(ECX=matrix, stack: quaternion*)` +Converts quaternion to 3x3 rotation matrix, then calls `multiplyMatrix4x4_SSE_Optimized` (game already has SSE matrix multiply!). The quaternion→matrix conversion uses x87 but the final multiply is SSE. + +### calculateScaledInverseMatrix (0x7bd820) — 347 bytes, 1 call +Used for billboarding. Transposes the 3x3 rotation, scales by 1/scale², applies inverse translation. + +## Optimization Strategy + +### What we know +- The game already uses SSE for matrix multiplication (multiplyMatrix4x4_SSE_Optimized) +- All other math (scale, translate, interpolate) uses x87 FPU +- findInterpolationIndices has good temporal coherence — hot path is already fast +- The 58 findInterpolationIndices calls are spread across translation, rotation, scale tracks for each bone + +### Priority targets (by impact) +1. **Profile first** — need real data on call frequency, early-exit ratio, cycles per call, bone counts +2. **LOD-based culling** — skip entire transformMatrix4x4 for distant/tiny models (biggest potential win) +3. **SSE interpolateAnimationKeyframes** — replace 4-component lerp with SSE (called per bone for rotation) +4. **SSE scaleMatrix3x3ByVector / ApplyTranslationMatrix** — replace x87 with SSE +5. **Batch findInterpolationIndices** — process multiple tracks per bone in one call to amortize function overhead + +### Implementation plan +- Phase 1: Profiling hook on transformMatrix4x4 (DONE — in transform44.zig) +- Phase 2: Analyze profiling data, identify hottest path +- Phase 3: Implement targeted SSE replacements or LOD culling diff --git a/src/transform44/transform44.zig b/src/transform44/transform44.zig index 9882dc4..2c46d83 100644 --- a/src/transform44/transform44.zig +++ b/src/transform44/transform44.zig @@ -1,7 +1,21 @@ -//! transform44 — hook for transformMatrix4x4 (0x714260) +//! transform44 — profiling & optimization of transformMatrix4x4 (0x714260) //! -//! transformMatrix4x4 is the main per-frame bone transform engine for M2 models. -//! 17703 bytes, processes bone entries (0x118 bytes each, array at model+0x90). +//! transformMatrix4x4 is the main per-frame bone transform engine for ALL visible +//! M2 models. 17703 bytes. Called from renderFrame, renderSceneNode, +//! updateAnimationTransform. Recursive for attached child objects (weapons, etc). +//! +//! Phase 1: Profiling instrumentation — measures call frequency, early-exit rate, +//! cycle cost, and bone counts to identify optimization targets. +//! +//! Key inner functions (call counts within transformMatrix4x4): +//! findInterpolationIndices (0x713d50) — 58 calls, binary/linear keyframe search +//! interpolateAnimationKeyframes (0x713ea0) — 2 calls, vec4 keyframe lerp +//! getInterpolatedFloat (0x71af20) — 4 calls, scalar keyframe lerp +//! getIndexOffset (0x71aff0) — 12 calls, trivial (ptr + idx*2) +//! setShortValue (0x71b010) — 12 calls, trivial (write u16) +//! scaleMatrix3x3ByVector (0x7bdca0) — 2 calls, x87 FPU (SSE candidate) +//! ApplyTranslationMatrix (0x7bdc40) — 5 calls, x87 FPU (SSE candidate) +//! rotateMatrixByQuaternion (0x7bddb0) — 1 call, builds rot matrix then SSE multiply const std = @import("std"); const hook = @import("zhook"); @@ -18,6 +32,108 @@ pub fn isActive() bool { return g_is_hook_owner; } +// ============================================================================= +// Profiling state +// ============================================================================= + +const DUMP_INTERVAL: u32 = 500; + +var prof = ProfState{}; + +const ProfState = struct { + calls: u32 = 0, + early_exits: u32 = 0, + cycles: u64 = 0, + total_bones: u64 = 0, + max_bones: u32 = 0, + max_depth: u32 = 0, + depth: u32 = 0, +}; + +inline fn rdtsc() u64 { + var lo: u32 = undefined; + var hi: u32 = undefined; + asm volatile ("rdtsc" + : [lo] "={eax}" (lo), + [hi] "={edx}" (hi), + ); + return @as(u64, hi) << 32 | lo; +} + +// ============================================================================= +// Hook: transformMatrix4x4 (0x714260) +// __thiscall(ECX=SceneObject*, stack: Matrix4x4*, Matrix4x4*, Matrix4x4*, Matrix4x4*) +// RET 0x10 (4 stack params × 4 bytes) +// +// Mapped to fastcall: ECX=this, EDX=unused, stack: mat1, mat2, mat3, mat4 +// Stack cleanup is identical (4 stack params = RET 0x10 in both conventions). +// ============================================================================= + +const TRANSFORM_ADDR: u32 = 0x714260; + +const TransformFn = fn (u32, u32, u32, u32, u32, u32) callconv(hook.cc.fastcall) void; + +var transform_hook: hook.Detour(TransformFn) = .{}; + +fn transformDetour(this: u32, edx: u32, mat1: u32, mat2: u32, mat3: u32, mat4: u32) callconv(hook.cc.fastcall) void { + asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true }); + + const start = rdtsc(); + + // Check sync gate — predict whether original will early-exit. + // Original code: if (this+0x10 == NULL || this+0x40 == *(*(this+0x30)+0x10)) return; + const model_data = hook.readMem(u32, this + 0x10); + var is_early = false; + var bone_count: u32 = 0; + if (model_data == 0) { + is_early = true; + } else { + const anim_ctx = hook.readMem(u32, this + 0x30); + if (anim_ctx != 0) { + const sync_val = hook.readMem(u32, this + 0x40); + const anim_sync = hook.readMem(u32, anim_ctx + 0x10); + if (sync_val == anim_sync) is_early = true; + } + // Read bone count from model header at this+0x2C -> +0x34 + const model_hdr = hook.readMem(u32, this + 0x2C); + if (model_hdr != 0) { + bone_count = hook.readMem(u32, model_hdr + 0x34); + } + } + + prof.depth += 1; + if (prof.depth > prof.max_depth) prof.max_depth = prof.depth; + + transform_hook.callOriginal(.{ this, edx, mat1, mat2, mat3, mat4 }); + + prof.depth -= 1; + const elapsed = rdtsc() - start; + prof.cycles +|= elapsed; + prof.calls += 1; + if (is_early) prof.early_exits += 1; + prof.total_bones +|= bone_count; + if (bone_count > prof.max_bones) prof.max_bones = bone_count; + + // Dump stats periodically (only at depth 0 to avoid spam during recursion) + if (prof.depth == 0 and prof.calls >= DUMP_INTERVAL) { + dumpStats(); + } +} + +fn dumpStats() void { + const real = prof.calls - prof.early_exits; + const avg_all = if (prof.calls > 0) prof.cycles / prof.calls else 0; + const avg_real = if (real > 0) prof.cycles / real else 0; + const avg_bones = if (real > 0) prof.total_bones / real else 0; + log.fmt("[prof] {d} calls ({d} work, {d} skip), {d}/{d} cyc avg/work, bones avg={d} max={d}, depth={d}\n", .{ + prof.calls, real, prof.early_exits, + avg_all, avg_real, avg_bones, + prof.max_bones, prof.max_depth, + }); + // Reset but preserve depth (we're always at depth 0 here) + prof = ProfState{}; +} + // ============================================================================= // Install / remove // ============================================================================= @@ -29,11 +145,13 @@ pub fn installHooks() void { if (!g_is_hook_owner) return; log = logging.Logger.open(module_name, .both); - log.print("transform44 module loaded\n"); + _ = transform_hook.attach(TRANSFORM_ADDR, &transformDetour); + log.print("transform44: profiling hook installed at 0x714260\n"); } pub fn removeHooks() void { if (g_is_hook_owner) { + transform_hook.detach(); log.close(); mod_mutex.release(&g_mutex); }