WIP: bone_sse SSE reimplementation of transformMatrix4x4 + OnWorldUpdate frame counter

bone_sse.zig: Full standalone SSE reimplementation of the 17703-byte bone
transform engine (0x714260). All helper functions reimplemented inline
(findInterpolationIndices, interpolateAnimationKeyframes, scaleMatrix3x3,
ApplyTranslation, rotateByQuaternion). Currently disabled (A/B dispatch
commented out) due to NULL ofsValues crash under investigation.

transform44.zig: Add OnWorldUpdate (0x482EA0) hook for true per-frame
counting. Previous frame counter used executeSceneRenderPass which fires
multiple times per frame (shadows, reflections, spell effects), causing
A/B periods to be as short as 0.5s during combat instead of ~30s.

SCENEOBJECT_OFFSETS.md: Complete assembly-verified field offset map
(51 offsets) extracted from [EBX+N] patterns in transformMatrix4x4.
Corrects bone_runtime_base from +0x80 to +0x090.
This commit is contained in:
MarcelineVQ
2026-03-14 00:22:36 -07:00
parent 55735d7b9e
commit 0833d23e28
3 changed files with 1758 additions and 60 deletions
+112
View File
@@ -0,0 +1,112 @@
# SceneObject Field Offsets -- Assembly-Verified
Extracted from `[EBX+N]` patterns in transformMatrix4x4 (0x714260, 17703 bytes).
EBX = this pointer (set at prologue: `MOV EBX, ECX`).
**WARNING**: Ghidra struct field names like `field_0x184` do NOT encode the actual
byte offset. The Ghidra struct has been modified over time and field names are
inconsistent with their positions. ONLY assembly `[EBX+N]` is authoritative.
**WARNING**: RESEARCH.md previously claimed bone_runtime_base at +0x80. Assembly
confirms it is actually at +0x090.
## Complete Offset Map (51 unique offsets)
| Offset | Type | Semantic Name | Evidence |
|--------|------|---------------|----------|
| +0x010 | ptr | model_data_ptr | NULL check for early bail |
| +0x02C | ptr | animation_context_ptr | +0xC=timestamp, +0x10=sync_value |
| +0x030 | ptr | model_container_ptr | +0x130=M2 header. Ghidra SWAPS label as "ptr_at_2c" |
| +0x040 | u32 | transform_sync_value | Compared with *(anim_ctx+0x10) |
| +0x04C | u32 | search_data_base_ptr | Previous timestamp for delta computation |
| +0x050 | u32 | emitter_enable_flag | Written 0 or 1 |
| +0x064 | ptr | search_data_count / gs_values_ptr | Pointer to global sequence value array |
| +0x068 | u32 | gs_time_base / position_x | Subtracted from timestamp for GS modulo |
| +0x084 | u32 | child_objects_padding | len_sq of world_transform translation row |
| +0x08C | u32 | animation_frame_counter | CMP against bone def limits for skip checks |
| +0x090 | ptr | bone_runtime_base | Array of 0x118-byte per-bone runtime structs |
| +0x094 | ptr | bone_out_ptr / transform_vec2_x | Output bone matrices (bone_idx * 0x40) |
| +0x0A0 | ptr | tex_anim_out / transform_vec2_y | Texture animation output |
| +0x0A8 | ptr | color_anim_out / transform_vec2_z | Color animation output |
| +0x0AC | f32 | scale_factor_1 | |
| +0x0B0 | f32 | scale_factor_2 | |
| +0x0B4 | f32 | scale_factor_3 | |
| +0x0BC | ptr | particle_shader_arg1 | LEA, passed to initParticlePixelShaderGeneration |
| +0x0FC | f32 | billboard_row0[0] / model_attachment_list1 | Camera forward direction X |
| +0x100 | f32 | billboard_row0[1] / unknown_0xe4 | Camera forward Y |
| +0x104 | f32 | billboard_row0[2] / attachment_list1 | Camera forward Z |
| +0x108 | f32 | billboard_row0[3] | |
| +0x10C | f32[16] | world_transform_matrix | Billboard/camera reference matrix rows 1-3 |
| +0x17C | u32 | field_17c | Copied from emitter_ctx+0x17C |
| +0x180 | f32 | field_180 | Scale multiplier for render_scale_z |
| +0x184 | f32 | field_184 | Per-axis scale factor (X) |
| +0x188 | f32 | field_188 | Per-axis scale factor (Y) / emitter intensity |
| +0x18C | f32 | global_scale_factor | Per-axis scale factor (Z) |
| +0x190 | f32 | field_190 | Used in offset computation (FADD with param_4[0]) |
| +0x194 | f32 | render_scale_x | Added to param_4[1] |
| +0x198 | f32 | render_scale_y | Added to param_4[2] |
| +0x19C | f32 | render_scale_z | Computed: param_5 * field_180 |
| +0x1A0 | f32[3] | world_position | Vec3, passed as param_3 to child recursive calls |
| +0x1AC | f32[3] | render_priority | Vec3, passed as param_4 to child recursive calls |
| +0x1C8 | ptr | hierarchy_ptr / final_world_pos_z | Attachment hierarchy data |
| +0x1CC | ptr | emitter_context_ptr | Emitter/particle context |
| +0x1D8 | u32 | field_1d8 | Read in emitter setup |
| +0x1DC | u32 | hierarchy_idx | Linked list of child SceneObjects |
| +0x200 | ptr | ribbon_emitter_out | Ribbon emitter output base |
| +0x3C4 | ptr | particle_data1 | Particle emitter data array 1 |
| +0x3C8 | ptr | particle_data2 | Particle emitter data array 2 |
| +0x3D0 | ptr | particle_data3 | Particle emitter data array 3 |
| +0x3D4 | ptr | particle_data4 | Particle emitter data array 4 |
| +0x3D8 | u32 | additional_remaining | Written 0, then OR'd with particle active flags |
## world_transform_matrix Detail (+0x10C, float[16])
Row-major 4x4. Only 3x3 rotation + translation accessed (columns 3 are 0):
| Index | Offset | Used for |
|-------|--------|----------|
| [0] | +0x10C | Row 1 col 0 (billboard) |
| [1] | +0x110 | Row 1 col 1 |
| [2] | +0x114 | Row 1 col 2 |
| [4] | +0x11C | Row 2 col 0 |
| [5] | +0x120 | Row 2 col 1 |
| [6] | +0x124 | Row 2 col 2 |
| [8] | +0x12C | Translation X (also used for child_padding len_sq) |
| [9] | +0x130 | Translation Y |
| [10]| +0x134 | Translation Z |
## Model Header Offsets (from *(*(this+0x30) + 0x130))
| Offset | Field |
|--------|-------|
| +0x14 | Global sequence count |
| +0x18 | Global sequence durations array ptr |
| +0x20 | Animation lookup table |
| +0x34 | Bone count |
| +0x38 | Bone definition array ptr |
| +0x54 | Texture animation count |
| +0x58 | Texture animation data ptr |
| +0x64 | Color animation count |
| +0x68 | Color animation data ptr |
| +0x6C | Bone keyframe count |
| +0x70 | Bone keyframe data ptr |
| +0x74 | Bone keyframe count 2 |
| +0x78 | Bone keyframe data ptr 2 |
| +0x104 | Attachment count |
| +0x108 | Attachment data ptr |
| +0x11C | Ribbon emitter count |
| +0x120 | Ribbon emitter data ptr |
| +0x124 | Particle emitter count |
| +0x128 | Particle emitter data ptr |
| +0x134 | Additional particle count |
| +0x138 | Additional particle data ptr |
| +0x13C | Final particle section count |
| +0x140 | Final particle section data ptr |
## Method
1. Ghidra headless script extracted all `[EBX + offset]` from function instructions
2. Cross-referenced with decompilation flow to assign semantic meaning
3. Assembly disassembly at key addresses confirmed field mapping
4. Ghidra struct definition found to be INCONSISTENT with assembly (field names
shifted from their actual byte positions) -- do not use Ghidra struct for offsets
File diff suppressed because it is too large Load Diff
+88 -60
View File
@@ -16,6 +16,7 @@ const hook = @import("zhook");
const logging = @import("../logging.zig");
const mod_mutex = @import("../mutex.zig");
pub const file_cache = @import("file_cache.zig");
const timer_fix = @import("timer_fix.zig");
extern fn clipPolygonToSinglePlane(u32, u32, u32) void;
extern fn buildTrianglePlanes(u32, u32, u32, u32, u32) u32;
extern fn rayTriangleIntersection(u32, u32, u32, u32, u32, u32) u32;
@@ -47,6 +48,7 @@ var last_frame_tsc: u64 = 0; // frame-to-frame TSC for total frame time
// Flips every DUMP_FRAMES so each dump period is purely one mode.
pub var ab_use_custom: bool = false;
// Persistent blit totals per A/B mode — NOT reset each dump period.
// Accumulates across all dump periods so rare blits still show up.
var blit_total_baseline: BlitAccum = .{};
@@ -58,6 +60,9 @@ const BlitAccum = struct {
};
const ProfState = struct {
// Frame timing jitter tracking
frame_min_cycles: u64 = std.math.maxInt(u64),
frame_max_cycles: u64 = 0,
// Frame counter (incremented by executeSceneRenderPass)
frames: u64 = 0,
// Total frame-to-frame wall cycles (sum of inter-frame deltas)
@@ -157,9 +162,6 @@ const ProfState = struct {
matmul_cycles: u64 = 0,
textline_calls: u64 = 0, // renderTextLine (0x5ce0c0)
textline_cycles: u64 = 0,
filefind_calls: u64 = 0, // File_FindInArchive cache (0x6549a0)
filefind_cycles: u64 = 0, // cached path
filefind_baseline_cycles: u64 = 0, // original path (same calls)
};
// =============================================================================
@@ -190,16 +192,6 @@ inline fn rdtsc() u64 {
return @as(u64, hi) << 32 | lo;
}
/// Called from main.zig fileFindDetour to accumulate per-call timing.
/// cached_cycles = time for cache path, baseline_cycles = time for original path.
pub fn addFilefindCycles(cached_cycles: u64, baseline_cycles: u64) void {
prof.filefind_cycles +|= cached_cycles;
prof.filefind_baseline_cycles +|= baseline_cycles;
prof.filefind_calls +|= 1;
}
/// Expose rdtsc for use by main.zig's fileFindDetour.
pub const rdtscPub = rdtsc;
// =============================================================================
// Hook: transformMatrix4x4 (0x714260)
@@ -291,23 +283,40 @@ var exec_render_pass_hook: hook.Detour(ExecRenderPassFn) = .{};
fn execRenderPassDetour(this: u32, edx: u32, pass_index: u32) callconv(hook.cc.fastcall) ?*anyopaque {
asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true });
// Track frame-to-frame wall time
const now = rdtsc();
if (last_frame_tsc != 0) {
prof.wall_cycles +|= now - last_frame_tsc;
}
last_frame_tsc = now;
const ret = exec_render_pass_hook.callOriginal(.{ this, edx, pass_index });
prof.erp_cycles +|= rdtsc() - now;
prof.erp_calls +|= 1;
// Use render pass as frame boundary for dump trigger
return ret;
}
// =============================================================================
// Hook: OnWorldUpdate (0x482EA0)
// __fastcall(ECX=frame_count) — fires exactly once per game frame.
// Used as the true frame counter for profiling dumps and A/B testing,
// instead of executeSceneRenderPass which fires multiple times per frame.
// =============================================================================
const WorldUpdateFn = fn (u32) callconv(hook.cc.fastcall) void;
var world_update_hook: hook.Detour(WorldUpdateFn) = .{};
fn worldUpdateDetour(frame_count: u32) callconv(hook.cc.fastcall) void {
const now = rdtsc();
if (last_frame_tsc != 0) {
const delta = now - last_frame_tsc;
prof.wall_cycles +|= delta;
if (delta < prof.frame_min_cycles) prof.frame_min_cycles = delta;
if (delta > prof.frame_max_cycles) prof.frame_max_cycles = delta;
}
last_frame_tsc = now;
world_update_hook.callOriginal(.{frame_count});
prof.frames +|= 1;
if (prof.frames >= DUMP_FRAMES) {
dumpStats();
}
return ret;
}
// =============================================================================
@@ -791,45 +800,38 @@ fn glyphDetour(a: u32, b: u32, c: u32, d: u32) callconv(hook.cc.fastcall) ?*anyo
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];
// 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)
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)
);
entry.* = .{
.font_ptr = a,
.char_code = c,
.param2 = d,
.width_bits = width_bits,
};
prof.glyph_misses +|= 1;
prof.glyph_hits +|= 1;
prof.glyph_cycles +|= rdtsc() - s;
prof.glyph_calls +|= 1;
return ret;
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;
@@ -1189,10 +1191,26 @@ fn dumpStats() void {
const mode: [*:0]const u8 = if (ab_use_custom) "CUSTOM" else "BASELINE";
// Frame jitter: convert min/max/avg from cycles to tenths-of-ms for one decimal place
const US10_DIV = 300; // cycles / 300 = tenths of us... no, cycles / 300_000 = tenths of ms
const TENTH_MS_DIV: u64 = 300_000; // @3GHz: cycles / 300_000 = tenths of ms
const frame_avg_t = if (f > 0) wall / f / TENTH_MS_DIV else 0;
const frame_min_t = if (prof.frame_min_cycles != std.math.maxInt(u64)) prof.frame_min_cycles / TENTH_MS_DIV else 0;
const frame_max_t = prof.frame_max_cycles / TENTH_MS_DIV;
_ = US10_DIV;
const bt = if (ab_use_custom) blit_total_custom else blit_total_baseline;
log.fmt(
\\[prof:{s}] {d} frames, wall={d}ms
\\[prof:{s}] {d} frames, wall={d}ms, frame: avg={d}.{d}ms min={d}.{d}ms max={d}.{d}ms
\\
, .{
mode, f, wall_ms,
frame_avg_t / 10, frame_avg_t % 10,
frame_min_t / 10, frame_min_t % 10,
frame_max_t / 10, frame_max_t % 10,
});
log.fmt(
\\ ms: erp={d} rf={d} t44={d} rtq={d} mov={d}
\\ %frame: erp={d}.{d}% rf={d}.{d}% t44={d}.{d}% rtq={d}.{d}% mov={d}.{d}%
\\ calls/f: erp={d} rf={d} t44={d}({d}skip) rtq={d} mov={d}
@@ -1200,8 +1218,6 @@ fn dumpStats() void {
\\ blit: {d}ms/{d}calls
\\
, .{
mode,
f, wall_ms,
erp_ms, rf_ms, t44_ms, rtq_ms, mov_ms,
erp_pct / 10, erp_pct % 10,
rf_pct / 10, rf_pct % 10,
@@ -1256,14 +1272,11 @@ fn dumpStats() void {
.{ .name = "partsetup", .cycles = prof.partsetup_cycles, .calls = prof.partsetup_calls },
.{ .name = "matmul", .cycles = prof.matmul_cycles, .calls = prof.matmul_calls },
.{ .name = "textline", .cycles = prof.textline_cycles, .calls = prof.textline_calls },
.{ .name = "ff_cache", .cycles = prof.filefind_cycles, .calls = prof.filefind_calls },
.{ .name = "ff_orig", .cycles = prof.filefind_baseline_cycles, .calls = prof.filefind_calls },
};
for (hotspots) |h| {
if (h.calls > 0) {
const hp = pct(h.cycles, wall);
const name = std.mem.span(h.name);
const is_micro = std.mem.eql(u8, name, "ff_cache") or std.mem.eql(u8, name, "ff_orig") or std.mem.eql(u8, name, "glyph");
const is_micro = std.mem.eql(u8, std.mem.span(h.name), "glyph");
if (is_micro) {
log.fmt(" {s}: {d}.{d}% {d}us {d}c/f\n", .{
h.name,
@@ -1323,6 +1336,7 @@ pub fn installHooks() void {
_ = transform_hook.attach(0x714260, &transformDetour);
_ = render_frame_hook.attach(0x707680, &renderFrameDetour);
_ = exec_render_pass_hook.attach(0x708900, &execRenderPassDetour);
_ = world_update_hook.attach(0x482EA0, &worldUpdateDetour);
_ = render_quads_hook.attach(0x76FB00, &renderQuadsDetour);
_ = movement_hook.attach(0x616620, &movementDetour);
_ = interp_kf_hook.attach(0x713ea0, &interpKfDetour);
@@ -1362,6 +1376,19 @@ pub fn installHooks() void {
_ = matmul_hook.attach(0x7bc6a0, &matmulDetour);
_ = textline_hook.attach(0x5ce0c0, &textlineDetour);
// TSC timer calibration (ported from VanillaFixes)
timer_fix.init();
const ti = timer_fix.getInfo();
if (ti.calibrated) {
if (ti.orig_freq == 1000) {
log.fmt("timer_fix: TSC was OFF, enabled with freq {d}\n", .{ti.cal_freq});
} else {
log.fmt("timer_fix: recalibrated TSC freq {d} -> {d} ({d}.{d}% drift)\n", .{ ti.orig_freq, ti.cal_freq, ti.diff_pct_x10 / 10, ti.diff_pct_x10 % 10 });
}
} else if (ti.cal_freq > 0) {
log.print("timer_fix: already calibrated, skipping\n");
}
// blit_hub installed in lateInit() to clobber UnitXP's hook
log.print("transform44: 39 profiling hooks installed (blit_hub deferred)\n");
}
@@ -1395,6 +1422,7 @@ pub fn removeHooks() void {
transform_hook.detach();
render_frame_hook.detach();
exec_render_pass_hook.detach();
world_update_hook.detach();
render_quads_hook.detach();
movement_hook.detach();
interp_kf_hook.detach();