3 Commits
Author SHA1 Message Date
dhkatzandClaude Opus 5.5 2c55a6ee25 Version 0.3.0
CI / build (push) Successful in 22s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:47:11 -07:00
dhkatzandClaude Opus 5.5 db344b7b95 Crossfade animations between ticks
CI / build (push) Successful in 1m30s
ModelInstance::step ramps the weights of animations fading in and out in tenths
over 10 ticks; between ticks, the posing hook sets them partway to the next
tick's weight, and puts them back with the times. Only if ModelInstance::step
counts the fades where expected (checked when installing); otherwise
crossfades stay at the client's rate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:41:23 -07:00
dhkatzandClaude Opus 5.5 9d0faecf4d Move particles between ticks
CI / build (push) Successful in 53s
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 <[email protected]>
2026-10-28 22:21:58 -07:00
4 changed files with 169 additions and 20 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
cmake_minimum_required(VERSION 3.28) cmake_minimum_required(VERSION 3.28)
project(t5de-renderer project(t5de-renderer
VERSION 0.2.0 VERSION 0.3.0
DESCRIPTION "A new 3D renderer for the IMVU Classic client" DESCRIPTION "A new 3D renderer for the IMVU Classic client"
LANGUAGES CXX) LANGUAGES CXX)
+160 -17
View File
@@ -30,7 +30,26 @@ std::atomic<std::uint64_t> steps = 0;
std::atomic<int> stepTicks = 1; std::atomic<int> stepTicks = 1;
std::atomic<bool> stepHooked = false; std::atomic<bool> stepHooked = false;
std::atomic<bool> poseHooked = false; std::atomic<bool> poseHooked = false;
bool crossfades = false; // whether ModelInstance::step's crossfades are as expected (checked when installing)
std::atomic<bool> cameraHooked = false; std::atomic<bool> cameraHooked = false;
std::atomic<bool> 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). // zero.dll's constants: a tick, in milliseconds (33), and an animation frame, in seconds (1/30).
const unsigned* millisecondsPerFrame = nullptr; const unsigned* millisecondsPerFrame = nullptr;
@@ -70,13 +89,28 @@ void __fastcall step(void* self, void* /*edx*/)
// - ModelInstance: +0x2c, a flag: its skeleton must be posed again; +0x120, its animation effects, a std::list (a pointer // - ModelInstance: +0x2c, a flag: its skeleton must be posed again; +0x120, its animation effects, a std::list (a pointer
// to the list's head node; a node: next, previous, then the effect). // to the list's head node; a node: next, previous, then the effect).
// - Effect: +0x34, playing (a flag); +0x3c, its time, in seconds; +0x90, its Cal3D animation. // - Effect: +0x34, playing (a flag); +0x3c, its time, in seconds; +0x90, its Cal3D animation.
// - The Cal3D animation: +0x8, its time, which the mixer poses the skeleton at (CalMixer::updateSkeleton). // - The Cal3D animation: +0x8, its time, which the mixer poses the skeleton at (CalMixer::updateSkeleton); +0xc, its
// weight in the pose.
//
// Crossfades (ModelInstance::step, at each tick): an effect fading in (+0x44 set, +0x18 nonzero) has its weight
// (+0x38) ramped over 10 ticks, counted at +0x48: (n + 1) * 0.1 * weight, then n goes up. One fading out is in the
// model's crossfade list (+0x138, a std::list; a node holds the effect at +0xc and its count at +0x14): m goes up, then
// (1 - m * 0.1) * weight; after 10, it's removed. Between ticks, both are partway to the next tick's weight.
constexpr std::size_t modelPoseDirty = 0x2c; constexpr std::size_t modelPoseDirty = 0x2c;
constexpr std::size_t modelEffects = 0x120; constexpr std::size_t modelEffects = 0x120;
constexpr std::size_t modelFadingOut = 0x138;
constexpr std::size_t effectPlaying = 0x34; constexpr std::size_t effectPlaying = 0x34;
constexpr std::size_t effectTime = 0x3c; constexpr std::size_t effectTime = 0x3c;
constexpr std::size_t effectAnimation = 0x90; constexpr std::size_t effectAnimation = 0x90;
constexpr std::size_t effectFadesIn = 0x44;
constexpr std::size_t effectFadeSource = 0x18;
constexpr std::size_t effectFadeCount = 0x48;
constexpr std::size_t effectWeight = 0x38;
constexpr std::size_t fadingOutEffect = 0xc;
constexpr std::size_t fadingOutCount = 0x14;
constexpr std::size_t animationTime = 0x8; constexpr std::size_t animationTime = 0x8;
constexpr std::size_t animationWeight = 0xc;
constexpr unsigned fadeTicks = 10;
template <class T> template <class T>
T& field(void* object, std::size_t offset) T& field(void* object, std::size_t offset)
@@ -108,8 +142,9 @@ float tick_fraction()
// Our ModelInstance::updateCalSkeleton, called where ModelInstance::render poses the model before drawing it: between // Our ModelInstance::updateCalSkeleton, called where ModelInstance::render poses the model before drawing it: between
// ticks, poses it at the time between them. Each playing animation that moved on at the last step is set ahead by the // ticks, poses it at the time between them. Each playing animation that moved on at the last step is set ahead by the
// fraction of a tick since, at the rate it moved (up to 4 frames a tick), the skeleton posed (by IMVU's), and the // fraction of a tick since, at the rate it moved (up to 4 frames a tick), and crossfading animations' weights partway
// times put back, with the skeleton marked to be posed again, so everything else sees the tick's pose. // to the next tick's; the skeleton is posed (by IMVU's), and the times and weights put back, with the skeleton marked to
// be posed again, so everything else sees the tick's pose.
void __fastcall updateCalSkeleton(void* model, void* /*edx*/) void __fastcall updateCalSkeleton(void* model, void* /*edx*/)
{ {
const float fraction = tick_fraction(); const float fraction = tick_fraction();
@@ -121,20 +156,30 @@ void __fastcall updateCalSkeleton(void* model, void* /*edx*/)
const float frame = *secondsPerFrame; const float frame = *secondsPerFrame;
struct Saved { struct Saved {
float* time; float* value;
float value; float was;
}; };
Saved saved[64]; Saved saved[128];
std::size_t count = 0; std::size_t count = 0;
const auto set = [&](float& value, float to) {
saved[count++] = {&value, value};
value = to;
};
void* head = field<void*>(model, modelEffects); void* head = field<void*>(model, modelEffects);
for (void* node = head != nullptr ? field<void*>(head, 0) : nullptr; node != nullptr && node != head && count < 64; for (void* node = head != nullptr ? field<void*>(head, 0) : nullptr; node != nullptr && node != head && count < 126;
node = field<void*>(node, 0)) { node = field<void*>(node, 0)) {
void* effect = field<void*>(node, 8); void* effect = field<void*>(node, 8);
if (effect == nullptr || field<std::uint8_t>(effect, effectPlaying) == 0) { void* animation = effect != nullptr ? field<void*>(effect, effectAnimation) : nullptr;
if (animation == nullptr) {
continue; continue;
} }
void* animation = field<void*>(effect, effectAnimation); const unsigned faded = field<std::uint32_t>(effect, effectFadeCount);
if (animation == nullptr) { if (crossfades && field<std::uint8_t>(effect, effectFadesIn) != 0 && field<std::int32_t>(effect, effectFadeSource) != 0
&& faded >= 1 && faded < fadeTicks) {
set(field<float>(animation, animationWeight),
(static_cast<float>(faded) + fraction) * 0.1f * field<float>(effect, effectWeight));
}
if (field<std::uint8_t>(effect, effectPlaying) == 0) {
continue; continue;
} }
const float time = field<float>(effect, effectTime); const float time = field<float>(effect, effectTime);
@@ -148,9 +193,18 @@ void __fastcall updateCalSkeleton(void* model, void* /*edx*/)
if (history.previous < 0 || perTick <= frame * 0.01f || perTick > frame * 4) { if (history.previous < 0 || perTick <= frame * 0.01f || perTick > frame * 4) {
continue; // held, looped back, or just started: as it is continue; // held, looped back, or just started: as it is
} }
float& posedTime = field<float>(animation, animationTime); set(field<float>(animation, animationTime), time + fraction * perTick);
saved[count++] = {&posedTime, posedTime}; }
posedTime = time + fraction * perTick; void* fadingHead = crossfades ? field<void*>(model, modelFadingOut) : nullptr;
for (void* node = fadingHead != nullptr ? field<void*>(fadingHead, 0) : nullptr;
node != nullptr && node != fadingHead && count < 127; node = field<void*>(node, 0)) {
void* effect = field<void*>(node, fadingOutEffect);
void* animation = effect != nullptr ? field<void*>(effect, effectAnimation) : nullptr;
const unsigned faded = field<std::uint32_t>(node, fadingOutCount);
if (animation != nullptr && faded >= 1 && faded < fadeTicks) {
set(field<float>(animation, animationWeight),
(1.0f - (static_cast<float>(faded) + fraction) * 0.1f) * field<float>(effect, effectWeight));
}
} }
if (count == 0) { if (count == 0) {
originalUpdateCalSkeleton(model); originalUpdateCalSkeleton(model);
@@ -158,8 +212,8 @@ void __fastcall updateCalSkeleton(void* model, void* /*edx*/)
} }
field<std::uint8_t>(model, modelPoseDirty) = 1; field<std::uint8_t>(model, modelPoseDirty) = 1;
originalUpdateCalSkeleton(model); originalUpdateCalSkeleton(model);
for (std::size_t i = 0; i < count; ++i) { for (std::size_t i = count; i-- > 0;) {
*saved[i].time = saved[i].value; *saved[i].value = saved[i].was;
} }
field<std::uint8_t>(model, modelPoseDirty) = 1; field<std::uint8_t>(model, modelPoseDirty) = 1;
@@ -224,6 +278,54 @@ void* __fastcall getCameraTransform(void* camera, void* /*edx*/, void* result)
return transform; 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<std::byte*>(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<float>(*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. // Replaces the vtable entry at slot, if it holds expected; returns whether it did.
bool replace(void** slot, void* expected, void* replacement) bool replace(void** slot, void* expected, void* replacement)
{ {
@@ -298,6 +400,45 @@ void install_pose(HMODULE sceneWindow)
return; return;
} }
poseHooked = true; poseHooked = true;
// Crossfades: ModelInstance::step's instructions that count a fade in (cmp dword ptr [eax + 0x48], 10) and a fade
// out (cmp dword ptr [ebx + 0x14], 10), where expected.
auto* step = reinterpret_cast<const std::byte*>(GetProcAddress(sceneWindow, "?step@ModelInstance@@QAEXABVFrameInterval@@@Z"));
constexpr std::uint8_t fadeIn[] = {0x83, 0x78, 0x48, 0x0a};
constexpr std::uint8_t fadeOut[] = {0x83, 0x7b, 0x14, 0x0a};
crossfades = step != nullptr && std::memcmp(step + 0xc6, fadeIn, sizeof(fadeIn)) == 0
&& std::memcmp(step + 0xf1, fadeOut, sizeof(fadeOut)) == 0;
if (!crossfades) {
log::info("scene hooks: ModelInstance::step's crossfades not as expected; crossfades at the client's rate");
}
}
void install_particles(HMODULE sceneWindow)
{
HMODULE particleLib = GetModuleHandleW(L"ParticleLib.dll");
getParticlePointer = particleLib == nullptr
? nullptr
: reinterpret_cast<GetParticlePointer>(GetProcAddress(
particleLib, "?GetParticlePointer@PContextParticleGroup_t@PAPI@@QAEIAAPAMAAI1111111111111@Z"));
getGroupCount = particleLib == nullptr ? nullptr
: reinterpret_cast<GetGroupCount>(GetProcAddress(
particleLib, "?GetGroupCount@PContextParticleGroup_t@PAPI@@QAEIXZ"));
auto* modelRender = reinterpret_cast<std::byte*>(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<RenderParticles>(render);
if (!redirect_call(modelRender + 0x114, reinterpret_cast<void*>(render), reinterpret_cast<void*>(&renderParticles))) {
log::info("scene hooks: ModelInstance::render doesn't call ParticleSystem::render where expected");
return;
}
particlesHooked = true;
} }
void install_camera(HMODULE sceneWindow) void install_camera(HMODULE sceneWindow)
@@ -346,10 +487,12 @@ void install()
install_step(sceneWindow); install_step(sceneWindow);
if (stepHooked) { // drawing between ticks needs the ticks if (stepHooked) { // drawing between ticks needs the ticks
install_pose(sceneWindow); install_pose(sceneWindow);
install_particles(sceneWindow);
install_camera(sceneWindow); install_camera(sceneWindow);
} }
log::info("scene hooks: ticks {}, posing between ticks {}, camera between ticks {}", log::info("scene hooks: ticks {}, posing between ticks {}, particles between ticks {}, camera between ticks {}",
stepHooked ? "recorded" : "not recorded", poseHooked ? "on" : "off", cameraHooked ? "on" : "off"); stepHooked ? "recorded" : "not recorded", poseHooked ? "on" : "off", particlesHooked ? "on" : "off",
cameraHooked ? "on" : "off");
}); });
} }
+4 -1
View File
@@ -8,7 +8,10 @@
// calls stepFrame, which steps each scene viewer). Recorded, for the frame pacer and for drawing between ticks. // calls stepFrame, which steps each scene viewer). Recorded, for the frame pacer and for drawing between ticks.
// - ModelInstance::render's call to updateCalSkeleton, which poses the model before it's drawn: between ticks, the // - 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 // 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. // tick; Cal3D poses between keyframes) and crossfading animations' weights partway to the next tick's, 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 // - CameraController::getCameraTransform (its exported vtable): between ticks, the camera where its easing toward its
// target has brought it since the tick. // target has brought it since the tick.
+4 -1
View File
@@ -97,7 +97,10 @@ 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 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 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 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); and crossfades between animations (weights ramped in tenths over 10 steps by ModelInstance::step),
partway to the next step's weights. Still at 30 per second: gaze (heads turning to whoever's talking).
Next: sharing pipelines between views (each view's scene compiles its own). Next: sharing pipelines between views (each view's scene compiles its own).