Skip to content
This repository was archived by the owner on Oct 18, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Code/client/Components/InterpolationComponent.h
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,16 @@ struct InterpolationComponent
// InterpolationSystem::Update: the first frame warps unconditionally, and every frame after it warps
// only when the body is not already there.
bool HasWarped{};

#if TP_SKYRIMVR
// The character controller the capsule was last read through, and whether it is the rigid body kind that has
// one to read. Checked by RTTI only when the pointer changes. See ReadCapsule in InterpolationSystem.
const void* Controller{};
bool ControllerHasCapsule{};

// Where the capsule settles across the ground relative to the reference, taken on the frame after a warp, and
// whether the last frame warped. Not zero for every body: an elk's rests 11.9 units off.
glm::vec2 CapsuleRest{};
bool WarpedLastFrame{};
#endif
};
66 changes: 66 additions & 0 deletions Code/client/Services/CollisionService.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#pragma once

struct World;
struct TransportService;
struct UpdateEvent;
struct DisconnectedEvent;

/**
* @brief Keeps every character except this client's own player from shoving loose objects about.
*
* This is the item drift fix. A body that is not this client's is moved by being teleported, by
* `InterpolationSystem`, and a teleport into an object has havok resolve the overlap by ejecting whatever it
* landed in, which sends that object sliding across the room and never stopping. The contact is removed rather
* than rationed: rate capping the warps was tried and changed nothing.
*
* It works through Skyrim's layer-versus-layer collision table, a bitfield per collision layer naming the layers
* it meets, so one write covers every actor in the game rather than each body as it spawns. That is also its
* limit, because the table is per layer and not per body, and every character in the game shares those layers.
* So the local player, whose collision is the one that is never teleported and therefore never ejects anything,
* is given mirror layers of their own: unused rows holding a copy of each character row, which lets exactly one
* character back through the pairs the table now closes.
*
* What that adds up to is the behaviour asked for: the player collides with everything, and an NPC or another
* player's body collides with the player and can still be hit, but cannot move the clutter or the dropped
* weapons in the room.
*
* Governed by the server's `bDisableCollisionBetweenOtherCharactersAndObjects`, off by default, so a server that
* says nothing gets the game exactly as it shipped, drift included.
*
* Does nothing at all on a Skyrim SE build.
*/
struct CollisionService
{
CollisionService(World& aWorld, entt::dispatcher& aDispatcher, TransportService& aTransport) noexcept;
~CollisionService() noexcept = default;

TP_NOCOPYMOVE(CollisionService);

private:
/**
* @brief Decides, once per frame, whether anything needs doing, and almost always decides that it does not.
*
* Everything expensive here reads unverified game memory through `IsReadable`, which is a `VirtualQuery` and
* therefore a syscall. An earlier version of this resolved the player's collision on every frame; the resolve
* failed, failure is the case that falls through to a search, and a thousand syscalls a frame at 72Hz made
* the game unplayable. So the frame path is made only of reads that cost nothing, and the work runs when one
* of them says the answer can have changed.
*/
void OnUpdate(const UpdateEvent& acEvent) noexcept;

// Puts the player's own layers back. A mirror row only means something in the table of a world this patched.
void OnDisconnected(const DisconnectedEvent& acEvent) noexcept;

World& m_world;
TransportService& m_transport;

entt::scoped_connection m_updateConnection;
entt::scoped_connection m_disconnectedConnection;

// What the last pass saw. Each of these changes which bodies the player has, or which physics world they are
// in, and each is a plain load: a pointer, a pointer, a flag, a float.
const void* m_root{nullptr};
const void* m_cell{nullptr};
bool m_weaponDrawn{false};
double m_sinceLastPass{0.0};
};
283 changes: 3 additions & 280 deletions Code/client/Services/Generic/CharacterService.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -71,276 +71,6 @@
#include <World.h>
#include <Games/TES.h>

#if TP_SKYRIMVR
#include <AI/AIProcess.h>
#include <Misc/MiddleProcess.h>

