Files
t5de-renderer/adapter/scene_hooks.cpp
T
dhkatzandClaude Opus 5.5 d1437a3923
CI / build (push) Successful in 1m38s
Move the camera between ticks
CameraController::step eases the camera's position, orbit angles and zoom a
share k (0.4) of the way to their targets at each tick; between ticks, the
adapter's getCameraTransform (its entry in CameraController's exported vtable,
replaced after checking it) sets them 1 - (1 - k)^fraction of the way for IMVU's
code to make the transform from, then puts them back. k is read from the
constant CameraController::step uses, checked to be referenced there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 21:44:56 -07:00

345 lines
14 KiB
C++

#include "scene_hooks.hpp"
#include <windows.h>
#include <algorithm>
#include <atomic>
#include <cmath>
#include <cstring>
#include <mutex>
#include <unordered_map>
#include "log.hpp"
namespace t5de::scene_hooks {
namespace {
// IMVU's SceneViewer::step() and ModelInstance::updateCalSkeleton(): methods, called with the object in ECX.
using Method = void(__thiscall*)(void* self);
Method originalStep = nullptr;
Method originalUpdateCalSkeleton = nullptr;
// IMVU's CameraController::getCameraTransform(): returns a Transform by value (constructed in result).
using GetTransform = void*(__thiscall*)(const void* self, void* result);
GetTransform originalGetCameraTransform = nullptr;
float cameraEasing = 0; // the share of the way to its target the camera moves at each tick
std::atomic<std::int64_t> stepTime = 0;
std::atomic<std::uint64_t> steps = 0;
std::atomic<bool> stepHooked = false;
std::atomic<bool> poseHooked = false;
std::atomic<bool> cameraHooked = 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; });
}
}
// The camera (SceneWindow.dll's CameraController::step, at each tick): each of these values moves a share k of the way
// to its target (k is 0.4): the camera's position (+0x118, 3 floats, to +0xf0), and its orbit angles and zoom, in
// degrees (+0x140, 3 floats, to +0x134; the difference taken between -180 and 180).
// A fraction f of a tick on, the same easing has moved it 1 - (1 - k)^f of the way: exactly, nothing guessed.
constexpr std::size_t cameraPosition = 0x118;
constexpr std::size_t cameraPositionTarget = 0xf0;
constexpr std::size_t cameraAngles = 0x140;
constexpr std::size_t cameraAnglesTarget = 0x134;
// Nonzero: the camera shows a fixed view ("take"), not eased.
constexpr std::size_t cameraTake = 0xec;
float degrees180(float degrees)
{
float wrapped = std::fmod(degrees + 180.0f, 360.0f);
if (wrapped < 0) {
wrapped += 360.0f;
}
return wrapped - 180.0f;
}
// Our CameraController::getCameraTransform: between ticks, the camera where its easing has brought it since the tick.
// The eased values are set ahead for IMVU's code to make the transform from, then put back. __fastcall takes ECX like
// __thiscall, and like it the callee pops the stack argument.
void* __fastcall getCameraTransform(void* camera, void* /*edx*/, void* result)
{
const float fraction = tick_fraction();
if (fraction < 0.01f || field<std::int32_t>(camera, cameraTake) != 0) {
return originalGetCameraTransform(camera, result);
}
const float moved = 1.0f - std::pow(1.0f - cameraEasing, fraction);
float saved[6];
for (int i = 0; i < 3; ++i) {
float& position = field<float>(camera, cameraPosition + i * 4);
saved[i] = position;
position += (field<float>(camera, cameraPositionTarget + i * 4) - position) * moved;
float& angle = field<float>(camera, cameraAngles + i * 4);
saved[3 + i] = angle;
angle = degrees180(angle + degrees180(field<float>(camera, cameraAnglesTarget + i * 4) - angle) * moved);
}
void* transform = originalGetCameraTransform(camera, result);
for (int i = 0; i < 3; ++i) {
field<float>(camera, cameraPosition + i * 4) = saved[i];
field<float>(camera, cameraAngles + i * 4) = saved[3 + i];
}
return transform;
}
// Replaces the vtable entry at slot, if it holds expected; returns whether it did.
bool replace(void** slot, void* expected, void* replacement)
{
if (*slot != expected) {
return false;
}
DWORD protection = 0;
if (!VirtualProtect(slot, sizeof(void*), PAGE_READWRITE, &protection)) {
return false;
}
*slot = replacement;
VirtualProtect(slot, sizeof(void*), protection, &protection);
FlushInstructionCache(GetCurrentProcess(), slot, sizeof(void*));
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;
}
void install_camera(HMODULE sceneWindow)
{
auto* vtable = reinterpret_cast<void**>(GetProcAddress(sceneWindow, "??_7CameraController@@6B@"));
auto* get = GetProcAddress(sceneWindow, "?getCameraTransform@CameraController@@UBE?AVTransform@@XZ");
auto* step = reinterpret_cast<const std::byte*>(GetProcAddress(sceneWindow, "?step@CameraController@@UAEXXZ"));
if (vtable == nullptr || get == nullptr || step == nullptr) {
log::info("scene hooks: CameraController's vtable, getCameraTransform or step not found");
return;
}
// The easing share: a float constant CameraController::step reads (at SceneWindow.dll's 0x100cb934 when loaded at
// its preferred address), checked to be referenced by step's code and to be the expected 0.4.
const auto* base = reinterpret_cast<const std::byte*>(sceneWindow);
const auto* easing = reinterpret_cast<const float*>(base + 0xcb934);
const auto address = static_cast<std::uint32_t>(reinterpret_cast<std::uintptr_t>(easing));
bool referenced = false;
for (std::size_t i = 0; i + 4 <= 0x700 && !referenced; ++i) {
referenced = std::memcmp(step + i, &address, 4) == 0;
}
if (!referenced || *easing != 0.4f) {
log::info("scene hooks: CameraController::step's easing not where expected");
return;
}
cameraEasing = *easing;
// CameraController's vtable: getCameraTransform is its 6th entry.
originalGetCameraTransform = reinterpret_cast<GetTransform>(get);
if (!replace(&vtable[5], reinterpret_cast<void*>(get), reinterpret_cast<void*>(&getCameraTransform))) {
log::info("scene hooks: CameraController's vtable doesn't hold its getCameraTransform");
return;
}
cameraHooked = true;
}
} // namespace
void install()
{
static std::once_flag once;
std::call_once(once, [] {
HMODULE sceneWindow = GetModuleHandleW(L"SceneWindow.dll");
if (sceneWindow == nullptr) {
log::info("scene hooks: SceneWindow.dll not loaded");
return;
}
install_step(sceneWindow);
if (stepHooked) { // drawing between ticks needs the ticks
install_pose(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");
});
}
bool installed() { return stepHooked; }
std::int64_t lastStep() { return stepTime; }
} // namespace t5de::scene_hooks