diff --git a/Code/client/Games/Skyrim/Actor.cpp b/Code/client/Games/Skyrim/Actor.cpp index 3c9989711..323078fee 100644 --- a/Code/client/Games/Skyrim/Actor.cpp +++ b/Code/client/Games/Skyrim/Actor.cpp @@ -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(); diff --git a/Code/client/Games/Skyrim/Forms/TESForm.h b/Code/client/Games/Skyrim/Forms/TESForm.h index 856367b4b..f25948ff5 100644 --- a/Code/client/Games/Skyrim/Forms/TESForm.h +++ b/Code/client/Games/Skyrim/Forms/TESForm.h @@ -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, }; @@ -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; } diff --git a/Code/client/Games/Skyrim/Havok/RagdollAddGuard.cpp b/Code/client/Games/Skyrim/Havok/RagdollAddGuard.cpp index 1ba011eda..277774937 100644 --- a/Code/client/Games/Skyrim/Havok/RagdollAddGuard.cpp +++ b/Code/client/Games/Skyrim/Havok/RagdollAddGuard.cpp @@ -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 { @@ -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(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) diff --git a/Code/client/Games/Skyrim/PlayerCharacter.cpp b/Code/client/Games/Skyrim/PlayerCharacter.cpp index 17254313e..6636d4b90 100644 --- a/Code/client/Games/Skyrim/PlayerCharacter.cpp +++ b/Code/client/Games/Skyrim/PlayerCharacter.cpp @@ -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 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{}; diff --git a/Code/client/Games/Skyrim/TESObjectREFR.cpp b/Code/client/Games/Skyrim/TESObjectREFR.cpp index 0e0e4cf02..210a2ff8a 100644 --- a/Code/client/Games/Skyrim/TESObjectREFR.cpp +++ b/Code/client/Games/Skyrim/TESObjectREFR.cpp @@ -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 @@ -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); @@ -1140,6 +1170,91 @@ TP_MAKE_THISCALL(HookRemoveInventoryItem, TESObjectREFR, BSPointerHandle* 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); diff --git a/Code/client/Games/Skyrim/TESObjectREFR.h b/Code/client/Games/Skyrim/TESObjectREFR.h index 2521c7d6e..a7c7ffaca 100644 --- a/Code/client/Games/Skyrim/TESObjectREFR.h +++ b/Code/client/Games/Skyrim/TESObjectREFR.h @@ -202,7 +202,11 @@ struct TESObjectREFR : TESForm void RemoveAllItems() noexcept; Vector 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; diff --git a/Code/client/Services/Generic/ActorValueService.cpp b/Code/client/Services/Generic/ActorValueService.cpp index 83d478e7d..990397718 100644 --- a/Code/client/Services/Generic/ActorValueService.cpp +++ b/Code/client/Services/Generic/ActorValueService.cpp @@ -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; + } } /** diff --git a/Code/client/Services/Generic/InventoryService.cpp b/Code/client/Services/Generic/InventoryService.cpp index eecd75ce5..7a4f1d53a 100644 --- a/Code/client/Services/Generic/InventoryService.cpp +++ b/Code/client/Services/Generic/InventoryService.cpp @@ -1,4 +1,7 @@ #include +#if TP_SKYRIMVR +#include +#endif #include #include @@ -28,6 +31,83 @@ #include #include +#if TP_SKYRIMVR +namespace +{ +struct DropCandidate +{ + uint32_t FormId{}; + float Distance{}; + bool Has3D{}; + + // Already paired with another drop, so adopting it would leave two drop ids on one reference. See the + // adoption in OnNotifyInventoryChanges. + bool Taken{}; +}; + +/** + * @brief The temporaries in a cell carrying one base form, nearest to the dropper first. + * + * Death loot lands on the body, so the distance from the dropping actor is what separates the item this + * message is about from another of the same kind lying elsewhere in the room. That ambiguity is real: on + * 2026-09-28 the temporary FF000DBE was matched by drops from two different draugr six minutes apart, so + * base form alone names the wrong object about as often as the right one. + * + * Only reached when the item is missing from our copy of the dropper's inventory, which happened five + * times in a three minute session, so walking the cell costs nothing worth counting. + */ +Vector FindDropCandidates(TESObjectCELL* apCell, const NiPoint3& acFrom, const uint32_t acBaseFormId) noexcept +{ + Vector candidates; + + if (!apCell || !apCell->refData.refArray) + return candidates; + + for (uint32_t i = 0; i < apCell->refData.capacity; ++i) + { + TESObjectREFR* pRef = apCell->refData.refArray[i].Get(); + + if (!pRef || pRef->formID < 0xFF000000) + continue; + + if (!pRef->baseForm || pRef->baseForm->formID != acBaseFormId) + continue; + + const float cDx = pRef->position.x - acFrom.x; + const float cDy = pRef->position.y - acFrom.y; + const float cDz = pRef->position.z - acFrom.z; + + candidates.push_back(DropCandidate{pRef->formID, std::sqrt(cDx * cDx + cDy * cDy + cDz * cDz), pRef->GetNiNode() != nullptr, + World::Get().ctx().at().IsRegisteredDrop(pRef->formID)}); + } + + std::sort(candidates.begin(), candidates.end(), [](const DropCandidate& acLhs, const DropCandidate& acRhs) { return acLhs.Distance < acRhs.Distance; }); + + return candidates; +} + +// Writes the candidate list into one line, so a decision can be read back without cross referencing. +String DescribeCandidates(const Vector& acCandidates) noexcept +{ + if (acCandidates.empty()) + return "none"; + + String out; + + for (const DropCandidate& cCandidate : acCandidates) + { + if (!out.empty()) + out += ", "; + + out += fmt::format("{:X} at {:.1f} (3D {}{})", cCandidate.FormId, cCandidate.Distance, cCandidate.Has3D ? "yes" : "NO", + cCandidate.Taken ? ", taken" : ""); + } + + return out; +} +} // namespace +#endif + InventoryService::InventoryService(World& aWorld, entt::dispatcher& aDispatcher, TransportService& aTransport) noexcept : m_world(aWorld) , m_dispatcher(aDispatcher) @@ -43,13 +123,66 @@ InventoryService::InventoryService(World& aWorld, entt::dispatcher& aDispatcher, void InventoryService::OnUpdate(const UpdateEvent& acUpdateEvent) noexcept { RunWeaponStateUpdates(); + +#if TP_SKYRIMVR + RunFailedDropScans(acUpdateEvent.Delta); +#endif } +#if TP_SKYRIMVR +/** + * @brief Reads back the references a remote drop could have damaged, a beat after the drop. + * + * This is the evidence that the fix holds. Before it, one frame after `DropOrPickUpObject` these lines read + * `3D NO` and the reference stayed that way until something walked into it. They should now read `3D yes` + * every time, and a `3D NO` here is the crash being manufactured again. + * + * A beat rather than immediately, because the damage showed up 13 to 14 ms after the call and the cell + * needs to have settled. The cell is resolved by id rather than through the dropper, since a corpse can be + * gone by the time this runs and the cell is the part that matters. + */ +void InventoryService::RunFailedDropScans(const double aDelta) noexcept +{ + for (size_t i = m_failedDropScans.size(); i > 0; --i) + { + FailedDropScan& scan = m_failedDropScans[i - 1]; + + scan.Remaining -= aDelta; + + if (scan.Remaining > 0.0) + continue; + + // The origin only orders the list, and this check cares about the 3D rather than the distances. + NiPoint3 origin{}; + + TESObjectCELL* pCell = Cast(TESForm::GetById(scan.CellId)); + const Vector cCandidates = FindDropCandidates(pCell, origin, scan.BaseFormId); + + size_t without3D = 0; + for (const DropCandidate& cCandidate : cCandidates) + { + if (!cCandidate.Has3D) + ++without3D; + } + + if (without3D) + spdlog::error("Drop check: {} of {} temporaries with base {:X} in cell {:X} have NO 3D, so an orphan was created after all. Candidates: {}", + without3D, cCandidates.size(), scan.BaseFormId, scan.CellId, DescribeCandidates(cCandidates)); + else + spdlog::warn("Drop check: all {} temporaries with base {:X} in cell {:X} still have their 3D. Candidates: {}", + cCandidates.size(), scan.BaseFormId, scan.CellId, DescribeCandidates(cCandidates)); + + m_failedDropScans.erase(m_failedDropScans.begin() + (i - 1)); + } +} +#endif + void InventoryService::OnInventoryChangeEvent(const InventoryChangeEvent& acEvent) noexcept { if (!m_transport.IsConnected()) return; + auto view = m_world.view(); const auto iter = std::find_if(std::begin(view), std::end(view), [view, formId = acEvent.FormId](auto entity) { return view.get(entity).Id == formId; }); @@ -212,6 +345,13 @@ void InventoryService::OnNotifyInventoryChanges(const NotifyInventoryChanges& ac // handle is the only way to pair our copy of the object with the dropper's, so it gets the block below. if (!acMessage.Drop) { + // Scaffolding. A dropped weapon keeps its 3D offline and loses it online 300 to 500 ms after the + // drop, so whatever unloads it is something we do to a remotely owned corpse that never happens + // to a local one. This and the equipment line below are the two calls we make into that actor + // around a death, so the next log says which of them lands inside that window. + spdlog::warn("Remote inventory change on actor {:X}: item {:X}, count {}", + pActor->formID, World::Get().GetModSystem().GetGameId(acMessage.Item.BaseId), acMessage.Item.Count); + ScopedInventoryOverride _; pActor->AddOrRemoveItem(acMessage.Item); @@ -220,6 +360,109 @@ void InventoryService::OnNotifyInventoryChanges(const NotifyInventoryChanges& ac uint32_t droppedHandle = 0; + // The local game id, because the ids the log lines below carry are the server's and the two only + // coincide for vanilla forms. + const uint32_t cGameBaseId = World::Get().GetModSystem().GetGameId(acMessage.Item.BaseId); + const uint32_t cDropCellId = pActor->parentCell ? pActor->parentCell->formID : 0; + + /** + * @brief An item the dropper no longer carries here is already in the world, so adopt it. + * + * Measured on 2026-09-28, eight remote drops in one session: whether the item is still in our copy + * of the actor's inventory predicted the outcome every single time. Present, and the drop works and + * registers. Absent, and `DropOrPickUpObject` returns no handle, five times out of five, because + * there is nothing left for it to drop. + * + * The call is not merely useless there. Twice it was followed within 14 ms, one frame, by the + * temporary that was already sitting in the cell losing its 3D and never getting it back. That is + * the state the Havok island listener and `CrossHairPickData::Pick` both walk into, and it is what + * killed the 2026-09-20 and 2026-09-28 sessions. A reference in it is a rigid body waiting to + * outlive its owner. + * + * So the drop is not replayed. The reference the game already made is registered under the drop id + * instead, which is also the first time this path pairs one at all: every one of those five logged + * `can never be paired`, meaning nobody could sync the item once somebody picked it up. + * + * Adoption is deliberately timid. Base form alone names the wrong object regularly, FF000DBE having + * been matched by two different draugr six minutes apart, and adopting wrongly is worse than not + * adopting, because `ObjectService::OnObjectRemoveNotify` deletes whatever was registered. So it + * takes the nearest candidate only when it is the only one near the body, and otherwise does + * nothing but say why. Either way the harmful call is skipped. + */ + if (!pActor->IsItemInInventory(cGameBaseId)) + { + // Death loot lands on the body. Generous enough for a weapon that slid, tight enough to exclude + // the rest of the room; every distance is logged so this can be set from data rather than taste. + constexpr float kAdoptRadius = 250.f; + + const Vector cCandidates = FindDropCandidates(pActor->parentCell, pActor->position, cGameBaseId); + + /** + * @brief Only a reference no other drop already owns can be adopted. + * + * Two draugr who die a second apart in the same doorway put their weapons within a couple of + * feet of each other, and the second one's message then finds the first one's weapon sitting + * well inside the radius. On 2026-09-30 that is exactly what happened: FF000DBC was registered + * as dropId 3900000003 for actor 380BB at 12:46:04, and 1.4 s later the drop from 380B7 + * adopted the same reference as dropId 4E00000004. One weapon, two drop ids, and 380B7's own + * weapon never existed on this machine at all, which is what the player saw. + * + * The first two draugr after a save reproduce it every session, because they are close + * together and die close together. + */ + const DropCandidate* pChosen = nullptr; + size_t nearby = 0; + + for (const DropCandidate& cCandidate : cCandidates) + { + if (cCandidate.Distance > kAdoptRadius || cCandidate.Taken || !cCandidate.Has3D) + continue; + + ++nearby; + + if (!pChosen) + pChosen = &cCandidate; + } + + if (nearby == 1 && acMessage.DropId) + { + m_dispatcher.trigger(DynamicObjectCreatedEvent(acMessage.DropId, pChosen->FormId)); + + spdlog::warn("Remote drop of {:X} from actor {:X} was already in the world: adopted {:X} as dropId {:X}, {:.1f} units from the body. The DropOrPickUpObject call that used to strip its 3D was skipped. Candidates: {}", + cGameBaseId, pActor->formID, pChosen->FormId, acMessage.DropId, pChosen->Distance, DescribeCandidates(cCandidates)); + } + else + { + spdlog::warn("Remote drop of {:X} from actor {:X} is not in our copy of its inventory and {} free candidate(s) sit within {:.0f} units of the body, so nothing was adopted. The DropOrPickUpObject call was skipped anyway, since it can only fail here. Candidates: {}", + cGameBaseId, pActor->formID, nearby, kAdoptRadius, DescribeCandidates(cCandidates)); + + // Diagnostic, reads against the two lines SetActorInventory writes at spawn. If this matches + // what the actor held before the sync rather than what the sync put in, something reset the + // actor after we set it; if it matches the sync minus this one item, the add never took. + { + auto& modSystem = World::Get().GetModSystem(); + + String held; + + for (const auto& cEntry : pActor->GetActorInventory().Entries) + { + if (!held.empty()) + held += ", "; + + held += fmt::format("{:X} x{}", modSystem.GetGameId(cEntry.BaseId), cEntry.Count); + } + + spdlog::warn("Actor {:X} is holding: {}", pActor->formID, held.empty() ? String("none") : held); + } + } + + // Whatever was decided, prove the reference kept its 3D. This is the line that says the crash + // did not happen: it used to read NO one frame after the call. + m_failedDropScans.push_back(FailedDropScan{cDropCellId, cGameBaseId, 1.5}); + + return; + } + { ScopedInventoryOverride _; @@ -240,7 +483,7 @@ void InventoryService::OnNotifyInventoryChanges(const NotifyInventoryChanges& ac const uint32_t cBaseId = acMessage.Item.BaseId.BaseId; const int32_t cCount = acMessage.Item.Count; - m_world.GetRunner().Queue([this, droppedHandle, cDropId, cActorId, cBaseId, cCount]() { + m_world.GetRunner().Queue([this, droppedHandle, cDropId, cActorId, cBaseId, cCount, cGameBaseId, cDropCellId]() { TESObjectREFR* pDropped = droppedHandle ? TESObjectREFR::GetByHandle(droppedHandle) : nullptr; if (pDropped && cDropId) @@ -267,7 +510,13 @@ void InventoryService::OnNotifyInventoryChanges(const NotifyInventoryChanges& ac * what the game crashed on twice at SkyrimVR.exe+03AD7B1. */ if (!droppedHandle) - spdlog::error("Dropped remote item {:X} (count {}) from actor {:X} produced NO REFERENCE: DropOrPickUpObject returned no handle, so this client has no copy of the item and dropId {:X} can never be paired.", cBaseId, cCount, cActorId, cDropId); + { + // Now unexpected rather than routine: the inventory check above skips the call in the one + // case that produced this, so reaching it means a drop failed for some other reason. + spdlog::error("Dropped remote item {:X} (count {}) from actor {:X} produced NO REFERENCE even though the item was in our copy of its inventory, so dropId {:X} can never be paired.", cBaseId, cCount, cActorId, cDropId); + + m_failedDropScans.push_back(FailedDropScan{cDropCellId, cGameBaseId, 1.5}); + } else spdlog::error("Dropped remote item {:X} (count {}) from actor {:X} produced handle {:X}, which no longer resolves one update later, so dropId {:X} can never be paired.", cBaseId, cCount, cActorId, droppedHandle, cDropId); }); @@ -320,6 +569,12 @@ void InventoryService::OnNotifyEquipmentChanges(const NotifyEquipmentChanges& ac return; } + // Scaffolding, see the remote inventory line in OnNotifyInventoryChanges. A death sends an equipment + // change for the weapon immediately before the drop, so this is the other candidate for what unloads a + // just dropped weapon on the receiving client. + spdlog::warn("Remote equipment change on actor {:X}: item {:X}, {}, spell {}, shout {}", + pActor->formID, itemId, acMessage.Unequip ? "unequip" : "equip", acMessage.IsSpell, acMessage.IsShout); + uint32_t equipSlotId = modSystem.GetGameId(acMessage.EquipSlotId); TESForm* pEquipSlot = TESForm::GetById(equipSlotId); @@ -351,7 +606,30 @@ void InventoryService::OnNotifyEquipmentChanges(const NotifyEquipmentChanges& ac // TODO: ExtraData necessary? probably if (acMessage.Unequip) { + /** + * @brief Scaffolding: whether unequipping on a corpse is what puts the weapon into the world. + * + * On 2026-09-29 three references our own DropOrPickUpObject created kept their 3D and the one the + * game had already made, which we merely adopted, lost it and killed the session. So the producer is + * whatever creates that one, and this is the closest candidate: for actor 380BB the unequip of + * 2C66F and the drop that found the object already present landed in the same millisecond, while for + * 380B8 the unequip ran 12 ms before a drop that created the object normally. + * + * Scanned either side of the call, the same way the drop path was, so the next log says outright + * whether the reference appears across it. + */ + const Vector cBefore = FindDropCandidates(pActor->parentCell, pActor->position, itemId); + pEquipManager->UnEquip(pActor, pItem, nullptr, acMessage.Count, pEquipSlot, false, true, false, false, nullptr); + + const Vector cAfter = FindDropCandidates(pActor->parentCell, pActor->position, itemId); + + if (cBefore.size() != cAfter.size()) + spdlog::warn("Unequip of {:X} on actor {:X} changed the world: {} temporaries with that base before, {} after. Before: {}. After: {}", + itemId, pActor->formID, cBefore.size(), cAfter.size(), DescribeCandidates(cBefore), DescribeCandidates(cAfter)); + else + spdlog::warn("Unequip of {:X} on actor {:X} left {} temporaries with that base. {}", + itemId, pActor->formID, cAfter.size(), DescribeCandidates(cAfter)); } else { diff --git a/Code/client/Services/Generic/ObjectService.cpp b/Code/client/Services/Generic/ObjectService.cpp index 44e7bea36..02de3e22a 100644 --- a/Code/client/Services/Generic/ObjectService.cpp +++ b/Code/client/Services/Generic/ObjectService.cpp @@ -36,6 +36,43 @@ #include +#if TP_SKYRIMVR +namespace +{ +/** + * @brief A removal in progress: disabled on one main loop pass, deleted on a later one. + * + * Two separate problems put it here rather than in the notify handler that learns about it. + * + * The thread. Measured on 2026-09-30 from the thread ids in both clients' logs: every line from + * World::Update, which hangs off GameVM::Update, rotates across five or six threads within a single + * session, while the render hook logs on one thread for every line of every session. So a network notify + * runs on whichever Papyrus VM worker services that tick, and destroying a world reference from there + * runs alongside the renderer and Havok rather than between them. SkillsMenu hit the same thing and the + * answer was the same, see RunDeferredSkillsTreeUpdate. + * + * The order. Papyrus Disable does not do the work, it queues it onto a BSTaskPool worker, so a Delete in + * the same call pulls the reference apart before the disable runs. That was measured on 2026-08-16 and it + * crashed on every connect, see the comment on TESObjectREFR::Delete, which says a retry belongs in a + * service where the Delete can wait. This is that retry, and it waits on the reference itself rather than + * on a guessed number of frames. + */ +struct PendingDelete +{ + uint32_t FormId{}; + + // Whether Disable has been asked for yet. The pass that asks does nothing else. + bool Disabled{}; + + // Main loop passes since it was asked for, so a task that never runs does not hold the entry for ever. + uint32_t Passes{}; +}; + +std::mutex s_pendingDeleteLock; +Vector s_pendingDeletes; +} +#endif + ObjectService::ObjectService(World& aWorld, entt::dispatcher& aDispatcher, TransportService& aTransport) : m_world(aWorld) , m_transport(aTransport) @@ -522,8 +559,168 @@ void ObjectService::OnUpdate(const UpdateEvent& acEvent) noexcept RunDriftWatch(acEvent.Delta); RunCellDriftSweep(acEvent.Delta); + RunDropObjectHealthWatch(acEvent.Delta); } +/** + * @brief Says when a drop we registered ourselves has lost its 3D. + * + * RunCellDriftSweep already reports a temporary in that state, but it stops once it has gathered + * kMaxSamples references that do have 3D, so in a busy cell anything past that point in the reference + * array is never examined. On 2026-09-28 it never named FF000E07 at all: the Havok guard did, 3.2 s after + * we had registered it as our own drop, and the session died 45 s later on a rigid body for that same base + * form with no reference left behind it. + * + * This asks the question the other way round. m_dynamicObjects is capped at 256 and holds exactly the + * objects we put into the world, so walking it costs nothing and no amount of dungeon clutter can hide the + * one drop that matters. A registered drop with no 3D is the reference whose rigid body is about to outlive + * it, which is what the crosshair pick then walks into. See ShadowSceneAttachGuard for the other consumer. + */ +void ObjectService::RunDropObjectHealthWatch(const double aDelta) noexcept +{ + /** + * Every frame, not every second. The 3D goes somewhere inside a one second sample, which is far too + * coarse to say what took it: a second of this log holds a death, an equipment change, several inventory + * changes and every teleport we apply to a remote body. At 72 Hz the loss is pinned to about 14 ms, and + * whatever we did in that frame is the line immediately above it. + * + * The list is capped at 256 and each entry costs a form lookup and a virtual call, and it only ever + * reports a given object once, so this stays cheap enough to leave running. + */ + constexpr size_t kMaxReported = 256; + + for (const DynamicObject& cEntry : m_dynamicObjects) + { + TESObjectREFR* pObject = Cast(TESForm::GetById(cEntry.FormId)); + + // Three states are worth a line, and which one comes first is the whole question. A reference that + // stops resolving has been freed by the game, and that is the moment its rigid body is left without + // an owner. One that is alive with no 3D is on its way there. + /** + * @brief Healthy, and possibly healthy again. + * + * A drop is registered in the same frame the game creates it, and measured on 2026-09-29 the + * reference has no 3D at that instant: one to two milliseconds after `Registered dropped object`, + * every time, on the owner's own drops. The game is still building it, which HookDropObject's own + * comment says outright. + * + * So "no 3D" in the first frames means nothing on its own, and this says when it comes back. Without + * it the very first sample decides the verdict for the rest of the session, and both guards then + * refuse to touch a reference that recovered a frame later, which is what leaves looted weapons + * lying on the other player's floor. + */ + if (pObject && pObject->baseForm && pObject->baseForm->formID == cEntry.BaseFormId && pObject->GetNiNode()) + { + if (m_reportedDropWithout3D.erase(cEntry.FormId) != 0) + spdlog::warn("Registered drop {:X} (base {:X}, dropId {:X}) has its 3D after all, so the earlier report was premature and it is a normal object.", + cEntry.FormId, cEntry.BaseFormId, cEntry.DropId); + + m_dropWithout3DFor.erase(cEntry.FormId); + + continue; + } + + /** + * @brief One rich line for a drop that is still without 3D after a grace period. + * + * Recoveries are real: FF000DC5 came back on 2026-09-30 after being reported. So a single sample + * proves nothing, and the sessions that reproduce nothing are worth nothing, which is most of them + * lately. This waits until a drop has genuinely failed and then writes down everything about the + * moment, so one occurrence during ordinary play is worth more than a scripted run that comes back + * clean. + */ + if (pObject) + { + const double cWithoutFor = (m_dropWithout3DFor[cEntry.FormId] += aDelta); + + constexpr double kGrace = 2.0; + + if (cWithoutFor >= kGrace && cWithoutFor - aDelta < kGrace) + { + PlayerCharacter* pPlayer = PlayerCharacter::Get(); + TESObjectCELL* pCell = pObject->parentCell; + + uint32_t inCell = 0; + uint32_t temporaries = 0; + uint32_t temporariesWithout3D = 0; + + if (pCell && pCell->refData.refArray) + { + for (uint32_t i = 0; i < pCell->refData.capacity; ++i) + { + TESObjectREFR* pRef = pCell->refData.refArray[i].Get(); + if (!pRef) + continue; + + ++inCell; + + if (pRef->formID < 0xFF000000) + continue; + + ++temporaries; + + if (!pRef->GetNiNode()) + ++temporariesWithout3D; + } + } + + spdlog::error("CONFIRMED broken drop {:X} (base {:X}, dropId {:X}) after {:.1f}s without 3D. Loaded state {}, flags {:08X}, refFlags {:04X}, cell {:X} holding {} references of which {} temporaries and {} of those without 3D. Player {:.0f} units away, frame {:.1f} ms.", + cEntry.FormId, cEntry.BaseFormId, cEntry.DropId, cWithoutFor, + pObject->loadedState ? "present" : "gone", pObject->flags, pObject->referenceFlags, + pCell ? pCell->formID : 0, inCell, temporaries, temporariesWithout3D, + pPlayer ? glm::length(glm::vec3(pObject->position.x - pPlayer->position.x, pObject->position.y - pPlayer->position.y, pObject->position.z - pPlayer->position.z)) : -1.f, + aDelta * 1000.0); + } + } + + const char* pWhat = nullptr; + + if (!pObject) + pWhat = "no longer resolves, so the game has freed it"; + else if (!pObject->baseForm || pObject->baseForm->formID != cEntry.BaseFormId) + pWhat = "now carries a different base form, so its id has been recycled"; + else + pWhat = "has no 3D"; + + if (m_reportedDropWithout3D.size() >= kMaxReported) + m_reportedDropWithout3D.clear(); + + if (!m_reportedDropWithout3D.insert(cEntry.FormId).second) + continue; + + if (!pObject) + { + spdlog::warn("Registered drop {:X} (base {:X}, dropId {:X}) {}", cEntry.FormId, cEntry.BaseFormId, cEntry.DropId, pWhat); + continue; + } + + spdlog::warn("Registered drop {:X} (base {:X}, dropId {:X}) {}: loaded state {}, disabled {}, flags {:08X}, refFlags {:04X}, cell {:X}, at ({:.1f}, {:.1f}, {:.1f})", + cEntry.FormId, cEntry.BaseFormId, cEntry.DropId, pWhat, pObject->loadedState ? "present" : "gone", + pObject->IsDisabled(), pObject->flags, pObject->referenceFlags, + pObject->parentCell ? pObject->parentCell->formID : 0, + pObject->position.x, pObject->position.y, pObject->position.z); + } +} + +/** + * @brief Why this watch only reports, and does not clean up. + * + * Deleting the orphan here looked like the obvious close: the reference has no 3D, so it is already + * invisible and uninteractable, and removing it should cost the player nothing while taking away the crash. + * It was tried on 2026-09-29 and it is worse than leaving it alone. + * + * `FF000DD4`, a drop the owner had made itself one second earlier, lost its 3D and was deleted here. The + * process died 165 ms later on the same thread, inside Havok: frame 1 at VR `0x1403AD010`, a few hundred + * bytes from the island listener `ShadowSceneAttachGuard` already covers, with the seven frames below it all + * in `hkp`. So the Delete does not remove the orphan, it hands the reference to a teardown that walks + * straight into the same broken state, immediately and reliably, instead of whenever somebody happens to + * look at it. + * + * The precedent that made it look safe does not transfer. `OnObjectRemoveNotify` also calls plain Delete on + * 3D-less references and has never done this, so something about the two situations differs and it is not + * yet known what. Do not retry this without knowing. + */ + void ObjectService::RestoreObjectPhysics(TESObjectREFR* apObject) noexcept { // Puts the rigid body back together with the node after warping, and hands the object to local physics. @@ -794,16 +991,12 @@ void ObjectService::RunCellDriftSweep(const double aDelta) noexcept const float cThreshold = kMovingPerSecond * cElapsed; - for (uint32_t i = 0; i < pCell->refData.capacity && samples.size() < kMaxSamples; ++i) + for (uint32_t i = 0; i < pCell->refData.capacity; ++i) { TESObjectREFR* pRef = pCell->refData.refArray[i].Get(); if (!pRef) continue; - // Actors move under their own power and are never streamed as objects, so they are noise here. - if (Cast(pRef)) - continue; - /** * @brief A temporary sitting in the cell with no 3D, which is the state that crashes the game. * @@ -816,8 +1009,20 @@ void ObjectService::RunCellDriftSweep(const double aDelta) noexcept * Temporaries only. A static reference with no 3D is ordinary: disabled markers and anything past the * 3D load distance read exactly the same and there are hundreds of them. A temporary is created at * runtime, and every investigation of this crash so far has ended on a dropped item. + * + * Deliberately ahead of the actor skip and outside the sample cap below, and the order of the tests + * is what keeps that affordable: the form id is a field compare that rejects almost everything, so + * the virtual call and the cast only run for a temporary that really has no 3D. Under the cap this + * missed the orphan every time on 2026-09-28, in a barrow with more references than kMaxSamples, and + * the Havok guard found it instead, minutes later. */ - if (pRef->formID >= 0xFF000000 && !pRef->GetNiNode() && m_reportedWithout3D.insert(pRef->formID).second) + // Keyed by form id and base form together, not the id alone. Temporary ids are recycled within a + // session: FF000ADF was reported here as a 3BE11 at 21:03 on 2026-09-29 and was a 236A5 by 21:12. + // With the id alone as the key the recycled object could never be reported, however broken it got, + // and the silence read as health. + const uint64_t cReportKey = (static_cast(pRef->formID) << 32) | (pRef->baseForm ? pRef->baseForm->formID : 0); + + if (pRef->formID >= 0xFF000000 && !pRef->GetNiNode() && !Cast(pRef) && m_reportedWithout3D.insert(cReportKey).second) { const bool cHeld = m_heldByHand[0] == pRef->formID || m_heldByHand[1] == pRef->formID; const bool cSettling = std::any_of(m_settling.begin(), m_settling.end(), [pRef](const SettlingObject& acSettling) { return acSettling.FormId == pRef->formID; }); @@ -832,6 +1037,15 @@ void ObjectService::RunCellDriftSweep(const double aDelta) noexcept pRef->position.x, pRef->position.y, pRef->position.z); } + // The drift sampling below stays capped, since its comparison is quadratic in the sample count and + // it runs on the game's own thread. Only the report above walks the whole cell. + if (samples.size() >= kMaxSamples) + continue; + + // Actors move under their own power and are never streamed as objects, so they are noise here. + if (Cast(pRef)) + continue; + // The node rather than the reference, for the same reason the rest of this file reads it: a // temporary's reference position is frozen at creation and would read as perfectly still. glm::vec3 position{}; @@ -1222,6 +1436,20 @@ void ObjectService::OnDynamicObjectCreated(const DynamicObjectCreatedEvent& acEv if (!pObject || !pObject->baseForm) return; + // One reference, one entry. Adoption can name a reference the registry already holds, and two entries + // for the same form id would let GetDropId and GetDynamicFormId disagree about which drop it is. + for (auto it = m_dynamicObjects.begin(); it != m_dynamicObjects.end(); ++it) + { + if (it->FormId != acEvent.FormId) + continue; + + if (it->DropId != acEvent.DropId) + spdlog::warn("Dropped object {:X} was registered as dropId {:X} and is now being registered as {:X}, keeping the newer one", it->FormId, it->DropId, acEvent.DropId); + + m_dynamicObjects.erase(it); + break; + } + if (m_dynamicObjects.size() >= kMaxDynamicObjects) m_dynamicObjects.erase(m_dynamicObjects.begin()); @@ -1232,7 +1460,43 @@ void ObjectService::OnDynamicObjectCreated(const DynamicObjectCreatedEvent& acEv m_dynamicObjects.push_back(entry); - spdlog::info("Registered dropped object {:X} (base {:X}) as dropId {:X}", entry.FormId, entry.BaseFormId, entry.DropId); + // The flags travel with this line so a weapon that syncs can be read against one that sticks. Every + // registered drop that has reached the watch so far has had no 3D one millisecond after this point and + // has never got one, so whatever separates the two is a property of the reference itself, and this is + // the only place both kinds pass through. + // Distance from the player, because Skyrim only builds 3D for references within a radius of them. A + // weapon dropped by a draugr the other client is fighting three rooms away would read as broken here and + // be perfectly ordinary. See the drop probe in TESObjectREFR::RemoveInventoryItem. + float playerDistance = -1.f; + + if (PlayerCharacter* pPlayer = PlayerCharacter::Get()) + { + const glm::vec3 cGap(pObject->position.x - pPlayer->position.x, pObject->position.y - pPlayer->position.y, pObject->position.z - pPlayer->position.z); + + playerDistance = glm::length(cGap); + } + + spdlog::info("Registered dropped object {:X} (base {:X}) as dropId {:X}: 3D {}, loaded state {}, {:.0f} units from the player, disabled {}, flags {:08X}, refFlags {:04X}, cell {:X}", + entry.FormId, entry.BaseFormId, entry.DropId, pObject->GetNiNode() ? "yes" : "NO", pObject->loadedState ? "present" : "gone", + playerDistance, pObject->IsDisabled(), pObject->flags, pObject->referenceFlags, + pObject->parentCell ? pObject->parentCell->formID : 0); +} + +/** + * @brief Whether a reference is already spoken for by a drop. + * + * Const, so unlike GetDropId it prunes nothing; an entry whose reference has been reused still reads as + * taken here, which is the safe way round for a caller deciding whether to adopt. + */ +bool ObjectService::IsRegisteredDrop(const uint32_t acFormId) const noexcept +{ + for (const DynamicObject& cEntry : m_dynamicObjects) + { + if (cEntry.FormId == acFormId) + return true; + } + + return false; } // A stale entry is worse than a missing one, because a recycled form id would sync the wrong object, so @@ -1358,14 +1622,141 @@ void ObjectService::OnObjectRemoveNotify(const NotifyObjectRemove& acMessage) no if (TESObjectREFR* pObject = Cast(TESForm::GetById(cFormId))) { - pObject->Delete(); + /** + * @brief What we are about to delete, read before the Delete because afterwards none of it can be. + * + * Delete is Papyrus ObjectReference.Delete, which only marks the reference and leaves the game to + * free it whenever it next gets round to it, and nothing on this path detaches the Havok body + * first. So this is the one place that can tell the two stories apart: a reference that already + * has no 3D here was broken before we touched it, and one that still has its 3D is one we are + * breaking. On 2026-09-28 the session died on a rigid body for base 2C66F whose reference was + * gone, 28 s after this ran on FF000DFE, which carries that base form. + * + * The Havok body is deliberately not read. NiAVObject is pure padding in our headers, so reaching + * the collision object would mean inventing an offset, and the body hangs off the 3D anyway. + */ + spdlog::warn("Removing dropped object {:X} (base {:X}, dropId {:X}): 3D {}, loaded state {}, cell {:X}, at ({:.1f}, {:.1f}, {:.1f})", + cFormId, pObject->baseForm ? pObject->baseForm->formID : 0, acMessage.DropId, + pObject->GetNiNode() ? "present" : "GONE", pObject->loadedState ? "present" : "gone", + pObject->parentCell ? pObject->parentCell->formID : 0, + pObject->position.x, pObject->position.y, pObject->position.z); + + /** + * @brief The removal is queued rather than performed, because this is not the main loop. + * + * Five crashes in the Havok island family, on 2026-09-28 twice and 2026-09-29 three times, each one + * preceded by a Delete of a dropped reference: 165 ms, 2.3 s, 4.7 s, 6.4 s and 28 s afterwards, all + * with the same stack through `0x1403ACFAA` into hkp. Declining the Delete stopped them and stopped + * loot sync with them, which is a plug rather than a fix and was reported as such. + * + * What was never checked is where the Delete ran. It ran here, on a network notify, and the thread + * ids in both clients' logs on 2026-09-30 settled what that means: see PendingDelete. + * + * The pairing is forgotten below either way, so nothing here keeps pointing at it. + */ + { + std::scoped_lock _{s_pendingDeleteLock}; - spdlog::info("Removed dropped object {:X} (dropId {:X}), it was picked up elsewhere", cFormId, acMessage.DropId); + s_pendingDeletes.push_back(PendingDelete{cFormId}); + } } ForgetDynamicObject(acMessage.DropId); } +/** + * @brief Performs the removals queued above, on the game's main loop. + * + * Called from HookMainLoop in SkyrimVM64.cpp, next to RunDeferredSkillsTreeUpdate, which is there for the + * same reason. + * + * One pass asks for the disable and does nothing else. Later passes wait for it to have happened, which is + * readable on the reference: the task clears the 3D and sets the disabled flag. Only then is the reference + * deleted, which is what the 2026-08-16 attempt got wrong. + * + * A disable that never lands leaves the entry to time out, and the reference is then left alone rather than + * deleted anyway. Nothing is lost by that. The disable is what makes the item go away for the player who did + * not pick it up; the delete only stops the cell carrying a marked reference around until the next load. + */ +void RunDeferredObjectDeletes() noexcept +{ + // About a second and a half at 90 Hz, against a task pool that normally drains within a frame or two. + constexpr uint32_t kMaxPasses = 128; + + std::scoped_lock _{s_pendingDeleteLock}; + + for (auto it = s_pendingDeletes.begin(); it != s_pendingDeletes.end();) + { + TESObjectREFR* pObject = Cast(TESForm::GetById(it->FormId)); + + if (!pObject) + { + spdlog::info("Deferred removal of {:X} found nothing left to remove", it->FormId); + + it = s_pendingDeletes.erase(it); + continue; + } + + if (!it->Disabled) + { + /** + * @brief A reference that has already lost its 3D is left exactly as it is. + * + * Destroying one of these kills the session, and moving the destruction onto this thread did not + * change that. Measured on 2026-09-30: `FF000DF6`, base 2C672, was adopted with its 3D at + * 14:24:51.416 and had lost it 41 ms later. The player aimed at it and the pickup guard refused + * them at 14:24:58, which is what proves the crosshair was holding a handle to it. The other + * player took it, this ran at 14:25:20.496, and the process died 1.5 s later on the perk entry + * point stack with `BSFadeNode "Weapon (0002C672)"` on it. + * + * Read before the disable, because the disable takes the 3D away itself and a later pass could + * not tell a reference that arrived broken from one we have just taken apart. + * + * Nothing visible is left behind, since the reference is not drawn. What it costs is the other + * player's copy vanishing while ours stays as an invisible entry in the cell. The candidate + * filter in InventoryService ignores these, so one left here cannot spoil a later drop. + */ + if (!pObject->GetNiNode()) + { + spdlog::warn("Dropped object {:X} was left in the world rather than removed, because it has no 3D and destroying one in that state is what every crash has followed", it->FormId); + + it = s_pendingDeletes.erase(it); + continue; + } + + pObject->Disable(false); + + it->Disabled = true; + ++it; + continue; + } + + ++it->Passes; + + if (!pObject->IsDisabled() || pObject->GetNiNode()) + { + if (it->Passes < kMaxPasses) + { + ++it; + continue; + } + + spdlog::warn("Dropped object {:X} was disabled {} passes ago and the task has still not run, so it is left as it is: 3D {}, flags {:08X}", + it->FormId, it->Passes, pObject->GetNiNode() ? "present" : "gone", pObject->flags); + + it = s_pendingDeletes.erase(it); + continue; + } + + pObject->Delete(); + + spdlog::info("Removed dropped object {:X} on the main loop, {} passes after the disable: loaded state {}, flags {:08X}", + it->FormId, it->Passes, pObject->loadedState ? "present" : "gone", pObject->flags); + + it = s_pendingDeletes.erase(it); + } +} + void ObjectService::StopSettling(const uint32_t acFormId) noexcept { for (auto it = m_settling.begin(); it != m_settling.end(); ++it) diff --git a/Code/client/Services/InventoryService.h b/Code/client/Services/InventoryService.h index cdd8b71e9..19e1fb35b 100644 --- a/Code/client/Services/InventoryService.h +++ b/Code/client/Services/InventoryService.h @@ -57,6 +57,24 @@ struct InventoryService */ void RunWeaponStateUpdates() noexcept; +#if TP_SKYRIMVR + /** + * @brief Re-scans the cell of a drop that produced no handle, a beat after the drop. + * + * Scaffolding for the orphaned-drop crash, see ReportTemporariesWithBase. Remove with it. + */ + void RunFailedDropScans(const double aDelta) noexcept; + + struct FailedDropScan + { + uint32_t CellId{}; + uint32_t BaseFormId{}; + double Remaining{}; + }; + + Vector m_failedDropScans{}; +#endif + World& m_world; entt::dispatcher& m_dispatcher; TransportService& m_transport; diff --git a/Code/client/Services/ObjectService.h b/Code/client/Services/ObjectService.h index aa2aa9a79..d74afc6e4 100644 --- a/Code/client/Services/ObjectService.h +++ b/Code/client/Services/ObjectService.h @@ -32,6 +32,12 @@ class ObjectService final : public BSTEventSink public: ObjectService(World&, entt::dispatcher&, TransportService&); +#if TP_SKYRIMVR + // Whether this reference is already paired with a drop. InventoryService asks before adopting one, + // because two drops pointing at a single reference means one of the two weapons never exists. + bool IsRegisteredDrop(const uint32_t acFormId) const noexcept; +#endif + private: void OnDisconnected(const DisconnectedEvent&) noexcept; void OnCellChange(const CellChangeEvent&) noexcept; @@ -71,6 +77,10 @@ class ObjectService final : public BSTEventSink void RunDrivenObjectTimeouts() noexcept; void RunDrivenObjectInterpolation(const double aDelta) noexcept; + // Diagnostic only. Watches the drops we registered ourselves for losing their 3D, which is the state + // that kills the session. See RunDropObjectHealthWatch. + void RunDropObjectHealthWatch(const double aDelta) noexcept; + // Diagnostic only, nothing here writes to an object. See WatchForDrift. void WatchForDrift(const uint32_t acFormId, const char* acpReason) noexcept; void StopWatchingDrift(const uint32_t acFormId) noexcept; @@ -263,6 +273,14 @@ class ObjectService final : public BSTEventSink // Temporaries already named by the no-3D report in RunCellDriftSweep, so a reference that stays in that // state is written once rather than every second. Cleared on a cell change with the samples. - Set m_reportedWithout3D{}; + Set m_reportedWithout3D{}; + + // Drops already named by RunDropObjectHealthWatch, so one that stays broken is written once rather than + // every frame. + Set m_reportedDropWithout3D{}; + + // How long each drop has gone without 3D, so a slow load can be told from a failed one. Cleared the + // moment its 3D appears. See the confirmation report in RunDropObjectHealthWatch. + Map m_dropWithout3DFor{}; #endif }; diff --git a/Code/client/SkyrimVM64.cpp b/Code/client/SkyrimVM64.cpp index 10272ba5b..18f16646e 100644 --- a/Code/client/SkyrimVM64.cpp +++ b/Code/client/SkyrimVM64.cpp @@ -16,6 +16,7 @@ static TVMDestructor* VMDestructor = nullptr; #if TP_SKYRIMVR void RunDeferredSkillsTreeUpdate(); // Interface/Menus/SkillsMenu.cpp +void RunDeferredObjectDeletes(); // Services/Generic/ObjectService.cpp #endif int TP_MAKE_THISCALL(HookVMUpdate, GameVM, float a2) @@ -32,6 +33,7 @@ short TP_MAKE_THISCALL(HookMainLoop, Main) #if TP_SKYRIMVR RunDeferredSkillsTreeUpdate(); + RunDeferredObjectDeletes(); #endif return TiltedPhoques::ThisCall(MainLoop, apThis); diff --git a/Code/client/VRAddressMap.inl b/Code/client/VRAddressMap.inl index d51b97e85..ae449adcc 100644 --- a/Code/client/VRAddressMap.inl +++ b/Code/client/VRAddressMap.inl @@ -1,11 +1,11 @@ // Generated by Tools/vr_addresses/resolve.mjs. Do not edit by hand. // Hand-verified corrections belong in Tools/vr_addresses/overrides.csv. // -// AE ids used by the client: 3086, mapped to SkyrimVR 1.4.15: 3079 +// AE ids used by the client: 3087, mapped to SkyrimVR 1.4.15: 3080 // curated-verified 2814 // auto-diff 121 // curated-weak 62 -// override 43 +// override 44 // curated-identical 34 // UNMAPPED 7 // addrlib 5 @@ -53,6 +53,7 @@ static constexpr VRAddress kVRAddressMap[] = { {19689, 0x029F110, 1, "s_removeInventoryItem"}, {19702, 0x029FAC0, 1, "s_getContainer"}, {19708, 0x029FDB0, 1, "s_addInventoryItem"}, + {19727, 0x02A12A0, 5, "s_load3D"}, {19742, 0x02A5710, 1, "s_SpawnActorInWorld"}, {19750, 0x02A5CF0, 5, "s_findReferenceFor3D"}, {19784, 0x02A7BA0, 1, "refrGetWorldLocation"}, diff --git a/Tools/vr_addresses/overrides.csv b/Tools/vr_addresses/overrides.csv index 98ca915dc..7ff99b338 100644 --- a/Tools/vr_addresses/overrides.csv +++ b/Tools/vr_addresses/overrides.csv @@ -307,3 +307,15 @@ # rigid body already has a world, adds m_rigidBodies (+0x10, count +0x18) in one batch, then each of # m_constraints (+0x20, count +0x28). SE 1.6.1170 0x140BD8180 is byte identical for the first 64 bytes. 64157, 0x140B50C30, hkaRagdollInstance::addToWorld - SE 1.6.1170 0x140BD8180 byte identical, sse_vr.csv agrees + +# TESObjectREFR::Load3D(bool aBackgroundLoading), for repairing a dropped reference whose asynchronous 3D load +# lost its race. se_ae.csv has no row for SSE 19300, so the chain in resolve.mjs cannot reach it, but the +# neighbours are contiguous: 19301->19728, 19302->19729, 19305->19732, 19306->19733, all +427, which puts +# 19300 at AE 19727 (present in offsets-1-6-318-0 at 0x1402A1ED0). database.csv carries SSE 19300 at +# 0x14028FB90, matching offsets-1-5-97-0 exactly, and gives VR 0x1402A12A0. +# +# Confirmed by reading it rather than by trusting the table. The first thirty bytes take (this, bool), load +# TESForm::flags from [rcx+0x10], test bit 0xB and bit 5, and bail on either: disabled and deleted, the two +# bits already confirmed from runtime flag values on 2026-09-30 (0x82A after a Disable and Delete). Nothing +# else in this region takes a bool and gates on exactly those two. +19727, 0x1402A12A0, TESObjectREFR::Load3D(bool) - SSE 19300 via a contiguous se_ae run, verified by disassembly diff --git a/Tools/vr_addresses/report.tsv b/Tools/vr_addresses/report.tsv index 40a0d41a8..43e7cf1de 100644 --- a/Tools/vr_addresses/report.tsv +++ b/Tools/vr_addresses/report.tsv @@ -4,12 +4,12 @@ aeid kind name confidence vr vr_offset sse_1_5_97 sse_route flags db_name site 11616 pointer setHealth auto-diff 0x14011ea00 0x11ea00 0x14010e420 addr-match Code/client/Games/Skyrim/ExtraData/ExtraDataList.cpp:126 11619 pointer setChargeData auto-diff 0x14011ede0 0x11ede0 0x14010e800 addr-match Code/client/Games/Skyrim/ExtraData/ExtraDataList.cpp:104 11620 pointer setSoulData curated-verified 0x14011ef40 0x11ef40 0x14010e960 addr-match RE::BSExtraDataList::SetSoul Code/client/Games/Skyrim/ExtraData/ExtraDataList.cpp:97 -11806 pointer realSetLevelMod auto-diff 0x140129a30 0x129a30 0x140119450 both Code/client/Games/Skyrim/Actor.cpp:239 +11806 pointer realSetLevelMod auto-diff 0x140129a30 0x129a30 0x140119450 both Code/client/Games/Skyrim/Actor.cpp:240 11822 pointer setPoison auto-diff 0x14012a640 0x12a640 0x14011a060 addr-match Code/client/Games/Skyrim/ExtraData/ExtraDataList.cpp:119 12052 pointer s_hasQuestObjectAlias curated-identical 0x140136c30 0x136c30 0x140126650 both bool ExtraDataList::HasQuestObjectAlias (BSExtraDataList * a_this) Code/client/Games/Skyrim/ExtraData/ExtraDataList.cpp:140 12060 pointer setEnchantmentData curated-identical 0x1401372b0 0x1372b0 0x140126cd0 addr-match void ExtraDataList::SetEnchantment(EnchantmentItem* a_enchantment Code/client/Games/Skyrim/ExtraData/ExtraDataList.cpp:133 12401 pointer realSetLock auto-diff 0x1401454a0 0x1454a0 0x140134af0 addr-match Code/client/Games/Misc/Lock.cpp:6 -13631 pointer s_start curated-verified 0x140175a10 0x175a10 0x1401652d0 addr-match void UIMessageQueue::AddMessage (UIMessageQueue * a_this Code/client/Games/Skyrim/Interface/UI.cpp:148 +13631 pointer s_start curated-verified 0x140175a10 0x175a10 0x1401652d0 addr-match void UIMessageQueue::AddMessage (UIMessageQueue * a_this Code/client/Games/Skyrim/Interface/UI.cpp:219 13718 pointer getCell auto-diff 0x14017c4e0 0x17c4e0 0x14016bac0 addr-match Code/client/Games/ModManager.cpp:68 13723 pointer s_realSpawnNewREFR curated-verified 0x14017cc30 0x17cc30 0x14016c210 both ObjectRefHandle TESDataHandler::CreateReferenceAtLocation(TESBoundObject* a_base Code/client/Games/ModManager.cpp:76 13878 pointer skyGet curated-verified 0x1401876a0 0x1876a0 0x140177790 both RE::Sky::GetSingleton Code/client/Games/Skyrim/Sky/Sky.cpp:13 @@ -23,7 +23,7 @@ aeid kind name confidence vr vr_offset sse_1_5_97 sse_route flags db_name site 15002 pointer sub_1401A2220 override 0x1401b1f00 0x1b1f00 none sub_1401A2220 - 1.5.97 address taken from the symbol name, mapped through sse_vr.csv Code/client/Games/Skyrim/AnimationExperiments.cpp:376 15006 pointer sub_1401A1D50 override 0x1401b1a30 0x1b1a30 none sub_1401A1D50 - 1.5.97 address taken from the symbol name, mapped through sse_vr.csv Code/client/Games/Skyrim/AnimationExperiments.cpp:407 16005 pointer s_isQuestObject curated-verified 0x1401e7740 0x1e7740 0x1401d6ba0 both bool InventoryEntryData::IsQuestObject() Code/client/Games/Skyrim/ExtraData/ExtraContainerChanges.cpp:33 -16040 pointer s_getContainerChangs curated-verified 0x1401e99e0 0x1e99e0 0x1401d8e40 both InventoryChanges* TESObjectREFR::MakeInventoryChanges() Code/client/Games/Skyrim/TESObjectREFR.cpp:470 +16040 pointer s_getContainerChangs curated-verified 0x1401e99e0 0x1e99e0 0x1401d8e40 both InventoryChanges* TESObjectREFR::MakeInventoryChanges() Code/client/Games/Skyrim/TESObjectREFR.cpp:487 16113 pointer s_getArmor UNMAPPED 0x1401e7570 both Code/client/Games/Skyrim/ExtraData/ExtraContainerChanges.cpp:78 16142 pointer s_save auto-diff 0x1401fce80 0x1fce80 0x1401ec310 addr-match Code/client/Games/Skyrim/ExtraData/ExtraContainerChanges.cpp:15 16143 pointer s_load auto-diff 0x1401fcfb0 0x1fcfb0 0x1401ec440 addr-match Code/client/Games/Skyrim/ExtraData/ExtraContainerChanges.cpp:24 @@ -33,24 +33,25 @@ aeid kind name confidence vr vr_offset sse_1_5_97 sse_route flags db_name site 18573 pointer s_clearImageSpaceModifier curated-identical 0x14025e3c0 0x25e3c0 0x14024ccd0 both void ImageSpaceModifierInstanceForm::Stop(TESImageSpaceModifier* a_imod) Code/client/Games/Skyrim/Forms/ImageSpaceModifierInstanceForm.h:12 19075 pointer s_getCOCPlacementInfo override 0x14027a4c0 0x27a4c0 0x140268ef0 addr-match TESObjectCELL::GetCOCPlacementInfo - was auto-diff, confirmed against SE 0x1402BD850. The two bodies are byte identical for the first 0x40 bytes bar one displacement, SE mov rax,[rcx+0x68] against VR mov rax,[rcx+0x60], which is the same ExtraDataList -8 every TESObjectCELL member above 0x48 takes Code/client/Games/Skyrim/Forms/TESObjectCELL.cpp:30 19364 pointer s_pushEvent UNMAPPED none Code/client/Games/Skyrim/Events/EventDispacther.cpp:32 -19512 pointer s_lockChange auto-diff 0x140297310 0x297310 0x140285be0 addr-match Code/client/Games/Skyrim/TESObjectREFR.cpp:1260 -19689 pointer s_removeInventoryItem auto-diff 0x14029f110 0x29f110 0x14028d9e0 addr-match Code/client/Games/Skyrim/TESObjectREFR.cpp:1266 -19702 pointer s_getContainer auto-diff 0x14029fac0 0x29fac0 0x14028e390 addr-match Code/client/Games/Skyrim/TESObjectREFR.cpp:490 -19708 pointer s_addInventoryItem auto-diff 0x14029fdb0 0x29fdb0 0x14028e680 addr-match Code/client/Games/Skyrim/TESObjectREFR.cpp:1265 -19742 pointer s_SpawnActorInWorld auto-diff 0x1402a5710 0x2a5710 0x140294000 both Code/client/Games/Skyrim/Actor.cpp:1505 -19750 pointer s_findReferenceFor3D override 0x1402a5cf0 0x2a5cf0 0x1402945e0 addr-match RE::TESObjectREFR::FindReferenceFor3D Code/client/Games/Skyrim/Havok/RagdollAddGuard.cpp:173 +19512 pointer s_lockChange auto-diff 0x140297310 0x297310 0x140285be0 addr-match Code/client/Games/Skyrim/TESObjectREFR.cpp:1362 +19689 pointer s_removeInventoryItem auto-diff 0x14029f110 0x29f110 0x14028d9e0 addr-match Code/client/Games/Skyrim/TESObjectREFR.cpp:1368 +19702 pointer s_getContainer auto-diff 0x14029fac0 0x29fac0 0x14028e390 addr-match Code/client/Games/Skyrim/TESObjectREFR.cpp:507 +19708 pointer s_addInventoryItem auto-diff 0x14029fdb0 0x29fdb0 0x14028e680 addr-match Code/client/Games/Skyrim/TESObjectREFR.cpp:1367 +19727 pointer s_load3D override 0x1402a12a0 0x2a12a0 0x14028fb90 addr-match TESObjectREFR::Load3D Code/client/Games/Skyrim/TESObjectREFR.cpp:443 +19742 pointer s_SpawnActorInWorld auto-diff 0x1402a5710 0x2a5710 0x140294000 both Code/client/Games/Skyrim/Actor.cpp:1630 +19750 pointer s_findReferenceFor3D override 0x1402a5cf0 0x2a5cf0 0x1402945e0 addr-match RE::TESObjectREFR::FindReferenceFor3D Code/client/Games/Skyrim/Havok/RagdollAddGuard.cpp:201 19784 pointer refrGetWorldLocation auto-diff 0x1402a7ba0 0x2a7ba0 0x1402964a0 addr-match Code/client/Services/Debug/Views/CombatView.cpp:77 -19787 pointer s_rotateX auto-diff 0x1402a7d80 0x2a7d80 0x140296680 both Code/client/Games/Skyrim/TESObjectREFR.cpp:1261 -19788 pointer s_rotateY auto-diff 0x1402a7e40 0x2a7e40 0x140296740 both Code/client/Games/Skyrim/TESObjectREFR.cpp:1262 -19789 pointer s_rotateZ auto-diff 0x1402a7f00 0x2a7f00 0x140296800 both Code/client/Games/Skyrim/TESObjectREFR.cpp:1263 -19790 pointer s_setPosition curated-verified 0x1402a8010 0x2a8010 0x140296910 both void TESObjectREFR::SetPosition(const NiPoint3& a_pos) Code/client/Games/Skyrim/Actor.cpp:1498 -19796 pointer s_activate curated-identical 0x1402a8300 0x2a8300 0x140296c00 addr-match bool TESObjectREFR::ActivateRef(TESObjectREFR* a_activator Code/client/Games/Skyrim/TESObjectREFR.cpp:1264 -19800 pointer s_enable curated-identical 0x1402a9890 0x2a9890 0x140298190 both void TESObjectREFR::Enable(bool a_resetInventory) Code/client/Games/Skyrim/TESObjectREFR.cpp:965 -19812 pointer s_GetActorLocation curated-verified 0x1402aabf0 0x2aabf0 0x1402994f0 both BGSLocation* TESObjectREFR::GetCurrentLocation() Code/client/Games/Skyrim/Actor.cpp:1503 +19787 pointer s_rotateX auto-diff 0x1402a7d80 0x2a7d80 0x140296680 both Code/client/Games/Skyrim/TESObjectREFR.cpp:1363 +19788 pointer s_rotateY auto-diff 0x1402a7e40 0x2a7e40 0x140296740 both Code/client/Games/Skyrim/TESObjectREFR.cpp:1364 +19789 pointer s_rotateZ auto-diff 0x1402a7f00 0x2a7f00 0x140296800 both Code/client/Games/Skyrim/TESObjectREFR.cpp:1365 +19790 pointer s_setPosition curated-verified 0x1402a8010 0x2a8010 0x140296910 both void TESObjectREFR::SetPosition(const NiPoint3& a_pos) Code/client/Games/Skyrim/Actor.cpp:1623 +19796 pointer s_activate curated-identical 0x1402a8300 0x2a8300 0x140296c00 addr-match bool TESObjectREFR::ActivateRef(TESObjectREFR* a_activator Code/client/Games/Skyrim/TESObjectREFR.cpp:1366 +19800 pointer s_enable curated-identical 0x1402a9890 0x2a9890 0x140298190 both void TESObjectREFR::Enable(bool a_resetInventory) Code/client/Games/Skyrim/TESObjectREFR.cpp:982 +19812 pointer s_GetActorLocation curated-verified 0x1402aabf0 0x2aabf0 0x1402994f0 both BGSLocation* TESObjectREFR::GetCurrentLocation() Code/client/Games/Skyrim/Actor.cpp:1628 19846 pointer s_getHandle auto-diff 0x1402ad090 0x2ad090 0x14029b980 addr-match Code/client/Games/Skyrim/TESObjectREFR.cpp:166 20203 pointer getLocationEncounterZone auto-diff 0x1402b8480 0x2b8480 0x1402a6d10 addr-match Code/client/Services/Debug/Views/CombatView.cpp:36 -20221 pointer realCreateLock curated-verified 0x1402b89e0 0x2b89e0 0x1402a7270 addr-match REFR_LOCK* TESObjectREFR::AddLock() Code/client/Games/Skyrim/TESObjectREFR.cpp:441 -20223 pointer realGetLock curated-verified 0x1402b8c30 0x2b8c30 0x1402a74c0 addr-match RE::TESObjectREFR::GetLock Code/client/Games/Skyrim/TESObjectREFR.cpp:433 +20221 pointer realCreateLock curated-verified 0x1402b89e0 0x2b89e0 0x1402a7270 addr-match REFR_LOCK* TESObjectREFR::AddLock() Code/client/Games/Skyrim/TESObjectREFR.cpp:458 +20223 pointer realGetLock curated-verified 0x1402b8c30 0x2b8c30 0x1402a74c0 addr-match RE::TESObjectREFR::GetLock Code/client/Games/Skyrim/TESObjectREFR.cpp:450 20460 pointer s_loadCell curated-identical 0x1402c3850 0x2c3850 0x1402b20e0 addr-match TESWorldSpace * TESWorldSpace::sub_1402B20E0 (TESWorldSpace * a_this) Code/client/Games/Skyrim/Forms/TESWorldSpace.cpp:6 21600 pointer isFirstPerson auto-diff 0x1402ed9a0 0x2ed9a0 0x1402dc490 addr-match Code/client/Games/Skyrim/Camera/PlayerCamera.cpp:15 21622 pointer hasPerk override 0x1402ee440 0x2ee440 0x1402dcf30 addr-match Actor::HasPerk - was auto-diff. First 16 bytes byte identical to SE 0x140332E80 Code/client/Games/Skyrim/Magic/MagicTarget.cpp:234 @@ -70,7 +71,7 @@ aeid kind name confidence vr vr_offset sse_1_5_97 sse_route flags db_name site 26244 pointer releaseWeatherOverride auto-diff 0x1403c4970 0x3c4970 0x1403b4b50 addr-match Code/client/Games/Skyrim/Sky/Sky.cpp:33 26503 pointer s_getDifficultyMultiplier curated-identical 0x1403d0a80 0x3d0a80 0x1403c1200 addr-match float GetDifficultyMultiplier(uint32_t a_difficulty Code/client/Games/References.cpp:56 27040 pointer CreateTints override 0x1403eada0 0x3eada0 0x1403db420 addr-match ApplyMasksToRenderTargets Code/client/Systems/FaceGenSystem.cpp:64 -27244 pointer s_calculateExperience auto-diff 0x1403f2ff0 0x3f2ff0 0x1403e3640 addr-match Code/client/Games/Skyrim/PlayerCharacter.cpp:286 +27244 pointer s_calculateExperience auto-diff 0x1403f2ff0 0x3f2ff0 0x1403e3640 addr-match Code/client/Games/Skyrim/PlayerCharacter.cpp:330 32802 pointer sub_1404ECF50 auto-diff 0x1404fd1c0 0x4fd1c0 0x1404ecf50 addr-match Code/client/Games/Skyrim/AnimationExperiments.cpp:338 32803 pointer sub_1404ED090 auto-diff 0x1404fd300 0x4fd300 0x1404ed090 addr-match Code/client/Games/Skyrim/AnimationExperiments.cpp:375 32883 pointer InternalRevertAnimationGraphManager auto-diff 0x140500890 0x500890 0x1404f0620 addr-match Code/client/Games/IAnimationGraphManagerHolder.cpp:34 @@ -95,8 +96,8 @@ aeid kind name confidence vr vr_offset sse_1_5_97 sse_route flags db_name site 34526 pointer addTarget override 0x1405579c0 0x5579c0 0x140553750 id-map papyrus_extender::events::game::magictargetapply_0x1e8 Code/client/Games/Skyrim/Magic/MagicTarget.cpp:230 34529 pointer getTargetAsActor override 0x140557f80 0x557f80 0x140553e90 addr-match MagicTarget::GetTargetAsActor - was auto-diff. Byte identical to SE 0x1405C3B90 apart from one displacement, `lea rax,[rbx-0xA0]` against `lea rax,[rbx-0x98]`, which is sizeof(TESObjectREFR) and the ExtraDataList -8 all over again Code/client/Games/Skyrim/Magic/MagicTarget.cpp:77 34989 pointer s_start auto-diff 0x140569920 0x569920 0x1405636b0 addr-match Code/client/Games/Skyrim/Effects/SummonCreatureEffect.h:30 -35086 pointer s_applyActorEffect auto-diff 0x14056e070 0x56e070 0x140567a80 addr-match Code/client/Games/Skyrim/Actor.cpp:1507 -35269 pointer s_playDialogueOption auto-diff 0x140574bf0 0x574bf0 0x14056e5f0 addr-match Code/client/Games/Misc/MenuTopicManager.cpp:40 +35086 pointer s_applyActorEffect auto-diff 0x14056e070 0x56e070 0x140567a80 addr-match Code/client/Games/Skyrim/Actor.cpp:1632 +35269 pointer s_playDialogueOption auto-diff 0x140574bf0 0x574bf0 0x14056e5f0 addr-match Code/client/Games/Misc/MenuTopicManager.cpp:52 35503 pointer internalGetChangeFlags override 0x14057f650 0x57f650 0x140577ef0 addr-match internalGetChangeFlags - six call sites pair one for one with SE 0x140600930 Code/client/Games/Forms.cpp:115 35993 pointer ctor override 0x14059ef30 0x59ef30 none BGSLoadGameBuffer ctor - vtable 0x1416BDF80 has three referencing sites in each build and they pair one for one, and the body is byte identical to SE 0x140624D30 bar displacements Code/client/Games/SaveLoad.cpp:49 36000 pointer s_readFormId override 0x14059f160 0x59f160 0x140597aa0 addr-match BGSLoadFormBuffer::ReadFormId - was auto-diff, byte identical to SE 0x140624FC0. Also confirms buffer at 0x8 and position at 0x24 on VR, both builds doing `mov r9d,[rcx+0x24]` / `add r8,[rcx+8]` / `lea eax,[r9+3]` / `mov [rcx+0x24],eax`. Sits +0x20 past vtable slot 1 in both builds Code/client/Games/SaveLoad.cpp:119 @@ -107,31 +108,31 @@ aeid kind name confidence vr vr_offset sse_1_5_97 sse_route flags db_name site 36166 pointer addArmorEnchantment curated-identical 0x1405a6850 0x5a6850 0x14059f190 addr-match EnchantmentItem* BGSCreatedObjectManager::AddArmorEnchantment(BSTArray& a_effects) Code/client/Games/Skyrim/Forms/EnchantmentItem.cpp:13 36291 pointer s_SimulateTime curated-identical 0x1405ad8f0 0x5ad8f0 0x1405a6230 addr-match void Calendar::Update_1405a6230 (Calendar * a_this Code/client/Games/TimeManager.cpp:25 36544 pointer winMain override 0x1405b4290 0x5b4290 none winMain - target of the CRT invoke_main call sequence in SkyrimVR.exe Code/client/TiltedOnlineApp.cpp:53 -36548 hook MainInit override 0x1405b65e0 0x5b65e0 none MainInit - second call out of WinMain, prologue identical to SE 0x140641190 Code/client/Games/Skyrim/Interface/UI.cpp:139 -36564 pointer cMainLoop override 0x1405bab10 0x5bab10 none cMainLoop - callee of the Sleep(50) main-loop block in SkyrimVR.exe Code/client/SkyrimVM64.cpp:42 +36548 hook MainInit override 0x1405b65e0 0x5b65e0 none MainInit - second call out of WinMain, prologue identical to SE 0x140641190 Code/client/Games/Skyrim/Interface/UI.cpp:203 +36564 pointer cMainLoop override 0x1405bab10 0x5bab10 none cMainLoop - callee of the Sleep(50) main-loop block in SkyrimVR.exe Code/client/SkyrimVM64.cpp:87 37147 pointer sub_1405CCB20 auto-diff 0x1405d5120 0x5d5120 0x1405ccb20 addr-match Code/client/Games/Skyrim/AnimationExperiments.cpp:337 -37175 pointer s_characterDtor auto-diff 0x1405d63a0 0x5d63a0 0x1405cdda0 addr-match Code/client/Games/Skyrim/Actor.cpp:1502 -37198 pointer addDeathItems curated-identical 0x1405d80a0 0x5d80a0 0x1405cfac0 addr-match void Actor::AddDeathItems() Code/client/Games/Skyrim/Actor.cpp:1517 -37313 pointer s_ForceState auto-diff 0x1405dc650 0x5dc650 0x1405d4090 addr-match Code/client/Games/Skyrim/Actor.cpp:1504 -37334 pointer s_getLevel curated-verified 0x1405de910 0x5de910 0x1405d62e0 addr-match RE::Actor::GetLevel Code/client/Games/Skyrim/Actor.cpp:165 -37335 pointer s_damageActor curated-verified 0x1405de930 0x5de930 0x1405d6300 addr-match Actor::DamageHealth Code/client/Games/Skyrim/Actor.cpp:1506 -37356 pointer s_actorProcess auto-diff 0x1405e0e20 0x5e0e20 0x1405d87f0 addr-match Code/client/Games/Skyrim/Actor.cpp:1497 -37448 pointer s_regenAttributes curated-verified 0x1405e9b60 0x5e9b60 0x1405e1510 addr-match float TESObjectREFR::GetSubmergeLevel_1405E1510 (TESObjectREFR * a_this Code/client/Games/Skyrim/Actor.cpp:1508 +37175 pointer s_characterDtor auto-diff 0x1405d63a0 0x5d63a0 0x1405cdda0 addr-match Code/client/Games/Skyrim/Actor.cpp:1627 +37198 pointer addDeathItems curated-identical 0x1405d80a0 0x5d80a0 0x1405cfac0 addr-match void Actor::AddDeathItems() Code/client/Games/Skyrim/Actor.cpp:1642 +37313 pointer s_ForceState auto-diff 0x1405dc650 0x5dc650 0x1405d4090 addr-match Code/client/Games/Skyrim/Actor.cpp:1629 +37334 pointer s_getLevel curated-verified 0x1405de910 0x5de910 0x1405d62e0 addr-match RE::Actor::GetLevel Code/client/Games/Skyrim/Actor.cpp:166 +37335 pointer s_damageActor curated-verified 0x1405de930 0x5de930 0x1405d6300 addr-match Actor::DamageHealth Code/client/Games/Skyrim/Actor.cpp:1631 +37356 pointer s_actorProcess auto-diff 0x1405e0e20 0x5e0e20 0x1405d87f0 addr-match Code/client/Games/Skyrim/Actor.cpp:1622 +37448 pointer s_regenAttributes curated-verified 0x1405e9b60 0x5e9b60 0x1405e1510 addr-match float TESObjectREFR::GetSubmergeLevel_1405E1510 (TESObjectREFR * a_this Code/client/Games/Skyrim/Actor.cpp:1633 37511 pointer s_payGoldToContainer auto-diff 0x1405ee4f0 0x5ee4f0 0x1405e5dd0 addr-match Code/client/Games/Skyrim/TESObjectREFR.cpp:426 -37521 pointer s_pickUpObject auto-diff 0x1405eeca0 0x5eeca0 0x1405e6580 addr-match Code/client/Games/Skyrim/Actor.cpp:1510 -37525 pointer s_addInventoryItem auto-diff 0x1405ef640 0x5ef640 0x1405e6f20 addr-match Code/client/Games/Skyrim/Actor.cpp:1509 -37527 pointer s_getGoldAmount auto-diff 0x1405efa10 0x5efa10 0x1405e72f0 addr-match Code/client/Games/Skyrim/Actor.cpp:657 -37542 pointer s_speakSoundFunction curated-verified 0x1405f0e20 0x5f0e20 0x1405e8700 addr-match po3tweaks::VoiceModulation::SetObjectToFollow Code/client/Games/Skyrim/Actor.cpp:1516 -37577 pointer isFleeing auto-diff 0x1405f5280 0x5f5280 0x1405ecb70 addr-match Code/client/Games/Skyrim/Actor.cpp:1518 -37677 pointer s_setFactionRankInternal auto-diff 0x140600220 0x600220 0x1405f7ab0 addr-match Code/client/Games/Skyrim/Actor.cpp:746 -37698 pointer getPerkRank curated-verified 0x1406025a0 0x6025a0 0x1405f9e30 addr-match RE::Actor::HasPerk Code/client/Games/Skyrim/Actor.cpp:401 -37717 pointer setPlayerTeammate override 0x140602f40 0x602f40 none Actor::SetPlayerTeammate - compares this against the player singleton exactly as SE 0x14068D690 Code/client/Games/Skyrim/Actor.cpp:754 +37521 pointer s_pickUpObject auto-diff 0x1405eeca0 0x5eeca0 0x1405e6580 addr-match Code/client/Games/Skyrim/Actor.cpp:1635 +37525 pointer s_addInventoryItem auto-diff 0x1405ef640 0x5ef640 0x1405e6f20 addr-match Code/client/Games/Skyrim/Actor.cpp:1634 +37527 pointer s_getGoldAmount auto-diff 0x1405efa10 0x5efa10 0x1405e72f0 addr-match Code/client/Games/Skyrim/Actor.cpp:658 +37542 pointer s_speakSoundFunction curated-verified 0x1405f0e20 0x5f0e20 0x1405e8700 addr-match po3tweaks::VoiceModulation::SetObjectToFollow Code/client/Games/Skyrim/Actor.cpp:1641 +37577 pointer isFleeing auto-diff 0x1405f5280 0x5f5280 0x1405ecb70 addr-match Code/client/Games/Skyrim/Actor.cpp:1643 +37677 pointer s_setFactionRankInternal auto-diff 0x140600220 0x600220 0x1405f7ab0 addr-match Code/client/Games/Skyrim/Actor.cpp:809 +37698 pointer getPerkRank curated-verified 0x1406025a0 0x6025a0 0x1405f9e30 addr-match RE::Actor::HasPerk Code/client/Games/Skyrim/Actor.cpp:402 +37717 pointer setPlayerTeammate override 0x140602f40 0x602f40 none Actor::SetPlayerTeammate - compares this against the player singleton exactly as SE 0x14068D690 Code/client/Games/Skyrim/Actor.cpp:817 37757 pointer getDetectionState auto-diff 0x140604f30 0x604f30 0x1405fc740 addr-match Code/client/Services/Debug/Views/CombatView.cpp:51 -37905 pointer s_initiateMountPackage auto-diff 0x14060e300 0x60e300 0x140605b10 addr-match Code/client/Games/Skyrim/Actor.cpp:1514 -37975 pointer s_unequipObject auto-diff 0x140612420 0x612420 0x140609860 both Code/client/Games/Skyrim/Actor.cpp:1515 -38533 pointer s_setNoBleedoutRecovery override 0x14062c950 0x62c950 0x140623ba0 addr-match Actor::SetNoBleedoutRecovery - was auto-diff, confirmed against SE 0x1406B51D0. Same push rbx / sub rsp,0x20 prologue, same bit 5 flip of the flags dword (SE [this+0x204], VR [this+0x1FC], the ExtraDataList -8), same conditional virtual call afterwards at SE slot 0xE3 (call [rax+0x718]) and VR slot 0xE5 (call [rax+0x728]), which is the +2 the two extra VR virtuals above slot 0xA5 predict Code/client/Games/Skyrim/Actor.cpp:328 -38717 pointer s_removeSpell curated-verified 0x1406385f0 0x6385f0 0x14062f720 both bool Actor::RemoveSpell(SpellItem* a_spell) Code/client/Games/Skyrim/Actor.cpp:1499 -38757 pointer s_interruptCast curated-verified 0x14063ab50 0x63ab50 0x140631c80 both void Actor::InterruptCast(bool a_restoreMagicka) Code/client/Games/Skyrim/Actor.cpp:469 +37905 pointer s_initiateMountPackage auto-diff 0x14060e300 0x60e300 0x140605b10 addr-match Code/client/Games/Skyrim/Actor.cpp:1639 +37975 pointer s_unequipObject auto-diff 0x140612420 0x612420 0x140609860 both Code/client/Games/Skyrim/Actor.cpp:1640 +38533 pointer s_setNoBleedoutRecovery override 0x14062c950 0x62c950 0x140623ba0 addr-match Actor::SetNoBleedoutRecovery - was auto-diff, confirmed against SE 0x1406B51D0. Same push rbx / sub rsp,0x20 prologue, same bit 5 flip of the flags dword (SE [this+0x204], VR [this+0x1FC], the ExtraDataList -8), same conditional virtual call afterwards at SE slot 0xE3 (call [rax+0x718]) and VR slot 0xE5 (call [rax+0x728]), which is the +2 the two extra VR virtuals above slot 0xA5 predict Code/client/Games/Skyrim/Actor.cpp:329 +38717 pointer s_removeSpell curated-verified 0x1406385f0 0x6385f0 0x14062f720 both bool Actor::RemoveSpell(SpellItem* a_spell) Code/client/Games/Skyrim/Actor.cpp:1624 +38757 pointer s_interruptCast curated-verified 0x14063ab50 0x63ab50 0x140631c80 both void Actor::InterruptCast(bool a_restoreMagicka) Code/client/Games/Skyrim/Actor.cpp:470 38894 pointer s_equipFunc curated-identical 0x140640a90 0x640a90 0x140637a80 addr-match RE::ActorEquipManager::EquipObject Code/client/Games/Skyrim/EquipManager.cpp:87 38896 pointer s_equipFunc auto-diff 0x140640c40 0x640c40 0x140637c30 addr-match Code/client/Games/Skyrim/EquipManager.cpp:111 38897 pointer s_equipFunc curated-weak 0x140640cb0 0x640cb0 0x140637ca0 both void ActorEquipManager::EquipShout(Actor* a_actor Code/client/Games/Skyrim/EquipManager.cpp:131 @@ -152,19 +153,19 @@ aeid kind name confidence vr vr_offset sse_1_5_97 sse_route flags db_name site 39002 pointer sub_14063CAA0 auto-diff 0x140645b10 0x645b10 0x14063caa0 addr-match Code/client/Games/Skyrim/AnimationExperiments.cpp:335 39004 pointer ApplyAnimationVariables curated-verified 0x140646160 0x646160 0x14063d0f0 addr-match HandleAction Code/client/Games/Animation.cpp:110 39114 pointer s_checkForNewPackage auto-diff 0x14064c170 0x64c170 0x140642e20 addr-match Code/client/Games/References.cpp:183 -39643 pointer s_processResponse auto-diff 0x14066dd50 0x66dd50 0x140664a00 addr-match Code/client/Games/Skyrim/Actor.cpp:1513 +39643 pointer s_processResponse auto-diff 0x14066dd50 0x66dd50 0x140664a00 addr-match Code/client/Games/Skyrim/Actor.cpp:1638 39895 pointer knockExplosion curated-identical 0x140686920 0x686920 0x14067d4a0 addr-match void AIProcess::KnockExplosion(Actor* a_actor Code/client/Games/Skyrim/AI/AIProcess.cpp:6 -40245 pointer s_characterCtor curated-identical 0x14069bec0 0x69bec0 0x1406928c0 addr-match Character * Character::ctor (Character * a_this) Code/client/Games/Skyrim/Actor.cpp:1500 -40246 pointer s_characterCtor2 auto-diff 0x14069bfc0 0x69bfc0 0x1406929c0 addr-match Code/client/Games/Skyrim/Actor.cpp:1501 -40255 pointer QueueUpdate curated-verified 0x14069c710 0x69c710 0x140693110 both RE::Actor::DoReset3D Code/client/Games/Skyrim/Actor.cpp:188 -40412 pointer cVMDestructor addrlib 0x1406a3a30 0x6a3a30 0x140699df0 addr-match Code/client/SkyrimVM64.cpp:44 -40454 pointer s_dropObject auto-diff 0x1406c00f0 0x6c00f0 0x14069f910 addr-match Code/client/Games/Skyrim/Actor.cpp:1511 -40488 pointer s_addSkillExperience curated-verified 0x1406c30b0 0x6c30b0 0x1406a2540 addr-match RE::PlayerCharacter::AddSkillExperience Code/client/Games/Skyrim/PlayerCharacter.cpp:285 -40533 pointer s_pickUpObject auto-diff 0x1406c6e00 0x6c6e00 0x1406a5300 addr-match Code/client/Games/Skyrim/PlayerCharacter.cpp:283 -40535 pointer s_setWaypoint auto-diff 0x1406c74d0 0x6c74d0 0x1406a59d0 addr-match Code/client/Games/Skyrim/PlayerCharacter.cpp:287 -40536 pointer s_removeWaypoint auto-diff 0x1406c7630 0x6c7630 0x1406a5b30 addr-match Code/client/Games/Skyrim/PlayerCharacter.cpp:288 +40245 pointer s_characterCtor curated-identical 0x14069bec0 0x69bec0 0x1406928c0 addr-match Character * Character::ctor (Character * a_this) Code/client/Games/Skyrim/Actor.cpp:1625 +40246 pointer s_characterCtor2 auto-diff 0x14069bfc0 0x69bfc0 0x1406929c0 addr-match Code/client/Games/Skyrim/Actor.cpp:1626 +40255 pointer QueueUpdate curated-verified 0x14069c710 0x69c710 0x140693110 both RE::Actor::DoReset3D Code/client/Games/Skyrim/Actor.cpp:189 +40412 pointer cVMDestructor addrlib 0x1406a3a30 0x6a3a30 0x140699df0 addr-match Code/client/SkyrimVM64.cpp:89 +40454 pointer s_dropObject auto-diff 0x1406c00f0 0x6c00f0 0x14069f910 addr-match Code/client/Games/Skyrim/Actor.cpp:1636 +40488 pointer s_addSkillExperience curated-verified 0x1406c30b0 0x6c30b0 0x1406a2540 addr-match RE::PlayerCharacter::AddSkillExperience Code/client/Games/Skyrim/PlayerCharacter.cpp:329 +40533 pointer s_pickUpObject auto-diff 0x1406c6e00 0x6c6e00 0x1406a5300 addr-match Code/client/Games/Skyrim/PlayerCharacter.cpp:327 +40535 pointer s_setWaypoint auto-diff 0x1406c74d0 0x6c74d0 0x1406a59d0 addr-match Code/client/Games/Skyrim/PlayerCharacter.cpp:331 +40536 pointer s_removeWaypoint auto-diff 0x1406c7630 0x6c7630 0x1406a5b30 addr-match Code/client/Games/Skyrim/PlayerCharacter.cpp:332 42345 pointer setEnabled curated-verified 0x14072c0a0 0x72c0a0 0x140705250 both RE::PlayerControls::SetActivateHandlerBlocked Code/client/Games/Skyrim/AI/Movement/PlayerControls.cpp:13 -42704 pointer s_updateDetectionState auto-diff 0x140743170 0x743170 0x1407187d0 addr-match Code/client/Games/Skyrim/Actor.cpp:1512 +42704 pointer s_updateDetectionState auto-diff 0x140743170 0x743170 0x1407187d0 addr-match Code/client/Games/Skyrim/Actor.cpp:1637 44108 pointer s_launch curated-verified 0x140776440 0x776440 0x14074b170 addr-match BSPointerHandle* Projectile::Launch(BSPointerHandle* a_result Code/client/Games/Skyrim/Projectiles/Projectile.cpp:152 47196 pointer isValidTarget auto-diff 0x1407e1ac0 0x7e1ac0 0x1407b6770 addr-match Code/client/Services/Debug/Views/CombatView.cpp:44 47303 pointer actorHasEquippedRangedWeapon_m auto-diff 0x1407e7e90 0x7e7e90 0x1407bcb40 addr-match Code/client/Services/Debug/Views/CombatView.cpp:70 @@ -172,24 +173,24 @@ aeid kind name confidence vr vr_offset sse_1_5_97 sse_route flags db_name site 50790 pointer forceFirstPerson curated-weak 0x140876550 0x876550 0x14084b200 both void PlayerCamera::ForceFirstPerson() Code/client/Games/Skyrim/Camera/PlayerCamera.cpp:37 50796 pointer forceThirdPerson curated-weak 0x140876610 0x876610 0x14084b350 both void PlayerCamera::ForceThirdPerson() Code/client/Games/Skyrim/Camera/PlayerCamera.cpp:44 51093 pointer s_setCurrentPickREFR curated-verified 0x1408864d0 0x8864d0 0x14085bb30 addr-match RE::Console::SetSelectedRef Code/client/Games/References.cpp:185 -51538 hook FavoritesCanProcess auto-diff 0x1408a3ee0 0x8a3ee0 0x1408781c0 addr-match Code/client/Games/Skyrim/Interface/UI.cpp:144 +51538 hook FavoritesCanProcess auto-diff 0x1408a3ee0 0x8a3ee0 0x1408781c0 addr-match Code/client/Games/Skyrim/Interface/UI.cpp:208 51925 pointer s_requestLoadingText auto-diff 0x1408c23e0 0x8c23e0 0x140894950 both Code/client/Games/Skyrim/LoadingScreen.cpp:49 -52510 hook ProcessMessage override 0x1408ec3e0 0x8ec3e0 0x1408bf360 addr-match StatsMenu::ProcessMessage Code/client/Games/Skyrim/Interface/Menus/SkillsMenu.cpp:26 -52518 hook controlPatch override 0x1408ed870 0x8ed870 0x1408c0b80 addr-match StatsMenu MenuEventHandler CanProcess - slot 1 of the secondary vtable in both builds Code/client/Games/Skyrim/Interface/Menus/SkillsMenu.cpp:34 -52626 pointer s_showSubtitle curated-identical 0x1408f9c60 0x8f9c60 0x1408ccbb0 addr-match void SubtitleManager::ShowSubtitle(SubtitleManager *a_this Code/client/Games/Misc/SubtitleManager.cpp:46 -52627 pointer s_hideSubtitle auto-diff 0x1408f9ea0 0x8f9ea0 0x1408ccdf0 addr-match Code/client/Games/Misc/SubtitleManager.cpp:28 +52510 hook ProcessMessage override 0x1408ec3e0 0x8ec3e0 0x1408bf360 addr-match StatsMenu::ProcessMessage Code/client/Games/Skyrim/Interface/Menus/SkillsMenu.cpp:115 +52518 hook controlPatch override 0x1408ed870 0x8ed870 0x1408c0b80 addr-match StatsMenu MenuEventHandler CanProcess - slot 1 of the secondary vtable in both builds Code/client/Games/Skyrim/Interface/Menus/SkillsMenu.cpp:123 +52626 pointer s_showSubtitle curated-identical 0x1408f9c60 0x8f9c60 0x1408ccbb0 addr-match void SubtitleManager::ShowSubtitle(SubtitleManager *a_this Code/client/Games/Misc/SubtitleManager.cpp:49 +52627 pointer s_hideSubtitle auto-diff 0x1408f9ea0 0x8f9ea0 0x1408ccdf0 addr-match Code/client/Games/Misc/SubtitleManager.cpp:29 52847 pointer fadeOutGame curated-verified 0x140903080 0x903080 0x1408d5530 addr-match void FadeOutGame_1408d5530(undefined8 *param_1 Code/client/Games/References.cpp:119 -52849 pointer updateItemList curated-verified 0x140903370 0x903370 0x1408d5710 addr-match void SendUIMessage::SendInventoryUpdateMessage(TESObjectREFR* a_inventoryRef Code/client/Games/Skyrim/TESObjectREFR.cpp:950 +52849 pointer updateItemList curated-verified 0x140903370 0x903370 0x1408d5710 addr-match void SendUIMessage::SendInventoryUpdateMessage(TESObjectREFR* a_inventoryRef Code/client/Games/Skyrim/TESObjectREFR.cpp:967 52933 pointer s_showHudMessage curated-verified 0x140908170 0x908170 0x1408da3d0 addr-match RE::DebugNotification Code/client/Utils.cpp:90 -53112 hook hookLoc addrlib 0x140919330 0x919330 0x1408e6cf0 addr-match Code/client/Games/Skyrim/Interface/Menus/MapMenu.cpp:12 +53112 hook hookLoc addrlib 0x140919330 0x919330 0x1408e6cf0 addr-match Code/client/Games/Skyrim/Interface/Menus/MapMenu.cpp:113 53915 pointer s_setSize auto-diff 0x14095aa60 0x95aa60 0x140920360 both Code/client/Games/Skyrim/Misc/BSScript.cpp:102 -53926 pointer cVMUpdate curated-weak 0x14095c670 0x95c670 0x140921f10 addr-match SkyrimVM::ProcessRegisteredUpdates(SkyrimVM* a_vm Code/client/SkyrimVM64.cpp:43 +53926 pointer cVMUpdate curated-weak 0x14095c670 0x95c670 0x140921f10 addr-match SkyrimVM::ProcessRegisteredUpdates(SkyrimVM* a_vm Code/client/SkyrimVM64.cpp:88 54425 pointer s_registerSink auto-diff 0x140976690 0x976690 0x14093bff0 addr-match Code/client/Games/Skyrim/Events/EventDispacther.cpp:12 54522 pointer s_unregisterSink auto-diff 0x14097bd00 0x97bd00 0x140941760 both Code/client/Games/Skyrim/Events/EventDispacther.cpp:22 -55497 pointer s_setBeastForm auto-diff 0x1409ad8a0 0x9ad8a0 0x140973a00 addr-match Code/client/Games/Skyrim/PlayerCharacter.cpp:284 +55497 pointer s_setBeastForm auto-diff 0x1409ad8a0 0x9ad8a0 0x140973a00 addr-match Code/client/Games/Skyrim/PlayerCharacter.cpp:328 55739 pointer s_bindEverythingToScript curated-verified 0x1409baf10 0x9baf10 0x140980190 both FDGE::BSScript::InitializeScriptTypes Code/client/Games/Misc/BSScript.cpp:99 -56205 pointer s_playAnimation auto-diff 0x1409d02a0 0x9d02a0 0x140995780 both Code/client/Games/Skyrim/TESObjectREFR.cpp:1268 -56206 pointer s_playAnimationAndWait auto-diff 0x1409d0320 0x9d0320 0x140995800 both Code/client/Games/Skyrim/TESObjectREFR.cpp:1267 +56205 pointer s_playAnimation auto-diff 0x1409d02a0 0x9d02a0 0x140995780 both Code/client/Games/Skyrim/TESObjectREFR.cpp:1370 +56206 pointer s_playAnimationAndWait auto-diff 0x1409d0320 0x9d0320 0x140995800 both Code/client/Games/Skyrim/TESObjectREFR.cpp:1369 56626 pointer s_internalMoveTo curated-verified 0x1409e90e0 0x9e90e0 0x1409ae5c0 addr-match RE::TESObjectREFR::MoveTo Code/client/Games/Skyrim/TESObjectREFR.cpp:412 57185 pointer sub_1409CA9D0 auto-diff 0x140a052c0 0xa052c0 0x1409ca770 addr-match Code/client/Games/Skyrim/AnimationExperiments.cpp:82 57704 hook createHavokThread auto-diff 0x140a12230 0xa12230 0x1409d76e0 addr-match Code/client/Games/Skyrim/BSSystem/BSThread.cpp:87 @@ -200,7 +201,7 @@ aeid kind name confidence vr vr_offset sse_1_5_97 sse_route flags db_name site 63362 pointer BSSendEvent auto-diff 0x140b1cfa0 0xb1cfa0 0x140ae24a0 addr-match Code/client/Games/Skyrim/AnimationExperiments.cpp:240 63372 pointer sub_140AE2DB0 auto-diff 0x140b1d8b0 0xb1d8b0 0x140ae2db0 addr-match Code/client/Games/Skyrim/AnimationExperiments.cpp:227 63591 pointer InternalSendEvent auto-diff 0x140b2ac40 0xb2ac40 0x140aefeb0 addr-match Code/client/Games/Skyrim/AnimationExperiments.cpp:207 -64157 pointer s_addToWorld override 0x140b50c30 0xb50c30 0x140b15e50 addr-match hkaRagdollInstance::addToWorld - SE 1.6.1170 0x140BD8180 byte identical, sse_vr.csv agrees Code/client/Games/Skyrim/Havok/RagdollAddGuard.cpp:233 +64157 pointer s_addToWorld override 0x140b50c30 0xb50c30 0x140b15e50 addr-match hkaRagdollInstance::addToWorld - SE 1.6.1170 0x140BD8180 byte identical, sse_vr.csv agrees Code/client/Games/Skyrim/Havok/RagdollAddGuard.cpp:270 68115 pointer s_formAllocate curated-identical 0x140c3d0e0 0xc3d0e0 0x140c02260 both void* MemoryManager::Allocate(std::size_t a_size Code/client/Games/Memory.cpp:116 68117 pointer s_formFree curated-identical 0x140c3d3e0 0xc3d3e0 0x140c02560 both void MemoryManager::Deallocate(void* a_mem Code/client/Games/Memory.cpp:182 68221 pointer hash_stub curated-verified 0x140c413f0 0xc413f0 0x140c06570 addr-match RE::CRC32Calculator::SizeOf64 Code/client/Games/Skyrim/AnimationExperiments.cpp:165 @@ -221,9 +222,9 @@ aeid kind name confidence vr vr_offset sse_1_5_97 sse_route flags db_name site 77226 hook initWindowLoc override 0x140dba850 0xdba850 0x140d68dd0 addr-match void Renderer::Init(RendererInitOSData* a_data Code/client/Games/Skyrim/BSGraphics/BSGraphicsRenderer.cpp:100 77246 hook timerLoc curated-verified 0x140dbbdd0 0xdbbdd0 0x140d6a2b0 addr-match void BSGraphics::Renderer::End() Code/client/Games/Skyrim/BSGraphics/BSGraphicsRenderer.cpp:118 77299 pointer CreateResourceView curated-verified 0x140dbf9b0 0xdbf9b0 0x140d6dbc0 both NiTexture::RendererData* BSRenderManager::CreateRenderTexture(std::uint32_t a_width Code/client/Systems/FaceGenSystem.cpp:63 -77799 pointer s_getProperty override 0x140df8ab0 0xdf8ab0 0x140da3b40 addr-match hkpWorldObject property lookup, key 2 is the bhkNiCollisionObject - the call 25857 makes at 0x1403AD68D Code/client/Games/Skyrim/Havok/RagdollAddGuard.cpp:172 +77799 pointer s_getProperty override 0x140df8ab0 0xdf8ab0 0x140da3b40 addr-match hkpWorldObject property lookup, key 2 is the bhkNiCollisionObject - the call 25857 makes at 0x1403AD68D Code/client/Games/Skyrim/Havok/RagdollAddGuard.cpp:200 82074 pointer s_isMenuOpen auto-diff 0x140f1a3b0 0xf1a3b0 0x140ebe150 addr-match Code/client/Games/Skyrim/Interface/UI.cpp:23 -82082 hook ProcessHook override 0x140f1a770 0xf1a770 0x140ebe630 addr-match UI menu message pump - call sequence aligns with SE 0x140FA3FA0 throughout Code/client/Games/Skyrim/Interface/UI.cpp:134 +82082 hook ProcessHook override 0x140f1a770 0xf1a770 0x140ebe630 addr-match UI menu message pump - call sequence aligns with SE 0x140FA3FA0 throughout Code/client/Games/Skyrim/Interface/UI.cpp:198 82088 pointer s_CloseAll addrlib 0x140f1bf10 0xf1bf10 0x140ebfb70 addr-match Code/client/Games/Skyrim/Interface/UI.cpp:31 104247 pointer s_getHandle curated-verified 0x14126bbd0 0x126bbd0 0x141233720 id-map VMHandle Object::GetHandle() Code/client/Games/Skyrim/Misc/BSScript.cpp:7 104296 pointer s_reset override 0x14126f1c0 0x126f1c0 none BSScript::Variable::Reset - the sibling that calls it is byte identical to SE 0x141425AF0 and calls this address at the same point Code/client/Games/Skyrim/Misc/BSScript.cpp:24 @@ -3055,7 +3056,7 @@ aeid kind name confidence vr vr_offset sse_1_5_97 sse_route flags db_name site 400320 pointer pObjManager curated-identical 0x141f831c8 0x1f831c8 0x141ebeae8 addr-match BGSCreatedObjectManager::GetSingleton() Code/client/Games/BGSCreatedObjectManager.cpp:5 400327 pointer s_instance curated-verified 0x141f83200 0x1f83200 0x141ebeb20 addr-match RE::UI::Singleton Code/client/Games/Skyrim/Interface/UI.cpp:13 400441 pointer tes auto-diff 0x141f850d8 0x1f850d8 0x141ec0a50 addr-match Code/client/Games/TES.cpp:7 -400443 pointer s_singleton curated-weak 0x141f850e8 0x1f850e8 0x141ec0a60 addr-match SubtitleManager* GetSingleton() Code/client/Games/Misc/SubtitleManager.cpp:13 +400443 pointer s_singleton curated-weak 0x141f850e8 0x1f850e8 0x141ec0a60 addr-match SubtitleManager* GetSingleton() Code/client/Games/Misc/SubtitleManager.cpp:14 400447 pointer s_instance curated-verified 0x141f85108 0x1f85108 0x141ec0a80 addr-match RE::Calendar::Singleton Code/client/Games/TimeManager.cpp:6 400475 pointer s_instance curated-verified 0x141f889d8 0x1f889d8 0x141ec3b78 addr-match RE::SkyrimVM::Singleton Code/client/Games/Skyrim/Misc/GameVM.cpp:7 400508 pointer s_factories curated-weak 0x141f88b40 0x1f88b40 0x141ec3ce0 both RE::FormFactory::FormFactories Code/client/Games/FormFactory.cpp:10 @@ -3064,7 +3065,7 @@ aeid kind name confidence vr vr_offset sse_1_5_97 sse_route flags db_name site 400863 pointer s_instance curated-identical 0x142f8aaa0 0x2f8aaa0 0x142ec5bd0 addr-match RE::ControlMap::Singleton Code/client/Games/Skyrim/AI/Movement/PlayerControls.cpp:24 400864 pointer s_instance curated-verified 0x142f8aaa8 0x2f8aaa8 0x142ec5bd8 addr-match RE::PlayerControls::Singleton Code/client/Games/Skyrim/AI/Movement/PlayerControls.cpp:6 401069 pointer s_character auto-diff 0x142fc46a8 0x2fc46a8 0x142eff7d8 addr-match Code/client/Games/Skyrim/PlayerCharacter.cpp:48 -401099 pointer s_singleton curated-verified 0x142fc4878 0x2fc4878 0x142eff988 both MenuTopicManager* GetSingleton() Code/client/Games/Misc/MenuTopicManager.cpp:10 +401099 pointer s_singleton curated-verified 0x142fc4878 0x2fc4878 0x142eff988 both MenuTopicManager* GetSingleton() Code/client/Games/Misc/MenuTopicManager.cpp:11 401100 pointer qword_142EFF990 auto-diff 0x142fc4880 0x2fc4880 0x142eff990 addr-match Code/client/Games/Skyrim/AnimationExperiments.cpp:341 401263 pointer s_instance curated-weak 0x142fc52e8 0x2fc52e8 0x142f003f8 addr-match RE::MenuControls::Singleton Code/client/Games/Skyrim/Interface/MenuControls.cpp:11 403330 pointer s_singleton curated-identical 0x142feb200 0x2feb200 0x142f266f8 addr-match BGSSaveLoadGame* GetSingleton() Code/client/Games/Forms.cpp:117