CI / build (push) Successful in 1m38s
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]>
345 lines
14 KiB
C++
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
|