From 9d0faecf4d6c62a11b53b7b551aa75a97e85862e Mon Sep 17 00:00:00 2001 From: dhkatz Date: Wed, 28 Oct 2026 22:21:58 -0700 Subject: [PATCH] Move particles between ticks The particle library moves particles by their velocity times a tick's time at each tick; between ticks, the adapter (ModelInstance::render's call to ParticleSystem::render, redirected after checking it) draws them moved on by their velocity times the time since, then moves each back by its own velocity: ParticleSystem::render sorts them by distance, reordering them in memory, so restoring saved positions in order scrambled them. Like ParticleSystem::render, it only reads particles when there's a group (the library throws without one, which crashed the client). Co-Authored-By: Claude Opus 5.5 --- adapter/scene_hooks.cpp | 100 +++++++++++++++++++++++++++++++++++++++- adapter/scene_hooks.hpp | 2 + docs/architecture.md | 4 +- 3 files changed, 103 insertions(+), 3 deletions(-) diff --git a/adapter/scene_hooks.cpp b/adapter/scene_hooks.cpp index 4655098..e7dc608 100644 --- a/adapter/scene_hooks.cpp +++ b/adapter/scene_hooks.cpp @@ -31,6 +31,24 @@ std::atomic stepTicks = 1; std::atomic stepHooked = false; std::atomic poseHooked = false; std::atomic cameraHooked = false; +std::atomic particlesHooked = false; + +// IMVU's ParticleSystem::render(RenderManager*, const Transform&, const Transform&): a method with 3 arguments. +using RenderParticles = void(__thiscall*)(void* self, void* renderManager, const void* a, const void* b); +RenderParticles originalRenderParticles = nullptr; +// The particle library's (ParticleLib.dll: PAPI, the Particle System API) PContextParticleGroup_t::GetParticlePointer: +// the current group's particles, as floats: returns their count, and sets the first's address, the stride between +// them, and the offsets of their attributes (in floats): position, previous position, size, velocity, previous +// velocity, color, alpha, age, up, rotational velocity, previous up, mass, data. +using GetParticlePointer = unsigned(__thiscall*)(void* group, float*& particles, unsigned& stride, unsigned& position, + unsigned& positionB, unsigned& size, unsigned& velocity, + unsigned& velocityB, unsigned& color, unsigned& alpha, unsigned& age, + unsigned& up, unsigned& rotationalVelocity, unsigned& upB, + unsigned& mass, unsigned& data); +GetParticlePointer getParticlePointer = nullptr; +// PContextParticleGroup_t::GetGroupCount: how many particle groups there are (GetParticlePointer throws without one). +using GetGroupCount = unsigned(__thiscall*)(void* group); +GetGroupCount getGroupCount = nullptr; // zero.dll's constants: a tick, in milliseconds (33), and an animation frame, in seconds (1/30). const unsigned* millisecondsPerFrame = nullptr; @@ -224,6 +242,54 @@ void* __fastcall getCameraTransform(void* camera, void* /*edx*/, void* result) return transform; } +// Particles (SceneWindow.dll's ParticleSystem::step, at each tick) move by their velocity times the tick's time +// (PContextActionList_t::TimeStep, millisecondsPerFrame / 1000 seconds a tick, then Move). Between ticks, they're drawn +// moved on by their velocity times the time since the tick, then moved back the same way. (ParticleSystem::render +// sorts the particles by distance before drawing them, reordering them in memory: each is moved back by its own +// velocity, wherever it now is.) A ParticleSystem's particle group is at +0x28 (what ParticleSystem::render reads +// them from). +constexpr std::size_t particleGroup = 0x28; + +void __fastcall renderParticles(void* system, void* /*edx*/, void* renderManager, const void* a, const void* b) +{ + const float fraction = tick_fraction(); + void* group = static_cast(system) + particleGroup; + float* particles = nullptr; + unsigned stride = 0, position = 0, positionB = 0, size = 0, velocity = 0, velocityB = 0, color = 0, alpha = 0, + age = 0, up = 0, rotationalVelocity = 0, upB = 0, mass = 0, data = 0; + // As ParticleSystem::render does: no particles without a group. The library reports errors by throwing; then the + // particles are drawn as they are. + const auto particle_pointer = [&] { + try { + if (getGroupCount(group) != 0) { + return getParticlePointer(group, particles, stride, position, positionB, size, velocity, velocityB, + color, alpha, age, up, rotationalVelocity, upB, mass, data); + } + } catch (...) { + } + return 0u; + }; + const unsigned count = fraction >= 0.01f ? particle_pointer() : 0; + if (count == 0 || particles == nullptr || stride == 0) { + originalRenderParticles(system, renderManager, a, b); + return; + } + const float seconds = fraction * static_cast(*millisecondsPerFrame) / 1000.0f; + const auto move = [&](float by) { + for (unsigned i = 0; i < count; ++i) { + float* particle = particles + std::size_t{i} * stride; + for (unsigned axis = 0; axis < 3; ++axis) { + particle[position + axis] += particle[velocity + axis] * by; + } + } + }; + move(seconds); + originalRenderParticles(system, renderManager, a, b); + if (particle_pointer() == count && particles != nullptr) { // the same particles, maybe reordered + move(-seconds); + } +} + // Replaces the vtable entry at slot, if it holds expected; returns whether it did. bool replace(void** slot, void* expected, void* replacement) { @@ -300,6 +366,34 @@ void install_pose(HMODULE sceneWindow) poseHooked = true; } +void install_particles(HMODULE sceneWindow) +{ + HMODULE particleLib = GetModuleHandleW(L"ParticleLib.dll"); + getParticlePointer = particleLib == nullptr + ? nullptr + : reinterpret_cast(GetProcAddress( + particleLib, "?GetParticlePointer@PContextParticleGroup_t@PAPI@@QAEIAAPAMAAI1111111111111@Z")); + getGroupCount = particleLib == nullptr ? nullptr + : reinterpret_cast(GetProcAddress( + particleLib, "?GetGroupCount@PContextParticleGroup_t@PAPI@@QAEIXZ")); + auto* modelRender = reinterpret_cast(GetProcAddress( + sceneWindow, "?render@ModelInstance@@QAEXPAVRenderManager@@ABVCullingFrustum@@ABVTransform@@2ABUFogState@@" + "ABULightState@@_NAAVSceneStats@@ABVRenderSettings@@@Z")); + auto* render = GetProcAddress(sceneWindow, "?render@ParticleSystem@@QAEXPAVRenderManager@@ABVTransform@@1@Z"); + if (getParticlePointer == nullptr || getGroupCount == nullptr || modelRender == nullptr || render == nullptr + || millisecondsPerFrame == nullptr) { + log::info("scene hooks: ParticleLib's GetParticlePointer, ModelInstance::render or ParticleSystem::render not found"); + return; + } + // ModelInstance::render draws its particle systems with a call at +0x114. + originalRenderParticles = reinterpret_cast(render); + if (!redirect_call(modelRender + 0x114, reinterpret_cast(render), reinterpret_cast(&renderParticles))) { + log::info("scene hooks: ModelInstance::render doesn't call ParticleSystem::render where expected"); + return; + } + particlesHooked = true; +} + void install_camera(HMODULE sceneWindow) { auto* vtable = reinterpret_cast(GetProcAddress(sceneWindow, "??_7CameraController@@6B@")); @@ -346,10 +440,12 @@ void install() install_step(sceneWindow); if (stepHooked) { // drawing between ticks needs the ticks install_pose(sceneWindow); + install_particles(sceneWindow); install_camera(sceneWindow); } - log::info("scene hooks: ticks {}, posing between ticks {}, camera between ticks {}", - stepHooked ? "recorded" : "not recorded", poseHooked ? "on" : "off", cameraHooked ? "on" : "off"); + log::info("scene hooks: ticks {}, posing between ticks {}, particles between ticks {}, camera between ticks {}", + stepHooked ? "recorded" : "not recorded", poseHooked ? "on" : "off", particlesHooked ? "on" : "off", + cameraHooked ? "on" : "off"); }); } diff --git a/adapter/scene_hooks.hpp b/adapter/scene_hooks.hpp index 5996717..7a6cfa2 100644 --- a/adapter/scene_hooks.hpp +++ b/adapter/scene_hooks.hpp @@ -9,6 +9,8 @@ // - ModelInstance::render's call to updateCalSkeleton, which poses the model before it's drawn: between ticks, the // model is posed at the time between them (its animations' times moved on by the fraction of a frame since the // tick; Cal3D poses between keyframes), then its animations are put back as they were. +// - ModelInstance::render's call to ParticleSystem::render: between ticks, particles drawn moved on by their velocity +// times the time since the tick (as the particle library moves them at each tick), then put back. // - CameraController::getCameraTransform (its exported vtable): between ticks, the camera where its easing toward its // target has brought it since the tick. diff --git a/docs/architecture.md b/docs/architecture.md index e7d6fb9..d27c832 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -97,7 +97,9 @@ their animations a fraction of a frame on (ModelInstance::render's call to updat between keyframes; animations held, looping or starting are posed as they are), and the camera where its easing (a share 0.4 of the way to its target per step) has brought it, `1 - 0.6^fraction` of the way (CameraController's getCameraTransform, in its exported vtable). Nothing is predicted: both are what the next step would give, at the -fraction. Still at 30 per second: particles, crossfades between animations, gaze. +fraction; and particles moved on by their velocity times the time since the step (ModelInstance::render's call to +ParticleSystem::render, redirected), then back by the same (the client sorts them by distance as it draws them, which +reorders them). Still at 30 per second: crossfades between animations, gaze. Next: sharing pipelines between views (each view's scene compiles its own).