diff --git a/adapter/scene_hooks.cpp b/adapter/scene_hooks.cpp index f667523..569da48 100644 --- a/adapter/scene_hooks.cpp +++ b/adapter/scene_hooks.cpp @@ -2,8 +2,11 @@ #include +#include #include +#include #include +#include #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 stepTime = 0; -std::atomic hooked = false; +std::atomic steps = 0; +std::atomic stepHooked = false; +std::atomic 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 +T& field(void* object, std::size_t offset) +{ + return *reinterpret_cast(static_cast(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 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(now.QuadPart - last) * 1000.0 / static_cast(frequency().QuadPart); + return static_cast(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(model, modelEffects); + for (void* node = head != nullptr ? field(head, 0) : nullptr; node != nullptr && node != head && count < 64; + node = field(node, 0)) { + void* effect = field(node, 8); + if (effect == nullptr || field(effect, effectPlaying) == 0) { + continue; + } + void* animation = field(effect, effectAnimation); + if (animation == nullptr) { + continue; + } + const float time = field(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(animation, animationTime); + saved[count++] = {&posedTime, posedTime}; + posedTime = time + fraction * frame; + } + if (count == 0) { + originalUpdateCalSkeleton(model); + return; + } + field(model, modelPoseDirty) = 1; + originalUpdateCalSkeleton(model); + for (std::size_t i = 0; i < count; ++i) { + *saved[i].time = saved[i].value; + } + field(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(static_cast(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(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(stepMethod); + if (!replace(&vtable[1], reinterpret_cast(stepMethod), reinterpret_cast(&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(GetProcAddress(zero, "?millisecondsPerFrame@@3IB")); + secondsPerFrame = zero == nullptr ? nullptr : reinterpret_cast(GetProcAddress(zero, "?secondsPerFrame@@3MB")); + auto* render = reinterpret_cast(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(update); + if (!redirect_call(render + 0x12, reinterpret_cast(update), reinterpret_cast(&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(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(stepMethod); - if (!replace(&vtable[1], reinterpret_cast(stepMethod), reinterpret_cast(&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; } diff --git a/adapter/scene_hooks.hpp b/adapter/scene_hooks.hpp index 2f3dcdc..af93816 100644 --- a/adapter/scene_hooks.hpp +++ b/adapter/scene_hooks.hpp @@ -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