From 1fb6c6fbe174186a6a327eb4a874c1f00d443d01 Mon Sep 17 00:00:00 2001 From: Sergio Rayo Date: Sun, 13 Sep 2026 13:45:04 +0200 Subject: [PATCH 1/2] Exclude faction jail containers from object sync (#700) When a player is sent to jail, the game moves their whole inventory into the faction's prisoner belongings container and stolen goods into its evidence chest. Both were synced like any other container, so the first player to enter the jail cell registered their stash on the server and the next player's local chest was overwritten with it; on release they walked out with someone else's inventory. Read the player inventory and stolen goods containers from every loaded faction's crime data and exclude them from object sync, so each client keeps its own copy. This replaces the need for per-chest hard-coded exclusions and also covers jails added by mods. The two quest chests already excluded by form ID stay as they are, since they are quest-owned rather than faction containers. Verified: client builds, TPTests pass. In-game check with two arrested party members pending. --- Code/client/Games/Skyrim/Forms/TESFaction.h | 36 ++++++++++++++++ Code/client/Games/TES.h | 8 +++- .../client/Services/Generic/ObjectService.cpp | 41 ++++++++++++++++++- 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/Code/client/Games/Skyrim/Forms/TESFaction.h b/Code/client/Games/Skyrim/Forms/TESFaction.h index b54126435..9e6f308e8 100644 --- a/Code/client/Games/Skyrim/Forms/TESFaction.h +++ b/Code/client/Games/Skyrim/Forms/TESFaction.h @@ -3,7 +3,43 @@ #include #include +struct TESObjectREFR; +struct BGSListForm; +struct BGSOutfit; + +struct TESReactionForm : BaseFormComponent +{ + void* reactionListHead; // BSSimpleList + void* reactionListNext; + uint8_t groupFormType; + uint8_t pad19[7]; +}; + +static_assert(sizeof(TESReactionForm) == 0x20); + struct TESFaction : TESForm { + // Where the game sends the player's belongings when they are arrested by this faction. + struct CrimeData + { + TESObjectREFR* jailMarker; // JAIL + TESObjectREFR* waitMarker; // WAIT + TESObjectREFR* stolenGoodsContainer; // STOL: stolen items taken on arrest + TESObjectREFR* playerInventoryContainer; // PLCN: the rest of the player's inventory, returned on release + BGSListForm* crimeGroup; // CRGR + BGSOutfit* jailOutfit; // JOUT + uint8_t crimeValues[0x18]; // CRVA + }; + TESFullName fullname; + TESReactionForm reactionForm; + uint8_t crimeGoldMap[0x30]; // BSTHashMap + uint32_t factionFlags; // DATA + uint32_t pad84; + CrimeData crimeData; }; + +static_assert(offsetof(TESFaction, fullname) == 0x20); +static_assert(offsetof(TESFaction, crimeData) == 0x88); +static_assert(offsetof(TESFaction, crimeData.stolenGoodsContainer) == 0x98); +static_assert(offsetof(TESFaction, crimeData.playerInventoryContainer) == 0xA0); diff --git a/Code/client/Games/TES.h b/Code/client/Games/TES.h index 5ee1804ea..8c19d3a5c 100644 --- a/Code/client/Games/TES.h +++ b/Code/client/Games/TES.h @@ -5,6 +5,7 @@ struct TESObjectCELL; struct TESWorldSpace; struct NiPoint3; struct TESForm; +struct TESFaction; struct Actor; struct ImageSpaceModifierInstance; @@ -109,12 +110,17 @@ struct ModManager Mod* GetByName(const char* acpName) const noexcept; TESObjectCELL* GetCellFromCoordinates(int32_t aX, int32_t aY, TESWorldSpace* aWorldSpace, bool aSpawnCell) noexcept; - uint8_t pad0[0x748]; + // Form arrays start at 0x10 and are indexed by FormType, 0x18 bytes each. + uint8_t pad0[0x118]; + GameArray factions; + uint8_t pad130[0x748 - 0x130]; GameArray quests; uint8_t pad760[0xD60 - 0x760]; GameList mods; }; +static_assert(offsetof(ModManager, factions) == 0x118); +static_assert(offsetof(ModManager, quests) == 0x748); static_assert(offsetof(ModManager, mods) == 0xD60); struct Setting diff --git a/Code/client/Services/Generic/ObjectService.cpp b/Code/client/Services/Generic/ObjectService.cpp index 188dde477..d9eb19991 100644 --- a/Code/client/Services/Generic/ObjectService.cpp +++ b/Code/client/Services/Generic/ObjectService.cpp @@ -21,6 +21,8 @@ #include #include #include +#include +#include #include @@ -60,11 +62,44 @@ bool IsPlayerHome(const TESObjectCELL* pCell) noexcept return false; } -bool ShouldSyncObject(const TESObjectREFR* apObject) noexcept +// Containers where the game stashes an arrested player's belongings, taken from the crime data of every +// loaded faction. Sending a player to jail moves their whole inventory into the faction's player inventory +// container (stolen goods go to the stolen goods container) and hands it back on release, so these are +// per-player storage: if they were synced, the first player to enter the jail cell would overwrite the +// stash of everyone who follows, and the others would walk out with the wrong inventory (#700). +// Only pointers are compared here; the crime data itself is never dereferenced. +Set GetPlayerStashContainers() noexcept +{ + Set containers{}; + + ModManager* pModManager = ModManager::Get(); + if (!pModManager) + return containers; + + for (const TESFaction* pFaction : pModManager->factions) + { + if (!pFaction) + continue; + + if (pFaction->crimeData.playerInventoryContainer) + containers.insert(pFaction->crimeData.playerInventoryContainer); + + if (pFaction->crimeData.stolenGoodsContainer) + containers.insert(pFaction->crimeData.stolenGoodsContainer); + } + + return containers; +} + +bool ShouldSyncObject(const TESObjectREFR* apObject, const Set& acPlayerStashContainers) noexcept { if (!apObject) return false; + if (acPlayerStashContainers.contains(apObject)) + return false; + + // Quest chests that take the player's whole inventory without going through faction crime data. switch (apObject->formID) { case 0x39CF1: // Don't sync the chest in the "Diplomatic Immunity" quest @@ -117,9 +152,11 @@ void ObjectService::OnCellChange(const CellChangeEvent& acEvent) noexcept AssignObjectsRequest request{}; + const Set playerStashContainers = GetPlayerStashContainers(); + for (TESObjectREFR* pObject : objects) { - if (!ShouldSyncObject(pObject)) + if (!ShouldSyncObject(pObject, playerStashContainers)) { spdlog::warn("Excluding sync for {:X}", pObject->formID); continue; From 0b29310c79308a936f830220099789ce5cdf0263 Mon Sep 17 00:00:00 2001 From: Daniil Zakharov Date: Sat, 19 Sep 2026 22:37:13 +0300 Subject: [PATCH 2/2] Fix `TESFaction.h` layout, shorten the comment --- Code/client/Games/Skyrim/Forms/TESFaction.h | 32 +++++++++++++------ .../client/Services/Generic/ObjectService.cpp | 9 ++---- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/Code/client/Games/Skyrim/Forms/TESFaction.h b/Code/client/Games/Skyrim/Forms/TESFaction.h index 9e6f308e8..6f5d3f777 100644 --- a/Code/client/Games/Skyrim/Forms/TESFaction.h +++ b/Code/client/Games/Skyrim/Forms/TESFaction.h @@ -2,20 +2,26 @@ #include #include +#include +#include struct TESObjectREFR; struct BGSListForm; struct BGSOutfit; +struct TESNPC; struct TESReactionForm : BaseFormComponent { - void* reactionListHead; // BSSimpleList - void* reactionListNext; + GameValueList reactions; uint8_t groupFormType; - uint8_t pad19[7]; + uint8_t pad19; + uint16_t pad1A; + uint32_t pad1C; }; static_assert(sizeof(TESReactionForm) == 0x20); +static_assert(offsetof(TESReactionForm, reactions) == 0x08); +static_assert(offsetof(TESReactionForm, groupFormType) == 0x18); struct TESFaction : TESForm { @@ -28,18 +34,26 @@ struct TESFaction : TESForm TESObjectREFR* playerInventoryContainer; // PLCN: the rest of the player's inventory, returned on release BGSListForm* crimeGroup; // CRGR BGSOutfit* jailOutfit; // JOUT - uint8_t crimeValues[0x18]; // CRVA + uint8_t crimeValues[0x14]; // CRVA + uint32_t pad44; }; TESFullName fullname; TESReactionForm reactionForm; - uint8_t crimeGoldMap[0x30]; // BSTHashMap + creation::BSTHashMap* crimeGoldMap; uint32_t factionFlags; // DATA - uint32_t pad84; + uint32_t pad5C; CrimeData crimeData; + // Remaining vendor data, ranks, crime counts, and timestamps are not accessed here. + uint8_t padA8[0x100 - 0xA8]; }; +static_assert(sizeof(TESFaction::CrimeData) == 0x48); +static_assert(sizeof(TESFaction) == 0x100); static_assert(offsetof(TESFaction, fullname) == 0x20); -static_assert(offsetof(TESFaction, crimeData) == 0x88); -static_assert(offsetof(TESFaction, crimeData.stolenGoodsContainer) == 0x98); -static_assert(offsetof(TESFaction, crimeData.playerInventoryContainer) == 0xA0); +static_assert(offsetof(TESFaction, reactionForm) == 0x30); +static_assert(offsetof(TESFaction, crimeGoldMap) == 0x50); +static_assert(offsetof(TESFaction, factionFlags) == 0x58); +static_assert(offsetof(TESFaction, crimeData) == 0x60); +static_assert(offsetof(TESFaction, crimeData.stolenGoodsContainer) == 0x70); +static_assert(offsetof(TESFaction, crimeData.playerInventoryContainer) == 0x78); diff --git a/Code/client/Services/Generic/ObjectService.cpp b/Code/client/Services/Generic/ObjectService.cpp index d9eb19991..b2140e2ed 100644 --- a/Code/client/Services/Generic/ObjectService.cpp +++ b/Code/client/Services/Generic/ObjectService.cpp @@ -62,12 +62,9 @@ bool IsPlayerHome(const TESObjectCELL* pCell) noexcept return false; } -// Containers where the game stashes an arrested player's belongings, taken from the crime data of every -// loaded faction. Sending a player to jail moves their whole inventory into the faction's player inventory -// container (stolen goods go to the stolen goods container) and hands it back on release, so these are -// per-player storage: if they were synced, the first player to enter the jail cell would overwrite the -// stash of everyone who follows, and the others would walk out with the wrong inventory (#700). -// Only pointers are compared here; the crime data itself is never dereferenced. +// Find each loaded faction's containers for a jailed player's belongings and stolen items. +// Do not sync them: each player's items must stay separate (#700). +// Only compare the container pointers; never read through them. Set GetPlayerStashContainers() noexcept { Set containers{};