namespace
{
bool IsReadable(const void* apPtr, const size_t aSize) noexcept
{
if (!apPtr)
return false;

MEMORY_BASIC_INFORMATION info{};
if (!VirtualQuery(apPtr, &info, sizeof(info)))
return false;

if (info.State != MEM_COMMIT || (info.Protect & PAGE_GUARD))
return false;

constexpr DWORD cReadable = PAGE_READONLY | PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY;
if (!(info.Protect & cReadable))
return false;

const auto cRegionEnd = reinterpret_cast<uintptr_t>(info.BaseAddress) + info.RegionSize;

return reinterpret_cast<uintptr_t>(apPtr) + aSize <= cRegionEnd;
}

/**
* @brief The decorated type name of a candidate pointer, by reading MSVC's RTTI. Null if it is not an object.
*
* Reads only. The previous attempt asked the object for its type by calling vtable slot 2, which crashed the
* session of 2026-08-23 17:09 with `call rax` on a candidate that was not an NiObject at all: readable memory
* says nothing about what calling through it will do, and the type cannot be checked by a call that presumes
* the type. Nothing here transfers control, so the worst a bad candidate costs is a failed check.
*
* The complete object locator sits at `vtable[-1]`. On x64 its fields past the signature are image relative,
* and it stores its own RVA, so subtracting that from where it was found gives the base of whichever module
* owns it without having to know which one that is. The base is then confirmed by its `MZ`, which makes a
* false positive essentially impossible.
*/
const char* RttiName(const void* apCandidate) noexcept
{
if (!IsReadable(apCandidate, sizeof(void*)))
return nullptr;

const auto* cpVtable = *reinterpret_cast<const uint8_t* const*>(apCandidate);

if (!IsReadable(cpVtable - sizeof(void*), sizeof(void*)))
return nullptr;

const auto* cpLocator = *reinterpret_cast<const uint8_t* const*>(cpVtable - sizeof(void*));

// signature, offset, cdOffset, type descriptor rva, class descriptor rva, self rva.
constexpr size_t kLocatorSize = 0x18;

if (!IsReadable(cpLocator, kLocatorSize))
return nullptr;

const auto cSignature = *reinterpret_cast<const uint32_t*>(cpLocator);
if (cSignature != 1)
return nullptr;

const auto cTypeRva = *reinterpret_cast<const uint32_t*>(cpLocator + 0x0C);
const auto cSelfRva = *reinterpret_cast<const uint32_t*>(cpLocator + 0x14);

const auto cBase = reinterpret_cast<uintptr_t>(cpLocator) - cSelfRva;

if (!IsReadable(reinterpret_cast<const void*>(cBase), 2) || *reinterpret_cast<const uint16_t*>(cBase) != 0x5A4D)
return nullptr;

// vftable pointer, spare, then the decorated name.
constexpr size_t kNameOffset = 0x10;

const auto* cpDescriptor = reinterpret_cast<const char*>(cBase + cTypeRva);

if (!IsReadable(cpDescriptor, kNameOffset + 1))
return nullptr;

return cpDescriptor + kNameOffset;
}

// A decorated name reads ".?AVbhkCharacterController@@", so the wanted text is looked for inside it rather
// than matched whole. Bounded and checked a byte at a time, because it is still unverified memory.
bool RttiNameContains(const char* acpName, const char* acpWanted) noexcept
{
constexpr size_t kMaxName = 128;

for (size_t start = 0; start < kMaxName; ++start)
{
if (!IsReadable(acpName + start, 1) || !acpName[start])
return false;

size_t i = 0;

while (acpWanted[i] && IsReadable(acpName + start + i, 1) && acpName[start + i] == acpWanted[i])
++i;

if (!acpWanted[i])
return true;
}

return false;
}

/**
* @brief Finds where a remote actor keeps its character controller, once, and says so.
*
* Step one of taking remote bodies out of collision. `MiddleProcess` does not model the controller on this
* build and the SE offset cannot be assumed, so this walks the process for a pointer whose RTTI names one.
* The offset it prints is meant to be read out of a log once and then written down as a constant, the same
* way `kNodeTranslateOffset` was arrived at.
*/
void SetBodyCollision(Actor* apActor, const bool aEnabled) noexcept
{
/**
* Every remote body, every time it is set up.
*
* This began as a one shot probe and the guard came with it, which on 2026-08-23 meant the filter was
* cleared on the first NPC to arrive and on nothing else, the remote player's own body included, while
* the log showed a successful write and the room went on drifting. A fix that runs once is not a fix.
*
* Cheap enough to repeat: a handful of guarded reads, and it returns immediately once the layer is
* already what it should be, so a 3D rebuild re-applying it costs nothing and says nothing.
*/
if (!apActor)
return;

if (!apActor->currentProcess || !apActor->currentProcess->middleProcess)
{
spdlog::warn("Character controller probe: actor {:X} has no middle process, so there is nothing to search", apActor->formID);
return;
}

/**
* Measured on SkyrimVR 1.4.15 on 2026-08-23 by the scan this replaced: the process holds a
* `bhkCharRigidBodyController` here, with a `bhkRagdollPenetrationUtil` alongside it at +0x258. The
* concrete class is not `bhkCharacterController`, which is the abstract base and never appears by name.
*
* Verified rather than trusted. If a future build moves it, the name will not match and the scan below
* says what is there instead, which is how this offset was found in the first place.
*/
constexpr size_t kCharControllerOffset = 0x250;

const auto* cpBase = reinterpret_cast<const uint8_t*>(apActor->currentProcess->middleProcess);

const void* pController = IsReadable(cpBase + kCharControllerOffset, sizeof(void*)) ? *reinterpret_cast<const void* const*>(cpBase + kCharControllerOffset) : nullptr;

const char* pControllerName = RttiName(pController);

if (!pControllerName || !RttiNameContains(pControllerName, "CharRigidBodyController"))
{
spdlog::warn("Character controller probe: MiddleProcess+{:#X} of actor {:X} is not a controller. Everything named in the first 0x400 bytes follows", kCharControllerOffset, apActor->formID);

for (size_t offset = 0; offset + sizeof(void*) <= 0x400; offset += sizeof(void*))
{
if (!IsReadable(cpBase + offset, sizeof(void*)))
continue;

if (const char* pName = RttiName(*reinterpret_cast<const void* const*>(cpBase + offset)))
spdlog::info(" MiddleProcess+{:#X} is a '{}'", offset, pName);
}

return;
}

const auto* cpController = reinterpret_cast<const uint8_t*>(pController);


/**
* The collision filter, which is where RTTI stops being able to help.
*
* Measured on 2026-08-23: the controller points at a `bhkRigidBody` here, with the havok level
* `ahkpCharacterRigidBody` alongside it at +0x350. Everything past this point is plain integers that no
* type information can locate, so the offsets below are the standard havok layout rather than anything
* this found on its own:
*
* bhkRefObject +0x10 hkReferencedObject* referencedObject, which is the hkpRigidBody
* hkpWorldObject +0x20 hkpLinkedCollidable collidable
* hkpCollidable +0x20 hkpTypedBroadPhaseHandle broadPhaseHandle
* broadPhaseHandle +0x08 uint32 collisionFilterInfo
*
* Which puts the filter at hkpRigidBody+0x48. Assumed layouts are how this investigation went wrong
* repeatedly, so it is checked rather than believed: the low seven bits are the collision layer, and a
* character's should read 30, `L_CHARCONTROLLER`. Any other value means the arithmetic is wrong and
* nothing should be written through it.
*/
constexpr size_t kRigidBodyOffset = 0x360;
constexpr size_t kReferencedObjectOffset = 0x10;
constexpr size_t kCollisionFilterOffset = 0x4C;
constexpr uint32_t kLayerMask = 0x7F;
constexpr uint32_t kCharControllerLayer = 30;

if (!IsReadable(cpController + kRigidBodyOffset, sizeof(void*)))
return;

const auto* cpRigidBody = *reinterpret_cast<const uint8_t* const*>(cpController + kRigidBodyOffset);

const char* pRigidBodyName = RttiName(cpRigidBody);

if (!pRigidBodyName || !RttiNameContains(pRigidBodyName, "bhkRigidBody"))
{
spdlog::warn("Collision filter probe: controller+{:#X} is not a bhkRigidBody, so the layout has moved", kRigidBodyOffset);
return;
}

if (!IsReadable(cpRigidBody + kReferencedObjectOffset, sizeof(void*)))
return;

const auto* cpHavokBody = *reinterpret_cast<const uint8_t* const*>(cpRigidBody + kReferencedObjectOffset);

if (!IsReadable(cpHavokBody + kCollisionFilterOffset, sizeof(uint32_t)))
{
spdlog::warn("Collision filter probe: nothing readable at hkpRigidBody+{:#X}", kCollisionFilterOffset);
return;
}

const uint32_t cFilter = *reinterpret_cast<const uint32_t*>(cpHavokBody + kCollisionFilterOffset);
const uint32_t cLayer = cFilter & kLayerMask;

constexpr uint32_t kNonCollidableLayer = 15;

const uint32_t cFrom = aEnabled ? kNonCollidableLayer : kCharControllerLayer;
const uint32_t cTo = aEnabled ? kCharControllerLayer : kNonCollidableLayer;

// Already in the wanted state, which a 3D rebuild will ask for again. Silent, or every rebuild would log
// a line saying nothing happened.
if (cLayer == cTo)
return;

if (cLayer != cFrom)
{
spdlog::warn("Collision filter: hkpRigidBody+{:#X} of actor {:X} holds {:#010X}, layer {}, and layer {} was expected. Nothing is being written", kCollisionFilterOffset, apActor->formID,
cFilter, cLayer, cFrom);
return;
}

/**
* The change itself: this body stops colliding with anything.
*
* Only the layer is touched. The rest of the word is a collision group and whatever else the filter
* encodes, and none of it is ours to reinterpret, so it is preserved exactly and the seven layer bits are
* replaced. Measured 0x0780001E on 2026-08-23, layer 30, which is how the offset was confirmed at all.
*
* Standing on the floor is not lost by this, because a remote body does not stand: InterpolationSystem
* forces its position every frame from the owner's stream, and always did. What it does lose is pushing
* clutter about, which is the entire point, and being hit by a thrown object, which is the price agreed
* for it.
*
* Whether to clear it at all is the server's `bEnableRemoteBodyCollision`, decided by the callers. Putting
* it back when a body becomes ours is not subject to that switch and never should be, or an actor handed
* over keeps the ghost collision it was given while it was somebody else's.
*/
const uint32_t cWanted = (cFilter & ~kLayerMask) | cTo;

auto* pFilter = const_cast<uint32_t*>(reinterpret_cast<const uint32_t*>(cpHavokBody + kCollisionFilterOffset));

*pFilter = cWanted;

/**
* Read back rather than assumed written. This is havok's own memory and something else may own the word,
* in which case it will not stay and saying so is better than reporting a change that did not happen.
*/
const uint32_t cAfter = *pFilter;

spdlog::info("Collision filter: actor {:X} moved from layer {} to {}, filter {:#010X} -> {:#010X}{}", apActor->formID, cLayer, aEnabled ? "L_CHARCONTROLLER" : "L_NONCOLLIDABLE", cFilter, cAfter,
cAfter == cWanted ? "" : " (it did not hold, so something else owns this word)");
}
} // namespace
#endif

