Skip to content

Sometimes naked npc fix is aggressively duplicating inventories with outfit items #5

Open

Description

@absol89

Skyrim together 1.8.1

Does not happen all the time, and some encounters duplicate more than once even if just 2 player enter vincinity.

Image

Test area: Go with 2 players to the lord stone, near coc MehrunesDagonShrine

Activity

  1. absol89 commented on Aug 22, 2026

    @absol89
    OwnerAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions