Color channel packing missing & 0xFF after >> 14 extraction — upper
bits bled into adjacent channels causing broken particle fading.
Same class of bug as the alpha output fix earlier.
Added particle_sse_reference.zig (faithful recreation from commit
574f96f) as a separate compilation unit for correctness comparison.
Faithful recreation of RenderSpriteQuads (0x5A0F50) with hoisted
invariant division and inlined DisplayMode_CalculateOffset. No
measurable improvement — cost is dominated by getAdapterInfo (7
sub-calls to D3D device per invocation × 3203 calls/frame) and
DrawPrimitive/DrawIndexedPrimitive virtual dispatch.
Added decompilation and assembly dumps for future reference.
Faithful recreation attempt of SetupParticleRendering (0x7B3D20).
All game function CCs verified from assembly. Vertex buffer setup
works (8 verts produced), but D3D draw submission doesn't produce
visible output. Disabled pending investigation of GfxDeviceMethod
param struct layout. The function remains as timing-only pass-through.
Fixes found during work:
- max_particle_sprites global: 0xCF58F4 → 0xCF5B60
- billboard_matrix global: 0xCF5898 → 0xCF5888
- index_buffer_6/12: 0xCF58D0/D4 → 0xCF5BAC/0xCF5AF4
- BuildIndexBuffer takes renders_count not field_28
- Identity matrices must be mutable (game writes to them)
10% of frame time, ~275 calls/frame, ~5800 cyc/call. Builds identity
matrices (32 stores of dead work), does 1-5 matmul calls, copies to
g_worldMatrix global. Translation matrix is identity+offset — matmul
chain can be simplified to direct computation.
- V4 store (vmovups) writes xyz+color as one 16-byte op instead of 4
scalar stores. Unaligned but still 1 μop on modern CPUs.
- inline for unrolls 4-vertex loops, letting LLVM schedule stores
across vertices and fill pipeline bubbles.
- Hoisted world_pos to locals to prevent array re-reads.
- A/B: BASELINE ~433ms → CUSTOM ~299ms (~31% per-period reduction).
Total from original: 520ms → 299ms = 42% reduction.
- Detected interleaved 24-byte vertex layout: xyz(12)+color(4)+uv(8).
Fast path writes 6 sequential u32s instead of scattered stores.
- Normal stride=0 (shared global) — write once in writeback, not 4x.
- Added stride_info export + logging for vertex layout analysis.
- A/B: ~30% peak reduction, baseline also improved due to less overhead.
- VBState caching on all 5 vertex paths (was only 2D and sin/cos).
Eliminates pointer re-reads: load once, emit 4 vertices, writeback.
- Cache setupRender() result per-frame via static + resetParticleCache()
called from worldUpdateDetour. Saves ~7K function calls/frame.
- @mulAdd throughout for FMA codegen on vertex position and texcoord.
- A/B verified: BASELINE ~521ms → CUSTOM ~387ms (~25% reduction).
- Inline calcColor: eliminates function call, allows OoO overlap of
cache misses on colorCtx with vertex math. Pow path falls back to
game function.
- Inline mat*vec3 transform: V4 FMA chain replaces call to 0x7BCA80.
- VBState: cache VB pointers/strides in locals, write back once after
4 vertices. Eliminates ~80 pointer re-reads per particle.
- @mulAdd throughout vertex loops for FMA codegen.
- A/B verified: BASELINE ~470ms → CUSTOM ~382ms (~20% reduction).
Full recreation of RenderParticleSprites (0x7B2A50, 2688 bytes) in
particle_sse.zig. All 5 code paths: 2D billboard, 3D billboard,
2D+rotation (sin/cos), 3D+rotation (axis-angle matrix), tail particles.
Key fixes during verification:
- colorCtx address: removed Ghidra's spurious -0x12 offset
- calcColor arg2: pass raw u32 from emitter+0x1A8, not truncated
- Texture coord lookups: +8 offset to match assembly's eax increment
between position and texcoord reads in the vertex loop
Verified in-game: particles render identically in CUSTOM vs BASELINE.
Next: optimize with SSE (inline calcColor, V4 vertex math).
- Ghidra C decompilation of RenderParticleSprites (422 lines) and 5
helper functions (calculateColorValues, matVec3Transform, etc.)
- particle_sse.zig with calcColorValues_SSE (10.7x bench but cache-miss
bound in-game — needs inlining into full function replacement)
- Bench harness for calcColorValues with correctness check
- build.zig: particle_sse as separate ReleaseFast compilation unit
- colorDetour reverted to pass-through (SSE has no in-game effect due
to L1 cache misses on scattered ColorCtx structs)
Assembly dumps and research doc for RenderParticleSprites (1.73% CPU),
calculateColorValues (0.63%), SetupParticleRendering, and
ProcessActiveParticles. Identifies SSE opportunities: V4 color interp,
billboard vertex math, rotation block. Plan for particle_sse.zig.
- glyph cache: add A/B toggle so BASELINE/CUSTOM periods alternate
between original function and shadow cache. Swap Murmur2 hash for
fast golden-ratio integer mix (3 insns vs multi-step).
- frustumCullBBox: fastRecip (vrcpss+NR) and cvtss2si replace vdivss
and @round bloat. 2.0x speedup (was 1.6x).
- Fix all Zig operator precedence bugs in silicon_sse: & and | bind
looser than == in Zig, so (flags & 0x8 == 0) was always false.
Affected frustumCullBBox and processLinkedListCollision.
- bone_sse: deduplicate findInterpIdx calls in bone loop — rotation's
search result reused for scale/translation when tracks share temporal
structure (canReuseInterp guard). Est. ~23% bone loop cycle reduction.
- silicon: SSE replacement for processLinkedListCollision (0x6ABC40,
1.57% CPU). V4 AABB overlap test replaces 6 x87 FCOMP/FNSTSW.
Benched at 3.2x speedup (378→115 cyc/call, 8 nodes).
- transform44: remove A/B toggle, always use SSE path (teardown guard
kept). A/B infrastructure remains for other hooks.
- bench: add processLinkedListCollision benchmark with fake linked list
test fixture and stubbed addGeometryToBuffer.
callFtol: use f32 multiply instead of f64 intermediate. Parity holds --
the delta*scale product is well within f32 precision range.
fastMod: replace integer modulo (idiv, ~25 cycles) with conditional
subtract (~2 cycles) for looping animation frame computation. Falls
back to real modulo for large time skips (alt-tab, etc).
3609 cycles (-14% vs 4176 baseline), parity PASS.
findInterpIdx now returns {idx0, idx1, t} as a struct instead of writing
all three to the output buffer. Only output[0] is written for next-frame
cache persistence. All 29 call sites updated to use returned values.
3574 cycles (-14% vs 4176 baseline), was 3841 (-8%). Parity PASS.
Replaced all remaining game function calls:
- findInterpIdx (0x713D50): full temporal-coherence search reimplementation
- interpAnimKF in boneKeyframeLoop: reuses existing pure Zig version
- applyTranslation/rotateByQuaternion/scaleMatrix3x3 in boneKeyframeLoop
- getInterpolatedFloat (0x71AF20): replaced with interpFloatTrack (identical)
- extractByte (0x71AE90): findInterpIdx + direct byte read
- Child recursion: direct call to transformImpl_SSE instead of 0x714260 hook
Only 2 game calls remain (cannot be replaced):
- 0x409AEF: one-time atexit registration in boneKeyframeLoop
- 0x7B5F60: IsParticleBufferEmpty (reads game particle state)
Root cause of billboard visual artifacts: callVec3SqMag used inline asm
to call game's x87 vec3SqMag (0x4549F0) with fstps to capture ST0.
With SSE2 codegen, the x87/SSE state interaction caused corrupted float
values in billboard bone matrices (mat[0][2] wildly wrong).
Replaced with pure Zig: x*x + y*y + z*z — no x87, no inline asm.
Also replaced:
- matMul (0x74A7C0): pure Zig f64 scalar matmul, no alignment needs
- callFtol (0x40A2B0): f64 intermediate + @intFromFloat (cvttsd2si)
Architecture: bone_sse.zig is REF code compiled with SSE2, called as
cdecl from thiscall wrapper in transform44.zig (cross-object to prevent
LLVM inlining AND ESP alignment into thiscall frame).
A/B: other hooks gated behind AB_OTHER_HOOKS=false for isolated testing.
Assembly-verified fixes from t44_helpers_asm.txt:
findInterpIdx (0x713D50):
- Range format is [start, last] not [start, count] (DEC EDI pattern)
- Backward scan entry: delta >= 0xFFFFFE0C not > (JC = unsigned below)
- 500-tick threshold (0x1F4) for forward/backward vs binary search
- GS check is CMP AX,0xFFFF (word compare), not >= 0
- t computation: FILD qword (i64 numer) / FIDIV dword (i32 denom)
interpAnimKF (0x713EA0):
- Keyframe stride is 16 bytes (SHL EAX,0x4), NOT 8 (CompQuat)
- Values are raw floats, no short-to-float conversion needed
Runtime constants — all now read from game memory:
- 0x80297C (3.0) and 0x802990 (6.0) for Hermite/Bezier basis
- 0x80C5C8 for billboard squared magnitude threshold
- 0x811610 and 0x8029D4 already read at runtime
Wrapper pattern: thiscall export delegates to normal fn for AVX alignment.
Full 5317-instruction walkthrough of t44_full_asm.txt vs bone_sse_reference.zig.
Crash fix (Issues 1-2): When anim_start >= anim_end in the looping animation
path, assembly always writes prim_time/sec_time = anim_start as fallback.
REF skipped the write, leaving garbage in bone_rt timing fields. On newly
loaded world SceneObjects this propagated through findInterpIdx → extractByte
→ ACCESS_VIOLATION at 0x71AEBC with ECX=0x7FFFFFFF (self-reinforcing bad
cached index).
Time clamp fix (Issues 3-4): Clamped-not-passed animation path now clamps
cur_time to sec_start when sec_start > cur_time, matching assembly at
0x7145EB/0x71474B.
Crossfade fix (Issues 5-6): interpVec3Track36 and interpFloatTrack12 had
'else return' for unknown interp modes. Assembly's JNZ skips primary interp
but falls through to crossfade check. Changed to 'else {}' fallthrough.
Also includes prior uncommitted fixes: particle crossfade blend_weight
restoration, bw>0→bw!=0, attach_count==0 early-return removal.
- Particle interpVec3Track/interpFloatTrack: pass 0.0 blend_weight to
disable crossfade, matching original which has no crossfade in particle
sections (only bone loop and boneKeyframeLoop have crossfade).
- interpFloatTrack: add explicit blend_weight parameter instead of
reading from bone_rt internally, allowing callers to control crossfade.
- Revert extractByte guard (was added then removed during investigation).
Known crash: extractByte (0x71AE90) crashes at 0x71AEBC with idx=0x7FFFFFFF
on world entry. findInterpIdx reads output[0] as cached search position;
if hierarchy buffer contains stale 0x7FFFFFFF, search overflows and
self-reinforces. Investigation ongoing — REF's attachment section matches
original assembly instruction-for-instruction.
- texAnimLoop alpha: add crossfade blend for mode != 0 (mode 0 skips
crossfade per original assembly JMP at 0x715B49). Fix alpha output
base to output+0x30 matching original ESI.
- colorAnimLoop: add crossfade blend, same mode 0 skip pattern.
Mode 0 uses direct short->float copy matching original.
- New wordAnimLoop: implements model_hdr+0x6C/0x70 word animation
section (assembly 0x715E46-0x715F25). Word copy with crossfade,
no float blending. Data stride 0x1C, output stride 0x20.
- Fix billboard cross product z-component for types 0x10/0x20:
was +cross.z, should be -cross.z (r0y*r1x - r0x*r1y).
- Extract shortInterpToFloat helper shared by alpha/color crossfade.
Known: particle emitter crash (pre-existing, idx=0x7FFFFFFF in
secondary findInterpIdx) — exposed by corrected colorAnimLoop count.
Assembly-level comparison of compiled REF against original 0x714260 revealed:
1. Billboard cross product sign error (types 0x10/0x20): computed +cross
instead of -cross for components 0/1, corrupting billboard bone matrices
2. colorAnimLoop wrong count field: read model_hdr+0x6C instead of +0x64
3. colorAnimLoop wrong gate offset: checked anim_data+0x04 instead of +0x0C
4. Timestamp delta guard inverted: REF guarded on cur_ts!=0 and always
wrote to this+0x4C; original guards on this+0x4C!=0 first and never
seeds the field (something else initializes it)
5. Section 5 emitter_ctx cached instead of re-read after matMul call
Also: build REF with x87-only target (subtract SSE/SSE2 features) to
match original's FLD/FMUL/FSTP codegen, and use callVec3SqMag for all
magnitude computations instead of inline SSE math.
Remaining known issues (not yet fixed):
- texAnimLoop alpha track missing crossfade blend
- colorAnimLoop missing crossfade blend
- Missing word animation section (model_hdr+0x6C/0x70)
- Bisect infrastructure and diagnostic code still present (test scaffolding)
- Replace all reimplemented game functions with actual game calls:
vec3_sqmag (0x4549F0), __ftol (0x40A2B0), getIndexOffset (0x71AFF0),
setShortValue (0x71B010) — matching assembly exactly
- Fix 3 wrong hardcoded constants that differ at runtime from Ghidra static values:
SHORT_TO_FLOAT: 0x38000000→0x38000100 (1/32767 not 1/32768)
BILLBOARD_EPSILON: 0x3727c5ac→0x34800000
HERMITE_5: 5.0→6.0
All now read from game memory at runtime
- Fix timestamp delta guard (this+0x4C): was guarding on stored value,
assembly guards on anim_ctx pointer — prevents first-frame initialization
- Change REF calling convention to thiscall matching original
- Add comprehensive memory comparison diagnostic (original vs REF)
- Disable interpKfDetour hook (was pure passthrough)
Bug #18: Ribbon emitter track offsets completely wrong. Position was
entry+0x24, actual tracks from assembly (0x716402-0x716AA9):
Track 1 (Vec3): gate=+0x1C, AnimData=+0x10, output=+0x00
Track 2 (Vec3): gate=+0x38, AnimData=+0x2C, output=+0x30
Track 3 (float): gate=+0x70, AnimData=+0x64, output=+0x80
Track 4 (Vec3): gate=+0x54, AnimData=+0x48, output=+0x50
Bug #19: Track 4 output was +0xA0 (should be +0x50), and tracks 2/4
were float (should be Vec3). Wrong output offsets corrupted stack data,
causing bone_rt pointer to contain float bit patterns.
Teardown: hook World_HandleLogoutCleanup (0x491180) instead of
CleanupWorldAndEntities (0x66FC40). Fires at START of logout sequence
before any Lua callbacks trigger model processing on freed data.
Prologue: PUSH ESI/EDI, epilogue: POP EDI/ESI, JMP (tail call).
Also: added bone_sse_reference.zig as separate compilation unit for
A/B testing the proven-working version independently.
Bug #15: Short-value interpolation read AD+0x0C (nTimestamps count) as
pointer to short array. Should be AD+0x18 (ofsValues). Caused segfault
reading from address ~0x134 (a count value treated as pointer).
Bug #16: boneKeyframeLoop AnimData offsets wrong. Rotation was kf_data+0x10,
should be +0x1C. Scale was kf_data+0x28 with garbled gate, should be +0x38
with gate at +0x44. Translation was correct at +0x00. Entry structure is
3x28-byte AnimBlocks: trans(+0x00), rot(+0x1C), scale(+0x38) = stride 0x54.
Bug #17: particleEmitterLoop (model_hdr+0x124) position AnimData was
entry+0x04, should be entry+0x10 with gate at entry+0x1C. Second track
was entry+0x20, should be entry+0x38/gate +0x44. Third track at
entry+0x60/gate +0x6C was missing entirely.
Also fixed IsParticleBufferEmpty calling convention (bug #14):
was __stdcall with stack param, now __fastcall(ECX=ptr) plain RET.
SSE dispatch enabled for A/B testing.
Moved math_sse.zig and all 17 hook declarations + CriticalSection spin
count optimization from transform44 into new ssemaths module. Off by
default (-Dssemaths=true to enable).
transform44 retains its profiling hooks and blit_hub optimization.
ssemaths is a clean standalone module with its own mutex, install/remove
lifecycle, and lateInit for post-UnitXP hook clobbering.
Added performance note to math_sse.zig documenting that hook-based
replacement adds ~5-8 cycles overhead that makes small functions slower,
and that in-place patching is the path to realize the full 2-4x gains
shown in inlined benchmarks.
Added V4 vector helpers (loadV3, storeV3, dot3) and rewrote several
functions to use @Vector(4, f32) operations instead of scalar f64:
- planeNormal: 0.7x -> 1.7x (79 -> 33 cyc) -- V4 cross + normalize
- transformAABox: 0.7x -> 1.2x (118 -> 69 cyc) -- f32 + @min/@max
- evaluatePolynomial: 0.4x -> 0.6x (24 -> 16 cyc) -- drop f64 promotion
- vec3MulScalar: 0.7x -> 0.8x -- V4 splat multiply
- dotProduct/squaredMagnitude: rewritten with dot3 helper
crossProduct reverted from shuffle-SIMD back to scalar -- shuffles added
latency that the x87 pipeline doesn't have (16 vs 13 cyc).
Remaining losers (dotProduct 0.4x, evalPoly 0.6x) are at the function
call overhead floor -- the original x87 is 5-11 cycles, which is close
to bare call/ret cost.
Was __stdcall with stack param — would corrupt stack for every visible
particle emitter. Fixed to __fastcall(ECX=ptr) matching assembly at
0x717EB8: MOV ECX,[EBP-0x10]; CALL 0x7B5F60. Plain RET, no stack cleanup.
Bug #14 found during final verification pass.