CharacterService::CharacterService(World& aWorld, entt::dispatcher& aDispatcher, TransportService& aTransport) noexcept
: m_world(aWorld)
, m_dispatcher(aDispatcher)
Expand Down Expand Up @@ -460,12 +190,6 @@ bool CharacterService::RequestOwnership(const uint32_t aFormId, const uint32_t a
return false;
}

#if TP_SKYRIMVR
// It is ours again, so it collides again. Without this a body handed over keeps the ghost collision it was
// given while it was somebody else's, for the rest of the session.
SetBodyCollision(pActor, true);
#endif

const auto* pRemoteComponent = m_world.try_get<RemoteComponent>(aEntity);
if (!pRemoteComponent || pRemoteComponent->Id != aServerId || pRemoteComponent->OwnershipEpoch == 0)
return false;
Expand Down Expand Up @@ -772,6 +496,7 @@ void CharacterService::OnDisconnected(const DisconnectedEvent& acDisconnectedEve
}

m_world.clear<WaitingForAssignmentComponent, LocalComponent, RemoteComponent>();

}

void CharacterService::OnAssignCharacter(const AssignCharacterResponse& acMessage) noexcept
Expand Down Expand Up @@ -1819,10 +1544,8 @@ void CharacterService::ProcessNewEntity(entt::entity aEntity) const noexcept
{
spdlog::info("New entity remotely managed, form id: {:X}, server id: {:X}, worn armor pieces: {}", pActor->formID, pRemoteComponent->Id, pActor->GetWornArmor().Entries.size());

#if TP_SKYRIMVR
if (!m_world.GetServerSettings().RemoteBodyCollisionEnabled)
SetBodyCollision(pActor, false);
#endif
// A remote body keeps its own collision layers, so it can be hit and can block the player. What
// stops it shoving the furniture about is the collision table, and that belongs to CollisionService.

// The body has come back after we kept it through a 3D rebuild, so put its equipment back on.
if (const auto* pPending = m_world.try_get<PendingEquipmentComponent>(aEntity))
Expand Down
Loading