You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Sometimes naked npc fix is aggressively duplicating inventories with outfit items #5
How the system works today
When two players get near an NPC, ownership flits between clients. Every flip runs three naked-fix paths:
TakeOwnership / assign handler → OwnerSelfHealDress() (CharacterService.cpp:187) — if the biped lacks a body piece, re-runs SetActorInventory(current) and schedules up to 2 retry passes + a possible 3D-rebuild escalation (ApplyCachedSelfHeals, line 2130).
GetActorInventory() fallback (Actor.cpp:623) — empty worn set → DeriveOutfitInventory() substitutes the base/template outfit.
Claim packet — BuildActorData() → GetActorInventory() becomes the server's authoritative Content, broadcast to everyone.
Root cause #1: DeriveOutfitInventory pushes the ENTIRE leveled list
In Actor.cpp (~line 715), for a LeveledItem outfit entry it iterates the whole list and pushes every armor as a separate worn entry:
for (int32_t i = 0; i < count; ++i)
if (TESObjectARMO* pArmor = Cast(pList[i].pForm))
armors.push_back(pArmor); // ALL of them
...
for (TESObjectARMO* pArmor : armors)
... fallback.Entries.push_back(...); // Count 1, ExtraWorn each
A bandit outfit rolls one cuirass from LItemClothes..., but the derived fallback contains all candidates. Once applied, the NPC's container legitimately holds 5–10 cuirasses. This exactly matches the screenshot (stacked outfit items) and "duplicates even with just 2 players": each ownership flip that hits the naked-fallback path re-derives and re-commits this inflated set as authoritative Content.
Root cause #2: server Content never merges duplicates
InventoryService::OnEquipmentChanges (server, line 56) calls Content.UpdateEquipment(...), which only toggles worn flags — it never merges same-BaseId entries. Two cuirass entries survive forever, and both can end up flagged worn.
Nothing else dedups Content; AddOrRemoveEntry merging only covers incremental add/remove packets.
So once one bad derive lands on the server, every later snapshot propagates the pile, and each new ownership fight can add another layer (old rolled gear + newly derived outfit coexist — "without deleting the item they are replacing").
Root cause #3: self-heal retries don't re-check locality
ApplyCachedSelfHeals (line 2130) and the immediate OwnerSelfHealDress calls never verify the actor is still local when the timer fires. During fast A↔B ownership ping-pong, a stale retry on a now-remote actor runs SetActorInventory → AddOrRemoveItem equips each worn entry (remote branch) and fires unsuppressed EquipmentChangeEvents, racing the incoming snapshot apply on the other client — the window where "some encounters duplicate more than once".
Proposed fix (minimal, targeted)
DeriveOutfitInventory: resolve each leveled entry to exactly one armor (deterministic pick, e.g. first entry, or pList[formID % count] for stable variety) instead of the whole list.
Server normalization: in ApplyActorData / UpdateEquipment, merge Content entries by BaseId (sum counts for non-worn, collapse to single copy for worn) so duplicates can't persist authoritatively.
Locality guard: ApplyCachedSelfHeals + the two inline OwnerSelfHealDress call sites skip if pActor->GetExtension()->IsRemote().
Optional belt-and-braces: extend the BaseId-replace dedup already present in SetActorInventory's substitution branch so a derived entry replaces a non-derived same-BaseId entry rather than coexisting.
Activity
absol89 commentedon Aug 22, 2026
How the system works today
When two players get near an NPC, ownership flits between clients. Every flip runs three naked-fix paths:
TakeOwnership / assign handler → OwnerSelfHealDress() (CharacterService.cpp:187) — if the biped lacks a body piece, re-runs SetActorInventory(current) and schedules up to 2 retry passes + a possible 3D-rebuild escalation (ApplyCachedSelfHeals, line 2130).
GetActorInventory() fallback (Actor.cpp:623) — empty worn set → DeriveOutfitInventory() substitutes the base/template outfit.
Claim packet — BuildActorData() → GetActorInventory() becomes the server's authoritative Content, broadcast to everyone.
Root cause #1: DeriveOutfitInventory pushes the ENTIRE leveled list
In Actor.cpp (~line 715), for a LeveledItem outfit entry it iterates the whole list and pushes every armor as a separate worn entry:
for (int32_t i = 0; i < count; ++i)
if (TESObjectARMO* pArmor = Cast(pList[i].pForm))
armors.push_back(pArmor); // ALL of them
...
for (TESObjectARMO* pArmor : armors)
... fallback.Entries.push_back(...); // Count 1, ExtraWorn each
A bandit outfit rolls one cuirass from LItemClothes..., but the derived fallback contains all candidates. Once applied, the NPC's container legitimately holds 5–10 cuirasses. This exactly matches the screenshot (stacked outfit items) and "duplicates even with just 2 players": each ownership flip that hits the naked-fallback path re-derives and re-commits this inflated set as authoritative Content.
Root cause #2: server Content never merges duplicates
InventoryService::OnEquipmentChanges (server, line 56) calls Content.UpdateEquipment(...), which only toggles worn flags — it never merges same-BaseId entries. Two cuirass entries survive forever, and both can end up flagged worn.
Nothing else dedups Content; AddOrRemoveEntry merging only covers incremental add/remove packets.
So once one bad derive lands on the server, every later snapshot propagates the pile, and each new ownership fight can add another layer (old rolled gear + newly derived outfit coexist — "without deleting the item they are replacing").
Root cause #3: self-heal retries don't re-check locality
ApplyCachedSelfHeals (line 2130) and the immediate OwnerSelfHealDress calls never verify the actor is still local when the timer fires. During fast A↔B ownership ping-pong, a stale retry on a now-remote actor runs SetActorInventory → AddOrRemoveItem equips each worn entry (remote branch) and fires unsuppressed EquipmentChangeEvents, racing the incoming snapshot apply on the other client — the window where "some encounters duplicate more than once".
Proposed fix (minimal, targeted)
DeriveOutfitInventory: resolve each leveled entry to exactly one armor (deterministic pick, e.g. first entry, or pList[formID % count] for stable variety) instead of the whole list.
Server normalization: in ApplyActorData / UpdateEquipment, merge Content entries by BaseId (sum counts for non-worn, collapse to single copy for worn) so duplicates can't persist authoritatively.
Locality guard: ApplyCachedSelfHeals + the two inline OwnerSelfHealDress call sites skip if pActor->GetExtension()->IsRemote().
Optional belt-and-braces: extend the BaseId-replace dedup already present in SetActorInventory's substitution branch so a derived entry replaces a non-derived same-BaseId entry rather than coexisting.
NakedFix: derive one armor per leveled outfit entry (issue #5)
NakedFix: normalize duplicate BaseId entries in server Content (issue #5
NakedFix: guard self-heal locality and dedupe polluted containers (is…