Add transform44 profiling hook and inner function analysis
Phase 1 profiling: hooks transformMatrix4x4 (0x714260) to measure call frequency, early-exit rate, cycle cost, bone counts, and recursion depth. Dumps stats every 500 calls. Decompiled and analyzed all 11 inner functions. Key findings: - findInterpolationIndices already has good temporal coherence (linear scan) - Matrix math (scale, translate) uses x87 FPU — SSE candidates - Game already has SSE matrix multiply used by rotateMatrixByQuaternion - interpolateAnimationKeyframes does 4-component lerp — textbook SSE
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user