Pose avatars between ticks
CI / build (push) Successful in 27s

ModelInstance::render poses its model (updateCalSkeleton) before drawing it;
the adapter redirects that one call (after checking it) to ours, which between
ticks moves each playing animation that went on a frame at the last tick ahead
by the fraction of a frame since, has IMVU's code pose the skeleton (Cal3D
poses between keyframes), and puts the animations back, marking the skeleton
to be posed again. Held, looping and starting animations are posed as they
are. The frame lengths come from zero.dll's millisecondsPerFrame and
secondsPerFrame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-28 21:38:07 -07:00
1 parent 065c7e9300
commit 2471b1fd4f
2 files changed
+207 -22

No files matched your search

+200 -18
View File
@@ -2,8 +2,11 @@
#include <windows.h>
#include <algorithm>
#include <atomic>
#include <cstring>
#include <mutex>
#include <unordered_map>
#include "log.hpp"
@@ -11,22 +14,147 @@ namespace t5de::scene_hooks {
namespace {
// IMVU's SceneViewer::step(), a virtual method: called with the scene viewer in ECX.
using StepFunction = void(__thiscall*)(void* self);
// IMVU's SceneViewer::step() and ModelInstance::updateCalSkeleton(): methods, called with the object in ECX.
using Method = void(__thiscall*)(void* self);
StepFunction originalStep = nullptr;
Method originalStep = nullptr;
Method originalUpdateCalSkeleton = nullptr;
std::atomic<std::int64_t> stepTime = 0;
std::atomic<bool> hooked = false;
std::atomic<std::uint64_t> steps = 0;
std::atomic<bool> stepHooked = false;
std::atomic<bool> poseHooked = false;
// zero.dll's constants: a tick, in milliseconds (33), and an animation frame, in seconds (1/30).
const unsigned* millisecondsPerFrame = nullptr;
const float* secondsPerFrame = nullptr;
LARGE_INTEGER frequency()
{
static const LARGE_INTEGER value = [] {
LARGE_INTEGER f;
QueryPerformanceFrequency(&f);
return f;
}();
return value;
}
// Our SceneViewer::step: records the tick, then steps as IMVU's does. __fastcall takes ECX like __thiscall (EDX unused).
// Each scene viewer steps in turn at a tick: steps closer than 5 ms are the same tick.
void __fastcall step(void* self, void* /*edx*/)
{
LARGE_INTEGER now;
QueryPerformanceCounter(&now);
if (now.QuadPart - stepTime.load() > frequency().QuadPart / 200) {
++steps;
}
stepTime = now.QuadPart;
originalStep(self);
}
// IMVU's objects, as far as posing between ticks reads and writes them (from SceneWindow.dll's ModelInstance::step,
// which copies each effect's time and weight into its Cal3D animation at every tick, and updateCalSkeleton):
// - 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).
// - 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).
constexpr std::size_t modelPoseDirty = 0x2c;
constexpr std::size_t modelEffects = 0x120;
constexpr std::size_t effectPlaying = 0x34;
constexpr std::size_t effectTime = 0x3c;
constexpr std::size_t effectAnimation = 0x90;
constexpr std::size_t animationTime = 0x8;
template <class T>
T& field(void* object, std::size_t offset)
{
return *reinterpret_cast<T*>(static_cast<std::byte*>(object) + offset);
}
// Each effect's time at the last two ticks it was posed at, to tell an animation playing on (one frame a tick) from
// one held, looping back or just started (posed as it is).
struct EffectHistory {
std::uint64_t step = 0;
float time = 0;
float previous = -1;
};
std::unordered_map<void*, EffectHistory> histories;
// How far into the current tick the client is, from 0 to just under 1; 0 if unknown.
float tick_fraction()
{
const std::int64_t last = stepTime.load();
if (last == 0 || millisecondsPerFrame == nullptr || *millisecondsPerFrame == 0) {
return 0;
}
LARGE_INTEGER now;
QueryPerformanceCounter(&now);
const double elapsed = static_cast<double>(now.QuadPart - last) * 1000.0 / static_cast<double>(frequency().QuadPart);
return static_cast<float>(std::clamp(elapsed / *millisecondsPerFrame, 0.0, 0.999));
}
// 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 a frame at the last tick is set
// ahead by the fraction of a frame since, the skeleton posed (by IMVU's), and the times 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*/)
{
const float fraction = tick_fraction();
if (fraction < 0.01f || secondsPerFrame == nullptr) {
originalUpdateCalSkeleton(model);
return;
}
const std::uint64_t tick = steps.load();
const float frame = *secondsPerFrame;
struct Saved {
float* time;
float value;
};
Saved saved[64];
std::size_t count = 0;
void* head = field<void*>(model, modelEffects);
for (void* node = head != nullptr ? field<void*>(head, 0) : nullptr; node != nullptr && node != head && count < 64;
node = field<void*>(node, 0)) {
void* effect = field<void*>(node, 8);
if (effect == nullptr || field<std::uint8_t>(effect, effectPlaying) == 0) {
continue;
}
void* animation = field<void*>(effect, effectAnimation);
if (animation == nullptr) {
continue;
}
const float time = field<float>(effect, effectTime);
EffectHistory& history = histories[effect];
if (history.step != tick) {
history.previous = history.step + 1 == tick ? history.time : -1;
history.time = time;
history.step = tick;
}
const float moved = history.time - history.previous;
if (history.previous < 0 || moved < frame * 0.5f || moved > frame * 1.5f) {
continue; // held, looped back, or just started: as it is
}
float& posedTime = field<float>(animation, animationTime);
saved[count++] = {&posedTime, posedTime};
posedTime = time + fraction * frame;
}
if (count == 0) {
originalUpdateCalSkeleton(model);
return;
}
field<std::uint8_t>(model, modelPoseDirty) = 1;
originalUpdateCalSkeleton(model);
for (std::size_t i = 0; i < count; ++i) {
*saved[i].time = saved[i].value;
}
field<std::uint8_t>(model, modelPoseDirty) = 1;
// Forget effects not posed for a while (they may be gone).
if (histories.size() > 1024) {
std::erase_if(histories, [tick](const auto& entry) { return entry.second.step + 30 < tick; });
}
}
// Replaces the vtable entry at slot, if it holds expected; returns whether it did.
bool replace(void** slot, void* expected, void* replacement)
{
@@ -43,6 +171,66 @@ bool replace(void** slot, void* expected, void* replacement)
return true;
}
// Redirects the call instruction (E8, then the target relative to the next instruction) at site, if it calls
// expected; returns whether it did.
bool redirect_call(std::byte* site, void* expected, void* replacement)
{
std::int32_t offset = 0;
std::memcpy(&offset, site + 1, sizeof(offset));
if (site[0] != std::byte{0xe8} || site + 5 + offset != expected) {
return false;
}
const auto replacementOffset = static_cast<std::int32_t>(static_cast<std::byte*>(replacement) - (site + 5));
DWORD protection = 0;
if (!VirtualProtect(site, 5, PAGE_EXECUTE_READWRITE, &protection)) {
return false;
}
std::memcpy(site + 1, &replacementOffset, sizeof(replacementOffset));
VirtualProtect(site, 5, protection, &protection);
FlushInstructionCache(GetCurrentProcess(), site, 5);
return true;
}
void install_step(HMODULE sceneWindow)
{
auto* vtable = reinterpret_cast<void**>(GetProcAddress(sceneWindow, "??_7SceneViewer@@6BIFrameListener@@@"));
auto* stepMethod = GetProcAddress(sceneWindow, "?step@SceneViewer@@UAEXXZ");
// IFrameListener's vtable: its destructor, then step (what stepFrame calls).
if (vtable == nullptr || stepMethod == nullptr) {
log::info("scene hooks: SceneViewer's vtable or step not found");
return;
}
originalStep = reinterpret_cast<Method>(stepMethod);
if (!replace(&vtable[1], reinterpret_cast<void*>(stepMethod), reinterpret_cast<void*>(&step))) {
log::info("scene hooks: SceneViewer's vtable doesn't hold its step");
return;
}
stepHooked = true;
}
void install_pose(HMODULE sceneWindow)
{
HMODULE zero = GetModuleHandleW(L"zero.dll");
millisecondsPerFrame =
zero == nullptr ? nullptr : reinterpret_cast<const unsigned*>(GetProcAddress(zero, "?millisecondsPerFrame@@3IB"));
secondsPerFrame = zero == nullptr ? nullptr : reinterpret_cast<const float*>(GetProcAddress(zero, "?secondsPerFrame@@3MB"));
auto* render = reinterpret_cast<std::byte*>(GetProcAddress(
sceneWindow, "?render@ModelInstance@@QAEXPAVRenderManager@@ABVCullingFrustum@@ABVTransform@@2ABUFogState@@"
"ABULightState@@_NAAVSceneStats@@ABVRenderSettings@@@Z"));
auto* update = GetProcAddress(sceneWindow, "?updateCalSkeleton@ModelInstance@@AAEXXZ");
if (millisecondsPerFrame == nullptr || secondsPerFrame == nullptr || render == nullptr || update == nullptr) {
log::info("scene hooks: ModelInstance::render, updateCalSkeleton or zero.dll's frame lengths not found");
return;
}
// ModelInstance::render poses the model first: its 5th instruction, at +0x12, calls updateCalSkeleton.
originalUpdateCalSkeleton = reinterpret_cast<Method>(update);
if (!redirect_call(render + 0x12, reinterpret_cast<void*>(update), reinterpret_cast<void*>(&updateCalSkeleton))) {
log::info("scene hooks: ModelInstance::render doesn't call updateCalSkeleton where expected");
return;
}
poseHooked = true;
}
} // namespace
void install()
@@ -50,26 +238,20 @@ void install()
static std::once_flag once;
std::call_once(once, [] {
HMODULE sceneWindow = GetModuleHandleW(L"SceneWindow.dll");
auto* vtable = sceneWindow == nullptr
? nullptr
: reinterpret_cast<void**>(GetProcAddress(sceneWindow, "??_7SceneViewer@@6BIFrameListener@@@"));
auto* stepMethod = sceneWindow == nullptr ? nullptr : GetProcAddress(sceneWindow, "?step@SceneViewer@@UAEXXZ");
// IFrameListener's vtable: its destructor, then step (what stepFrame calls).
if (vtable == nullptr || stepMethod == nullptr) {
log::info("scene hooks: SceneViewer's vtable or step not found; not installed");
if (sceneWindow == nullptr) {
log::info("scene hooks: SceneWindow.dll not loaded");
return;
}
originalStep = reinterpret_cast<StepFunction>(stepMethod);
if (!replace(&vtable[1], reinterpret_cast<void*>(stepMethod), reinterpret_cast<void*>(&step))) {
log::info("scene hooks: SceneViewer's vtable doesn't hold its step; not installed");
return;
install_step(sceneWindow);
if (stepHooked) {
install_pose(sceneWindow); // posing between ticks needs the ticks
}
hooked = true;
log::info("scene hooks: installed");
log::info("scene hooks: ticks {}, posing between ticks {}", stepHooked ? "recorded" : "not recorded",
poseHooked ? "on" : "off");
});
}
bool installed() { return hooked; }
bool installed() { return stepHooked; }
std::int64_t lastStep() { return stepTime; }
+7 -4
View File
@@ -1,11 +1,14 @@
#pragma once
// Our hooks into SceneWindow.dll's scene code, installed once, the first time an engine is made. Each replaces a
// pointer IMVU's code calls through (an entry of an exported vtable), after checking it holds the function expected,
// with ours, which calls IMVU's: nothing else is changed, and nothing is if a check fails (an unexpected
// SceneWindow.dll: the hooks' features are then off, and the client runs as it would without them).
// Our hooks into SceneWindow.dll's scene code, installed once, the first time an engine is made. Each replaces where
// IMVU's code calls a function (an entry of an exported vtable, or a call instruction's target), after checking it
// calls the function expected, with ours, which calls IMVU's: nothing else is changed, and nothing is if a check
// fails (an unexpected SceneWindow.dll: the hooks' features are then off, and the client runs as it would without).
//
// - SceneViewer::step (the IFrameListener vtable): the client's tick, 30 times a second (SceneWindow.dll's runApp
// 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
// 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.
#include <cstdint>