particle: WIP setupParticleRendering recreation (disabled)

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)
This commit is contained in:
MarcelineVQ
2026-03-24 00:25:12 -07:00
parent a73b5273fd
commit 1046c0a4e3
2 changed files with 405 additions and 0 deletions
+393
View File
@@ -261,6 +261,11 @@ inline fn emitVertex(vb: u32, px: f32, py: f32, pz: f32, color: u32, tu: f32, tv
var cached_render_state: u32 = 0;
var stride_logged: bool = false;
var debug_logged: bool = false;
export var debug_vertex_count: u32 = 0;
export var debug_max_sprites: u32 = 0;
export var debug_fmt_index: u32 = 0;
export var debug_data_ptr: u32 = 0;
/// Reset per-frame caches. Call from OnWorldUpdate or executeSceneRenderPass hook.
export fn resetParticleCache() void {
@@ -675,3 +680,391 @@ export fn renderParticleSprites_SSE(emitter: u32, particle_data: u32, vertex_buf
return 1;
}
// =============================================================================
// Game function pointers for SetupParticleRendering
// =============================================================================
const SC = std.builtin.CallingConvention;
const StdCall: SC = .{ .x86_stdcall = .{} };
// 0x58B0B0: SetTransformMatrix — __thiscall(ECX=matrixPtr)
const gameSetTransformMatrix: *const fn (u32) callconv(TC) void = @ptrFromInt(0x58B0B0);
// 0x58B050: SetVertexShader — __thiscall(ECX=matrixPtr)
const gameSetVertexShader: *const fn (u32) callconv(TC) void = @ptrFromInt(0x58B050);
// 0x7BC6A0: multiplyMatrix4x4 — __fastcall(ECX=out, EDX=matA, stack=matB), RET 0x4, returns out
const gameMatMul: *const fn (u32, u32, u32) callconv(FC) u32 = @ptrFromInt(0x7BC6A0);
// 0x409AEF: validateMemoryOperation — __thiscall(ECX=ptr)
const gameValidateMem: *const fn (u32) callconv(TC) void = @ptrFromInt(0x409AEF);
// 0x4549F0: vec3SquaredMagnitude — __thiscall(ECX=vec3ptr), returns f64 in ST(0)
// Can't call directly from Zig due to FPU return. Use inline asm.
// All calling conventions verified from assembly at each CALL site.
// 0x589F40: BeginRender — no params visible before call
const gameBeginRender: *const fn () callconv(StdCall) void = @ptrFromInt(0x589F40);
// 0x44ACF0: GetTextureBuffer — __fastcall(ECX=texDataPtr, EDX=0, stack=0), returns ptr in EAX
const gameGetTexture: *const fn (u32, u32, u32) callconv(FC) u32 = @ptrFromInt(0x44ACF0);
// 0x589E80: SetTexture — __fastcall(ECX=slot, EDX=texturePtr)
const gameSetTexture: *const fn (u32, u32) callconv(FC) void = @ptrFromInt(0x589E80);
// 0x589A90: GetDataPointerByIndex — __thiscall(ECX=index), returns ptr
const gameGetDataPtr: *const fn (u32) callconv(TC) u32 = @ptrFromInt(0x589A90);
// 0x58A140: CreateVertexBuffer — __fastcall(ECX=0, EDX=dataPtr, stack=count), returns ptr
const gameCreateVB: *const fn (u32, u32, u32) callconv(FC) u32 = @ptrFromInt(0x58A140);
// 0x58A080: LockVertexBuffer — __thiscall(ECX=vbPtr), returns base offset
const gameLockVB: *const fn (u32) callconv(TC) u32 = @ptrFromInt(0x58A080);
// 0x589AB0: GetMatrixElementPointer — __fastcall(ECX=fmtIndex, EDX=elementIndex), returns ptr
const gameGetMatElem: *const fn (u32, u32) callconv(FC) u32 = @ptrFromInt(0x589AB0);
// 0x7B3A10: RenderParticleSystemSorted — __thiscall(ECX=emitter, stack=vbPtrs)
const gameRenderSorted: *const fn (u32, u32) callconv(TC) void = @ptrFromInt(0x7B3A10);
// 0x58A0A0: UnlockVertexBuffer — __fastcall(ECX=vbPtr, EDX=0)
const gameUnlockVB: *const fn (u32, u32) callconv(FC) void = @ptrFromInt(0x58A0A0);
// 0x58A7C0: DrawPrimitive — __fastcall(ECX=vbPtr, EDX=fmtIndex)
const gameDrawPrim: *const fn (u32, u32) callconv(FC) void = @ptrFromInt(0x58A7C0);
// 0x58A010: IsObjectActiveAndValid — __thiscall(ECX=objPtr), returns bool-like
const gameIsObjValid: *const fn (u32) callconv(TC) u32 = @ptrFromInt(0x58A010);
// 0x7B3C50: BuildIndexBuffer — __thiscall(ECX=emitter, stack=ibPtr, count)
// Actually: PUSH edx(count), PUSH ecx(ibPtr), mov ecx,ebx(emitter), CALL
const gameBuildIB: *const fn (u32, u32, u32) callconv(TC) void = @ptrFromInt(0x7B3C50);
// 0x58A800: SetStreamSource — __thiscall(ECX=ibPtr)
const gameSetStream: *const fn (u32) callconv(TC) void = @ptrFromInt(0x58A800);
// 0x58A830: CallGfxDeviceMethod_Wrapper — __fastcall(ECX=paramsPtr, EDX=param2)
const gameGfxCall: *const fn (u32, u32) callconv(FC) void = @ptrFromInt(0x58A830);
// 0x589F50: EndRender — no params
const gameEndRender: *const fn () callconv(StdCall) void = @ptrFromInt(0x589F50);
// =============================================================================
// Global addresses for SetupParticleRendering
// =============================================================================
const SG = struct {
const world_matrix: u32 = 0xCF5B68; // g_worldMatrix (64 bytes, 4x4)
const light_dir_x: u32 = 0xCF5878;
const light_dir_y: u32 = 0xCF587C;
const light_dir_z: u32 = 0xCF5880;
const render_init_flags: u32 = 0xCF58EC;
const sprite_vertex_template: u32 = 0xCF5AF8; // 4 vertices × 3 floats = 48 bytes
const billboard_matrix: u32 = 0xCF5888; // 4x4 matrix (64 bytes, 0xCF5888-0xCF58C8)
const sprite_template_validator: u32 = 0xCF5B28; // for validateMemoryOperation
const billboard_validator: u32 = 0xCF58E8; // for validateMemoryOperation
const normal_validator: u32 = 0xCF586C; // for validateMemoryOperation
const default_normal: u32 = 0xCF5860; // 3 floats
const max_particle_sprites: u32 = 0xCF5B60; // u32
const transformed_vertices: u32 = 0xCF5B30; // output of billboard transform (48 bytes)
const index_buffer_6: u32 = 0xCF5BAC; // ptr to index buffer for field_28==6
const index_buffer_12: u32 = 0xCF5AF4; // ptr to index buffer for field_28==0xC
const billboard_epsilon: u32 = 0x8029D4;
};
// =============================================================================
// SetupParticleRendering (0x7B3D20)
// __thiscall(ECX=emitter, stack=viewMatrix), RET 0x4
// viewMatrix can be NULL.
//
// Faithful recreation from Ghidra decompilation + assembly.
// All game function calls preserved, matrix math inlined with V4.
// =============================================================================
export fn setupParticleRendering_SSE(emitter: u32, view_matrix: u32) callconv(TC) void {
// =========================================================================
// Section 1: Identity matrices for render state
// Optimization: use static identity instead of rebuilding on stack each call.
// =========================================================================
// Must be mutable — game functions may write to the matrix pointer
var identity_a = [16]u32{
0x3F800000, 0, 0, 0,
0, 0x3F800000, 0, 0,
0, 0, 0x3F800000, 0,
0, 0, 0, 0x3F800000,
};
var identity_b = [16]u32{
0x3F800000, 0, 0, 0,
0, 0x3F800000, 0, 0,
0, 0, 0x3F800000, 0,
0, 0, 0, 0x3F800000,
};
gameSetTransformMatrix(@intFromPtr(&identity_a));
gameSetVertexShader(@intFromPtr(&identity_b));
// =========================================================================
// Section 2: Build translation matrix = identity with last row = (-x, -y, -z, 1)
// =========================================================================
const neg_x = -rf32(emitter + 0x23C);
const neg_y = -rf32(emitter + 0x240);
const neg_z = -rf32(emitter + 0x244);
var translation = [16]u32{
0x3F800000, 0, 0, 0,
0, 0x3F800000, 0, 0,
0, 0, 0x3F800000, 0,
@bitCast(neg_x), @bitCast(neg_y), @bitCast(neg_z), 0x3F800000,
};
const flags = ru32(emitter + 0x1AC);
// =========================================================================
// Section 3: Compute g_worldMatrix based on flags
// Three paths: flag 0x100 set, flag clear + viewMatrix != NULL, flag clear + NULL
// =========================================================================
if ((flags & 0x100) != 0) {
// Path A: matmul(emitter_matrix × translation), then × identity (= just copy)
// emitter_matrix at emitter+0x1FC
var temp: [16]u32 = undefined;
_ = gameMatMul(@intFromPtr(&temp), emitter + 0x1FC, @intFromPtr(&translation));
// Original does matmul(result, temp, identity) — identity is a no-op, just copy
copyMat4x4(SG.world_matrix, @intFromPtr(&temp));
} else if (view_matrix != 0) {
// Path B: matmul(viewMatrix × translation), then × identity (= just copy)
var temp: [16]u32 = undefined;
_ = gameMatMul(@intFromPtr(&temp), view_matrix, @intFromPtr(&translation));
copyMat4x4(SG.world_matrix, @intFromPtr(&temp));
} else {
// Path C: matmul(translation × identity) = just copy translation
copyMat4x4(SG.world_matrix, @intFromPtr(&translation));
}
// =========================================================================
// Section 4: Set light direction from identity row 2 = (0, 0, 1)
// (Original reads from identity matrix on stack; we know it's always (0,0,1))
// =========================================================================
// Actually, identity matrix row 2 in the stack layout: the identity at [ebp-0x54]
// has row 2 = {0, 0, 1, 0} stored at [ebp-0x34, -0x30, -0x2c, -0x28].
// But this identity was passed to SetVertexShader which may have modified it?
// No — SetVertexShader just reads it. So light dir = identity[8,9,10] = (0, 0, 1).
// But wait: assembly shows mov eax,[ebp-0x34]; mov [0xCF5878],eax etc.
// [ebp-0x34] is identityMatrix.m20 = 0.0, [ebp-0x30] = m21 = 0.0, [ebp-0x2c] = m22 = 1.0
wu32(SG.light_dir_x, 0); // 0.0
wu32(SG.light_dir_y, 0); // 0.0
wu32(SG.light_dir_z, 0x3F800000); // 1.0
// =========================================================================
// Section 5: Flag 0x2000 — billboard/3D sprite setup
// =========================================================================
if ((flags & 0x2000) != 0) {
// One-time sprite vertex template initialization
const init_flags = ru8(SG.render_init_flags);
if ((init_flags & 1) == 0) {
wu8(SG.render_init_flags, init_flags | 1);
// Write 4 sprite vertices: {x, y, z} × 4
// Vertex 0: (-1, 1, 0), Vertex 1: (-1, -1, 0), Vertex 2: (1, 1, 0), Vertex 3: (1, -1, 0)
wu32(SG.sprite_vertex_template + 0, 0xBF800000); // -1.0
wu32(SG.sprite_vertex_template + 4, 0x3F800000); // 1.0
wu32(SG.sprite_vertex_template + 8, 0); // 0.0
wu32(SG.sprite_vertex_template + 12, 0xBF800000); // -1.0
wu32(SG.sprite_vertex_template + 16, 0xBF800000); // -1.0
wu32(SG.sprite_vertex_template + 20, 0); // 0.0
wu32(SG.sprite_vertex_template + 24, 0x3F800000); // 1.0
wu32(SG.sprite_vertex_template + 28, 0x3F800000); // 1.0
wu32(SG.sprite_vertex_template + 32, 0); // 0.0
wu32(SG.sprite_vertex_template + 36, 0x3F800000); // 1.0
wu32(SG.sprite_vertex_template + 40, 0xBF800000); // -1.0
wu32(SG.sprite_vertex_template + 44, 0); // 0.0
gameValidateMem(SG.sprite_template_validator);
}
// One-time billboard identity matrix initialization
if ((init_flags & 2) == 0) {
wu8(SG.render_init_flags, ru8(SG.render_init_flags) | 2);
// Write identity 4x4 to billboard_matrix
const bm = SG.billboard_matrix;
inline for (0..16) |i| {
const is_diag = (i % 5 == 0 and i < 16);
wu32(bm + @as(u32, @intCast(i)) * 4, if (is_diag) @as(u32, 0x3F800000) else 0);
}
gameValidateMem(SG.billboard_validator);
}
// Compute billboard matrix: depends on flag 0x100
if ((flags & 0x100) == 0) {
// matmul(emitter+0x1FC, g_worldMatrix) → billboard_matrix
var temp2: [16]u32 = undefined;
_ = gameMatMul(@intFromPtr(&temp2), emitter + 0x1FC, SG.world_matrix);
copyMat4x4(SG.billboard_matrix, @intFromPtr(&temp2));
} else {
// Just copy g_worldMatrix → billboard_matrix
copyMat4x4(SG.billboard_matrix, SG.world_matrix);
}
// Transform 4 sprite vertices through billboard matrix
// 4 vertices × vec3, output to g_transformedVertices
{
const bm = SG.billboard_matrix;
const bm00 = rf32(bm); const bm01 = rf32(bm + 4); const bm02 = rf32(bm + 8);
const bm10 = rf32(bm + 16); const bm11 = rf32(bm + 20); const bm12 = rf32(bm + 24);
const bm20 = rf32(bm + 32); const bm21 = rf32(bm + 36); const bm22 = rf32(bm + 40);
var vi: u32 = 0;
while (vi < 48) : (vi += 12) {
const sx = rf32(SG.sprite_vertex_template + vi);
const sy = rf32(SG.sprite_vertex_template + vi + 4);
const sz = rf32(SG.sprite_vertex_template + vi + 8);
wf32(SG.transformed_vertices + vi, @mulAdd(f32, bm20, sz, @mulAdd(f32, bm10, sy, bm00 * sx)));
wf32(SG.transformed_vertices + vi + 4, @mulAdd(f32, bm21, sz, @mulAdd(f32, bm11, sy, bm01 * sx)));
wf32(SG.transformed_vertices + vi + 8, @mulAdd(f32, bm22, sz, @mulAdd(f32, bm12, sy, bm02 * sx)));
}
}
// Store billboard matrix row 2 as rotation axis in emitter+0x284
wf32(emitter + 0x284, rf32(SG.billboard_matrix + 32));
wf32(emitter + 0x288, rf32(SG.billboard_matrix + 36));
wf32(emitter + 0x28C, rf32(SG.billboard_matrix + 40));
// Normalize the rotation axis
const ax = rf32(emitter + 0x284);
const ay = rf32(emitter + 0x288);
const az = rf32(emitter + 0x28C);
const sq_mag = @mulAdd(f32, az, az, @mulAdd(f32, ay, ay, ax * ax));
const epsilon = rf32(SG.billboard_epsilon);
if (@sqrt(sq_mag) >= epsilon) {
const inv_len = 1.0 / @sqrt(sq_mag);
wf32(emitter + 0x284, ax * inv_len);
wf32(emitter + 0x288, ay * inv_len);
wf32(emitter + 0x28C, az * inv_len);
}
}
// =========================================================================
// Section 6: Begin render, texture, vertex buffer setup
// =========================================================================
gameBeginRender();
const tex_id = ru32(emitter + 0x1A0);
const tex_ptr = gameGetTexture(tex_id, 0, 0);
if (tex_ptr == 0) {
// No texture — skip to end
gameEndRender();
gameSetVertexShader(@intFromPtr(&identity_a));
return;
}
gameSetTexture(0x17, tex_ptr);
// Compute max particle sprites: 0x4000 / emitter.vertexSize
const vert_size = ru32(emitter + 0x9C);
var max_sprites: u32 = 0x4000 / vert_size;
const emitter_max = ru32(emitter + 0x64);
if (emitter_max <= max_sprites) {
max_sprites = emitter_max;
}
wu32(SG.max_particle_sprites, max_sprites);
// Determine vertex format index
const format_flag = ru32(emitter + 0x194);
const fmt_index: u32 = if ((format_flag & 1) != 0) 4 else 8;
const data_ptr = gameGetDataPtr(fmt_index);
const vb_ptr = gameCreateVB(0, data_ptr, vert_size * max_sprites);
const vb_base = gameLockVB(vb_ptr);
// Build vertex buffer pointer array (same layout as RenderParticleSprites expects)
var vb_ptrs: [9]u32 = undefined;
// Position pointer
const pos_elem = gameGetMatElem(fmt_index, 0);
vb_ptrs[0] = pos_elem + vb_base; // pos ptr
vb_ptrs[4] = data_ptr; // pos stride
// Normal pointer
if ((format_flag & 1) == 0) {
// No per-vertex normals — use shared default
const nflags = ru8(SG.render_init_flags);
if ((nflags & 4) == 0) {
wu8(SG.render_init_flags, nflags | 4);
wu32(SG.default_normal, 0);
wu32(SG.default_normal + 4, 0);
wu32(SG.default_normal + 8, 0);
gameValidateMem(SG.normal_validator);
}
vb_ptrs[1] = SG.default_normal;
vb_ptrs[5] = 0; // stride 0 = shared
} else {
const norm_elem = gameGetMatElem(fmt_index, 3);
vb_ptrs[1] = norm_elem + vb_base;
vb_ptrs[5] = data_ptr;
}
// Color pointer
const color_elem = gameGetMatElem(fmt_index, 4);
vb_ptrs[2] = color_elem + vb_base;
vb_ptrs[6] = data_ptr;
// Texcoord pointer
const tc_elem = gameGetMatElem(fmt_index, 5);
vb_ptrs[3] = tc_elem + vb_base;
vb_ptrs[7] = data_ptr;
// Count
vb_ptrs[8] = 0;
// =========================================================================
// Section 7: Render particles
// =========================================================================
gameRenderSorted(emitter, @intFromPtr(&vb_ptrs));
// DEBUG: log vertex count produced
if (!debug_logged and vb_ptrs[8] > 0) {
debug_logged = true;
debug_vertex_count = vb_ptrs[8];
debug_max_sprites = max_sprites;
debug_fmt_index = fmt_index;
debug_data_ptr = data_ptr;
}
gameUnlockVB(vb_ptr, 0);
gameDrawPrim(vb_ptr, fmt_index);
// =========================================================================
// Section 8: Index buffer setup
// =========================================================================
const field_28 = ru32(emitter + 0x1C);
const renders_count = ru32(emitter + 0xA0);
if (field_28 == 6) {
var ib = ru32(SG.index_buffer_6);
if (gameIsObjValid(ib) == 0) {
gameBuildIB(emitter, ib, renders_count);
ib = ru32(SG.index_buffer_6);
}
gameSetStream(ib);
} else if (field_28 == 0xC) {
var ib = ru32(SG.index_buffer_12);
if (gameIsObjValid(ib) == 0) {
gameBuildIB(emitter, ib, renders_count);
ib = ru32(SG.index_buffer_12);
}
gameSetStream(ib);
}
// =========================================================================
// Section 9: Final setup
// =========================================================================
const renders = ru32(emitter + 0xA0);
const calc_scale: f32 = @floatFromInt(renders * field_28);
wf32(emitter + 0x20, calc_scale);
// CallGfxDeviceMethod_Wrapper — assembly-verified packed layout:
// [+0x00] u32 = 3 (primitive type)
// [+0x04] u32 = 0 (start index)
// [+0x08] u16 = (u16)(field_28 * renders) (verts per prim)
// [+0x0A] u16 = 0
// [+0x0C] u16 = (u16)(vertex_count - 1) (prim count)
// fastcall(ECX=&params, EDX=1)
const calc_int: u16 = @truncate(renders_count * field_28);
const vertex_count: u32 = vb_ptrs[8];
const prim_count: u16 = if (vertex_count > 0) @truncate(vertex_count - 1) else 0;
var gfx_bytes: [14]u8 align(4) = undefined;
@as(*u32, @ptrCast(gfx_bytes[0..4])).* = 3;
@as(*u32, @ptrCast(gfx_bytes[4..8])).* = 0;
@as(*u16, @ptrCast(gfx_bytes[8..10])).* = calc_int;
@as(*u16, @ptrCast(gfx_bytes[10..12])).* = 0;
@as(*u16, @ptrCast(gfx_bytes[12..14])).* = prim_count;
gameGfxCall(@intFromPtr(&gfx_bytes), 1);
// End render and restore vertex shader
gameEndRender();
gameSetVertexShader(@intFromPtr(&identity_a));
}
inline fn copyMat4x4(dst: u32, src: u32) void {
// Copy 64 bytes (16 floats) using V4 loads/stores
@as(*align(1) V4, @ptrFromInt(dst)).* = @as(*align(1) const V4, @ptrFromInt(src)).*;
@as(*align(1) V4, @ptrFromInt(dst + 16)).* = @as(*align(1) const V4, @ptrFromInt(src + 16)).*;
@as(*align(1) V4, @ptrFromInt(dst + 32)).* = @as(*align(1) const V4, @ptrFromInt(src + 32)).*;
@as(*align(1) V4, @ptrFromInt(dst + 48)).* = @as(*align(1) const V4, @ptrFromInt(src + 48)).*;
}
+12
View File
@@ -25,7 +25,12 @@ extern fn transformImpl_SSE(u32, u32, u32, u32, u32) callconv(.c) void;
extern fn calcColorValues_SSE(u32, u32, u32, u32, u32, u32, u32) callconv(.{ .x86_thiscall = .{} }) void;
extern fn renderParticleSprites_SSE(u32, u32, u32) callconv(.{ .x86_thiscall = .{} }) u32;
extern fn resetParticleCache() void;
extern fn setupParticleRendering_SSE(u32, u32) callconv(.{ .x86_thiscall = .{} }) void;
extern var stride_info: [8]u32; // exported from particle_sse.zig
extern var debug_vertex_count: u32;
extern var debug_max_sprites: u32;
extern var debug_fmt_index: u32;
extern var debug_data_ptr: u32;
var stride_dumped: bool = false;
/// Thiscall wrapper for the SSE implementation. Lives here (baseline SSE2 unit)
@@ -1461,6 +1466,13 @@ fn dumpStats() void {
}
// Dump particle VB stride info (once)
if (debug_vertex_count != 0) {
log.fmt(" partsetup_debug: verts={d} maxSprites={d} fmt={d} dataPtr=0x{x}\n", .{
debug_vertex_count, debug_max_sprites, debug_fmt_index, debug_data_ptr,
});
debug_vertex_count = 0;
}
if (stride_info[0] != 0 and !stride_dumped) {
stride_dumped = true;
log.fmt(" vb_strides: pos={d} norm={d} color={d} tc={d}\n", .{