Skip to content
Open
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
7 changes: 7 additions & 0 deletions Code/client/Games/Skyrim/Actor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1222,6 +1222,13 @@ void TP_MAKE_THISCALL(HookAddInventoryItem, Actor, TESBoundObject* apItem, Extra

void* TP_MAKE_THISCALL(HookPickUpObject, Actor, TESObjectREFR* apObject, int32_t aCount, bool aUnk1, float aUnk2)
{
#if TP_SKYRIMVR
// The same refusal as the player's path, see PlayerCharacter::HookPickUpObject for why the test is the
// missing 3D alone. An NPC taking one of these off the floor destroys the reference exactly as the player does.
if (apObject && apObject->IsTemporary() && !apObject->GetNiNode())
return nullptr;
#endif

if (!ScopedInventoryOverride::IsOverriden())
{
auto& modSystem = World::Get().GetModSystem();
Expand Down
4 changes: 4 additions & 0 deletions Code/client/Games/Skyrim/Forms/TESForm.h
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ struct TESForm : BaseFormComponent

enum FormFlags
{
// Set by Papyrus Delete. Confirmed twice on 2026-09-30: a reference read 0x82A after a Disable and a
// Delete, and TESObjectREFR::Load3D returns early on exactly this bit and on DISABLED.
DELETED = 1 << 5,
DISABLED = 1 << 0xB,
IGNORE_FRIENDLY_HITS = 1 << 0x14,
};
Expand Down Expand Up @@ -113,6 +116,7 @@ struct TESForm : BaseFormComponent
}

bool IsDisabled() const noexcept { return (flags & DISABLED) != 0; }
bool IsDeleted() const noexcept { return (flags & DELETED) != 0; }
bool IsTemporary() const noexcept { return formID >= 0xFF000000; }
bool IsConsumable() const noexcept { return formType == FormType::Ingredient || formType == FormType::Alchemy; }

Expand Down
40 changes: 38 additions & 2 deletions Code/client/Games/Skyrim/Havok/RagdollAddGuard.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,33 @@ std::string FindBadPart(void* apRagdoll, bool& aBodiesSound) noexcept
return {};
}

/**
* @brief Makes the game's 3D-to-reference lookup, fenced, because this one is allowed to fail.
*
* On 2026-09-30 it took the session down: the guard below correctly refused a ragdoll whose constraint list had
* gone bad, returned safely, and then died building its log line. `findReferenceFor3D` read a form pointer of
* `0x23F800000` out of the first body's 3D and compared its type, at VR `0x1402A5D78`. The same shape of
* pointer as the `0x200000000` constraint from 2026-09-13, so whatever corrupts one corrupts the other.
*
* Validating the arguments is not enough and cannot be. The walk happens inside the game, over the parent chain
* of a dying actor's 3D, and the pointer that faulted was several hops in. So the call is fenced rather than
* checked. A guard that refuses a dangerous operation and then crashes while saying so is worse than no guard,
* because it converts a survivable event into the exact outcome it exists to prevent.
*
* Its own function with no unwinding to do, which is what __try requires.
*/
TESObjectREFR* FindReferenceFor3DSafely(TFindReferenceFor3D* const apFind, NiAVObject* const apObject) noexcept
{
__try
{
return apFind(apObject);
}
__except (EXCEPTION_EXECUTE_HANDLER)
{
return nullptr;
}
}

// The reference whose 3D the ragdoll's first body belongs to, or null. Only called once that body has passed.
TESObjectREFR* ResolveReference(void* apRagdoll) noexcept
{
Expand All @@ -178,12 +205,21 @@ TESObjectREFR* ResolveReference(void* apRagdoll) noexcept
void* pCollisionObject = nullptr;
s_getProperty.Get()(&pCollisionObject, pBody, kCollisionObjectProperty);

if (!pCollisionObject)
// Checked the same way the bodies and constraints above are, since the property fetch hands back whatever
// the entity is carrying and this one belongs to something that is already coming apart.
if (!IsObject(pCollisionObject, kSceneObjectOffset + sizeof(void*)))
return nullptr;

auto* const pSceneObject = static_cast<NiAVObject*>(PointerAt(pCollisionObject, kSceneObjectOffset));

return pSceneObject ? s_findReferenceFor3D.Get()(pSceneObject) : nullptr;
if (!IsObject(pSceneObject, sizeof(void*)))
return nullptr;

TESObjectREFR* const pReference = FindReferenceFor3DSafely(s_findReferenceFor3D.Get(), pSceneObject);

// Whatever comes back is read by the caller as an Actor, so it gets the same treatment as everything else
// reached from a bad ragdoll.
return IsObject(pReference, sizeof(TESObjectREFR)) ? pReference : nullptr;
}

int HookAddToWorld(void* apRagdoll, void* apWorld, bool aUpdateFilter)
Expand Down
44 changes: 44 additions & 0 deletions Code/client/Games/Skyrim/PlayerCharacter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,50 @@ void PlayerCharacter::RemoveWaypoint() noexcept

char TP_MAKE_THISCALL(HookPickUpObject, PlayerCharacter, TESObjectREFR* apObject, int32_t aCount, bool aUnk1, bool aUnk2)
{
#if TP_SKYRIMVR
/**
* @brief Refuses to pick up a temporary that has lost its 3D, because that pickup kills the session.
*
* Five crashes traced end to end, on 2026-09-20 twice, 2026-09-29 twice, 2026-09-30 once, all the same:
* a dropped weapon loses its 3D, the player picks it up, and ten to fifteen seconds later
* `CrossHairPickData::Pick` walks the freed reference and the game executes a garbage pointer. The pickup
* is what frees it.
*
* The reason one of these can be picked up at all is that it keeps its Havok body after losing its 3D,
* which `ShadowSceneAttachGuard` reports as "in the physics world with no 3D". HIGGS grabs bodies rather
* than visuals, so the player grabs a sword that is not drawn on screen.
*
* **The test is the missing 3D and nothing else.** Requiring loadedState as well was tried on 2026-09-30,
* on the grounds that a reference merely outside the 3D radius is ordinary and should not be refused. It
* is ordinary, but the narrowing let the real case through within the hour: `FF000DE8`, base 236A5, was
* adopted with its 3D at 13:52:56.492, had lost it 400 ms later, was picked up unrefused at 13:53:13 and
* killed the process at 13:53:28 on the perk entry point stack. A reference that loses its 3D loses its
* loaded state with it, so the extra condition excluded exactly what it was meant to catch.
*
* Nothing visible is refused, which is why the broad test costs nothing. The object has no 3D, so it is
* not on screen and the player cannot tell it from empty air.
*
* This does not fix what unloads the 3D. It closes the one door that every crosshair crash has gone
* through, and it does so without destroying anything.
*/
if (apObject && apObject->IsTemporary() && !apObject->GetNiNode())
{
static std::mutex s_reportedLock;
static Set<uint32_t> s_reported;

std::scoped_lock _{s_reportedLock};

if (s_reported.size() >= 256)
s_reported.clear();

if (s_reported.insert(apObject->formID).second)
spdlog::warn("Refused to pick up temporary {:X} (base {:X}) because it has no 3D. Picking one of these up is what every crosshair crash has followed.",
apObject->formID, apObject->baseForm ? apObject->baseForm->formID : 0);

return 0;
}
#endif

auto& modSystem = World::Get().GetModSystem();

Inventory::Entry item{};
Expand Down
119 changes: 117 additions & 2 deletions Code/client/Games/Skyrim/TESObjectREFR.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -384,13 +384,13 @@ void TESObjectREFR::Delete() const noexcept
s_pDelete(this);
}

void TESObjectREFR::Disable() const noexcept
void TESObjectREFR::Disable(bool aFadeOut) const noexcept
{
using ObjectReference = TESObjectREFR;

PAPYRUS_FUNCTION(void, ObjectReference, Disable, bool);

s_pDisable(this, true);
s_pDisable(this, aFadeOut);
}

void TESObjectREFR::Enable() const noexcept
Expand Down Expand Up @@ -427,6 +427,36 @@ void TESObjectREFR::PayGoldToContainer(TESObjectREFR* pContainer, int32_t aAmoun
TiltedPhoques::ThisCall(s_payGoldToContainer, this, pContainer, aAmount);
}

/**
* @brief Builds the reference's 3D. Verified, and **not** a repair for a dropped reference that lost its 3D.
*
* `aBackgroundLoading` is the game's own flag; false means the 3D is there when this returns. VR
* `0x1402A12A0`, AE 19727, reached through a contiguous run in se_ae.csv and confirmed by disassembly: it
* takes (this, bool), reads TESForm::flags and returns early on DISABLED or DELETED. See overrides.csv.
*
* **Do not use it to repair an orphaned drop.** Tried on 2026-09-30 and it is worse than the fault. The 3D
* itself comes back reliably, four times out of four, within fifteen milliseconds: `FF000DAD`, `FF000DD5`,
* `FF000DE1`, `FF000DE2` all reported "has its 3D after the repair". Both clients then died within seconds of
* touching one. The player's copy went through the crosshair path on `BSFadeNode "Weapon (000236A5)"`, ten
* seconds after that base form was repaired. The other player picked up `FF000DE2` seventeen seconds after
* its repair and died two seconds later inside Havok, on `hkpRigidBody "Weapon (0002C672)"`.
*
* The reason is that the 3D was never the whole fault. One of these references is also sitting in the physics
* world with an orphaned rigid body, which is exactly what ShadowSceneAttachGuard reports about it, and
* building fresh 3D over that stale body is fatal. Worse, it makes the reference look healthy: GetNiNode then
* returns a node, so the pickup guard and the delete decline both stop covering it. The repair disarmed the
* two things that were holding the line.
*
* If this is tried again, the stale body has to go first: `TESObjectREFR::Release3DRelatedData`, SSE 19301,
* which se_ae.csv already maps to AE 19728, and then this. Release and rebuild, never rebuild alone.
*/
void TESObjectREFR::Load3D(bool aBackgroundLoading) noexcept
{
TP_THIS_FUNCTION(TLoad3D, void, TESObjectREFR, bool);
POINTER_SKYRIMSE(TLoad3D, s_load3D, 19727);
TiltedPhoques::ThisCall(s_load3D, this, aBackgroundLoading);
}

Lock* TESObjectREFR::GetLock() const noexcept
{
TP_THIS_FUNCTION(TGetLock, Lock*, const TESObjectREFR);
Expand Down Expand Up @@ -1140,6 +1170,91 @@ TP_MAKE_THISCALL(HookRemoveInventoryItem, TESObjectREFR, BSPointerHandle<TESObje
// lying on the floor for ever.
BSPointerHandle<TESObjectREFR>* pReturn = TiltedPhoques::ThisCall(RealRemoveInventoryItem, apThis, apResult, apItem, aCount, aReason, apExtraList, apMoveToRef, apDropLoc, apRotate);

/**
* @brief Scaffolding: is the reference already without 3D the instant the game hands it back?
*
* The same probe was put in Actor::DropObject on 2026-09-29 and produced nothing at all, which is how it
* became clear that a corpse's death drop never goes through that function and has always come through
* here instead. This is therefore the first point at which the object exists and nothing of ours has
* touched it.
*
* The cell is scanned rather than the returned handle resolved. Resolving it here is what left the
* dropper's own copy hanging in the air and never falling, so this reads fields and nothing else.
*/
/**
* @brief This probe may not be a probe.
*
* It walks the whole cell reference array right after the game's drop returns, on the game thread, and
* player 2's failure rate went from seven drops in nine to zero in nine, three sessions running, on the
* exact build where it started firing. It reads nothing it writes, but it inserts real work at the one
* moment that matters, which is what a race would need to be won.
*
* So it is switchable. ST_VR_NO_DROP_PROBE=1 removes it, and if the failures come back the bug is a race
* and the fix is a deliberate deferral rather than an accidental one.
*/
static const bool s_probeDisabled = []
{
char buffer[8]{};
size_t length = 0;

return getenv_s(&length, buffer, sizeof(buffer), "ST_VR_NO_DROP_PROBE") == 0 && length > 1 && buffer[0] == '1';
}();

if (!s_probeDisabled && cIsDrop && cReport && apThis->parentCell && apThis->parentCell->refData.refArray)
{
TESObjectCELL* pCell = apThis->parentCell;

uint32_t found = 0;
uint32_t without3D = 0;
uint32_t nearest = 0;
float nearestDistance = 1e9f;

for (uint32_t i = 0; i < pCell->refData.capacity; ++i)
{
TESObjectREFR* pRef = pCell->refData.refArray[i].Get();

if (!pRef || pRef->formID < 0xFF000000)
continue;

if (!pRef->baseForm || pRef->baseForm->formID != apItem->formID)
continue;

++found;

if (!pRef->GetNiNode())
++without3D;

const float cDx = pRef->position.x - apThis->position.x;
const float cDy = pRef->position.y - apThis->position.y;
const float cDz = pRef->position.z - apThis->position.z;
const float cDistance = std::sqrt(cDx * cDx + cDy * cDy + cDz * cDz);

if (cDistance < nearestDistance)
{
nearestDistance = cDistance;
nearest = pRef->formID;
}
}

// The distance from the player as well as from the corpse. Skyrim only builds 3D for references
// inside a radius of the player, so a weapon dropped by a draugr the other client is fighting three
// rooms away would read exactly like the broken ones, and would not be broken at all. Offline you
// are always standing next to your own kills, which is the one thing that run never tested.
float playerDistance = -1.f;

if (PlayerCharacter* pPlayer = PlayerCharacter::Get())
{
const float cPx = apThis->position.x - pPlayer->position.x;
const float cPy = apThis->position.y - pPlayer->position.y;
const float cPz = apThis->position.z - pPlayer->position.z;

playerDistance = std::sqrt(cPx * cPx + cPy * cPy + cPz * cPz);
}

spdlog::warn("Drop probe: {:X} dropped {:X} at {:.0f} units from the player, and the cell now holds {} temporaries with that base, {} with no 3D. Nearest {:X} at {:.1f} units from the body.",
apThis->formID, apItem->formID, playerDistance, found, without3D, nearest, found ? nearestDistance : 0.f);
}

if (cReport)
{
InventoryChangeEvent event(apThis->formID, std::move(item), cIsDrop);
Expand Down
6 changes: 5 additions & 1 deletion Code/client/Games/Skyrim/TESObjectREFR.h
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,11 @@ struct TESObjectREFR : TESForm
void RemoveAllItems() noexcept;
Vector<uint32_t> RemoveNonQuestItems(Inventory& aCurrentInventory) noexcept;
void Delete() const noexcept;
void Disable() const noexcept;
// aFadeOut is Papyrus ObjectReference.Disable's abFadeOut. Pass false to have the 3D and the collision
// go the moment this returns; the fade is animated and leaves both in place while it plays.
// Builds the reference's 3D. See the definition: false does it now rather than queueing it.
void Load3D(bool aBackgroundLoading) noexcept;
void Disable(bool aFadeOut) const noexcept;
void Enable() const noexcept;
void MoveTo(TESObjectCELL* apCell, const NiPoint3& acPosition) const noexcept;
void PayGold(int32_t aAmount) noexcept;
Expand Down
35 changes: 30 additions & 5 deletions Code/client/Services/Generic/ActorValueService.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -601,13 +601,38 @@ void ActorValueService::OnDeathStateChange(const NotifyDeathStateChange& acMessa

if (pActor->IsDead() != acMessage.IsDead)
{
// The other end of the line in RunDeathStateUpdates. Between the two, a body that is dead on one screen
// and alive on another can be traced to whichever client stopped agreeing, which was guesswork before.
spdlog::info("Owner says actor {:X} is {}, applying it here (server id {:X})", pActor->formID, acMessage.IsDead ? "dead" : "alive again", acMessage.Id);
/**
* @brief A bleedout is not a resurrection, so a corpse is left where it is.
*
* RunDeathStateUpdates sends on either field changing, so an actor going into bleedout sends
* `IsDead false, IsBleedingOut true`. That is a report of it going down, not of it getting up, and
* this branch used to read the `false` alone and stand a corpse back up.
*
* It only fires when our copy died first, which is why it looks like nothing for most of a session
* and then ruins one actor. Measured on 2026-09-30 for draugr 8009E: the owner sent "alive and
* down" at 13:13:15.014 and "dead" 250 ms later, our copy was already dead, and the Respawn in
* between reset the reference. That cost the weapon we had just synced into it, left the body
* holding the local leveled list roll instead, and put the actor back into its default idle rather
* than the one it was lying in. Three reported symptoms, one call.
*
* Nothing is lost by declining. The owner's own death follows within a couple of hundred
* milliseconds and our copy is already in the state it describes, and if the actor really does get
* back up, that arrives as its own message with IsBleedingOut clear.
*/
if (!acMessage.IsDead && acMessage.IsBleedingOut)
{
spdlog::info("Owner says actor {:X} is down rather than dead and our copy has already died, so it is left alone (server id {:X})", pActor->formID, acMessage.Id);
}
else
{
// The other end of the line in RunDeathStateUpdates. Between the two, a body that is dead on one screen
// and alive on another can be traced to whichever client stopped agreeing, which was guesswork before.
spdlog::info("Owner says actor {:X} is {}, applying it here (server id {:X})", pActor->formID, acMessage.IsDead ? "dead" : "alive again", acMessage.Id);

acMessage.IsDead ? pActor->Kill() : pActor->Respawn();
acMessage.IsDead ? pActor->Kill() : pActor->Respawn();

return;
return;
}
}

/**
Expand Down
Loading
Loading