Skip to content

Fix/improved naked npc fixes for NPC and Players - #880

Open
absol89 wants to merge 26 commits into
tiltedphoques:devfrom
absol89:fix/Improved-naked-npc-fix
Open

Fix/improved naked npc fixes for NPC and Players#880
absol89 wants to merge 26 commits into
tiltedphoques:devfrom
absol89:fix/Improved-naked-npc-fix

Conversation

@absol89

@absol89 absol89 commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Long story short, rfortier discovered that the original RunNakedNPCBugChecks() didn't solve the issue. Checked every 3s.

What I discovered when testing the PR code, was that there were still cases where NPC were naked during ownership flip.

On some players, this ownership flip left local characters in a RemoveAllItems state, as inventory or equip calls sent empty.

What my commits since then have been trying to do is the following:

  1. Make sure empty garbage equip/inventory states are not allowed to propagate, and not allowed to RemoveAllItems on ai.
  2. Make sure local NPC actors do not send too much networking data when there's fighting for ownership. Tested in modlist.
  3. Clean out old diagnostics and remove the RunNakedNPCBugChecks() code. Newer code runs on spawn + ownership flips.
  4. Previously, there were reports that pickpocketing equipped garments from NPC would reset them back to a normal outfit.
  5. I checked this after making all these commits, and verified that new code lets Party leader stealing worn items sync online.
  6. For the final issue, players who wore no body armor or body garment, had invisible equipped hands and feet on remotes.
  7. To fix this, i looked up a garment that shows an undressed torso, but counts as a body torso armor, so all gear show again.

When comparing before and after absol89@ed7ed1e , the previous commit's code isRemote() revealed NPC with bad worn state. That gave them the vampire lord's armor as proof that this was what was going on. Now, I changed the final commit to only give isRemotePlayer() the vampire lord armor to make sure their other limbs and helmets would render when they travel shirtless. Hopefully this fixes 3 issues in one swoop, and I am submitting it as a separate PR than 860 so there's possibility to review separately.

Video of npc not being naked after fixes. You can watch it without sound, it is just a 11 minute stress test https://www.youtube.com/watch?v=mCgSCQRPPiQ

Video of pickpocket sync working (leader did it). Watch with sound https://www.youtube.com/watch?v=tFoMUSwnAZo

Picture of an NPC being fully equipless by player pickpocket. It was important that this didn't trigger outfit reset, seems ok.
image

Picture of how player's look when not wearing torso item, and wearing helmet, gloves and boots on vanilla STR 1.8.0 (live).
image

Video of fix to limb rendering when player is not wearing torso items (gets armor 02011a85). https://youtu.be/EfgXv9WK8fE

rfortier and others added 20 commits July 9, 2026 17:49
Original naked NPC fix worked around the "fighting over NPC ownership" issues in STR by checking periodically to see if NPCs were naked and redressing them if they were. It didn't really address root cause, and introduced some new bugs.

The changes here:
- Are constrained by the lack of solid reproducers. Simulations only get you so far.
- Address all the root-cause issues that could be found / inferred in reasonable time. Difficult to say if it gets all (or enough) of  them without solid reproducers.
- Fixes the new bugs introduced by the first version.
- Keeps a last-resort timeout to redress NPCs in case this still doesn't fix all the root causes.

Root cause:
- Common wisdom was the Ownership code was fighting over NPCs. Leader and Member jump into a cell together and each try to set up the NPCs, broadcasting conflicting changes to  the other.
- This turned out to be true in many forms. First, when an Actor is constructed, shortly after creation the engine fires a bunch of EquipmentChangeEvents as the engine dresses the actor. Some to all of these these could be dropped because ServerIds for the actors or equipment aren't available yet. But, it is a real problem if some of them ARE broadcast once server IDs are allocated. Equipping an item the sender has but the receiver doesn't can end up unequipping. Even worse if inventory changes are sent.
- The new code blocks sending updates until WaitingFor3D ends (according to CK, accessing the default outfit before the Actor is fully constructed is undefined). And, it blocks sending updates while WaitingForAssignment. This is the KEY fix, since at the end of WaitingForAssignment, we know who should win, and the winner has sends a full Inventory and an full Equipment list. Applying that (by the non-owners) should fully dress the NPC. At least as long as the Owner has dressed the NPC.
- As a last resort/backstop, a "naked deadline" trips on the Actor. If this happens where the actor IsLocal(), and the actor is not dressed, then they will equip the default outfit (if there is one). These changes are synced.
- That last is sort of what the original fix did, but improved. This redressing is non-destructive.

Bugs fixed in Vanilla:
- Blocks the sending of potentially destructive equipment and  possibly inventory changes while debating Ownership. This may or may not be a complete fix.

Bugs fixed in oriignal Naked fix:
- The original code just tried to dress NPCs once every second. Note, that was not "after a second", but between 0 and 1 seconds after creation. I can't prove it, but this firing too soon, and in particular before WaitingFor3D leads to undefined behavior and is probably one of the reasons for lingering nakedness, and syncing broken equip states (and inventory?). This was replaced with a per-Actor timeout that works out to 2-3 seconds. It is reset at the end of WaitingFor3D and again at the end of Ownership change.
- Replaces the forced-redress by fully resetting inventory with simply equipping the default outfit; no changes to inventory. This is critical for followers that may have accummulated a lot of loot, but applies to any NPC that has loot.
- Does not try to recreate the default outfit inventory if you have removed it from inventory. This is probably what you wanted anyway.
- Only checks for redressing once per ownership change of the Actor, rather than rechecking every second. So you can change what the actor is wearing and it will stay that way as long as you stay in the cell (and the Leader doesn't enter and take over)
Root cause: the NPC's authoritative inventory (InventoryComponent::Content) is
wholesale-replaced from a client's GetActorInventory() snapshot on ownership
transfer (ApplyActorData). During the transient undressed window after a cell
reload (before the engine re-dresses the NPC), the owner's snapshot lacks the
body piece, so Content becomes body-piece-less and propagates nakedness to every
client via InitialInventory. The same overwrite also reset NPCs whose equipment
was legitimately removed.

Fix: detect a destructive fully-naked overwrite (incoming snapshot has no worn
entries while the authoritative Content still has worn items, and the NPC is
alive) and ignore it rather than applying it. A later real inventory interaction
(server InventoryService incremental change for that entity) sends correct data,
so there is no need to mix in the stale naked snapshot.

The only legitimate fully-empty states are: a player steals all items from a
living NPC (pickpocket - syncs incrementally and empties Content first) or loots
all items from a dead NPC. A dead NPC's naked overwrite is therefore allowed
through, as is any non-naked overwrite.

- Inventory: add IsFullyNaked()/HasWornItems() helpers (no game-specific slot knowledge).
- CharacterService::ApplyActorData: ignore the destructive alive-NPC overwrite instead of applying it.

Pickpocket removals still flow through the incremental AddOrRemoveEntry/UpdateEquipment
path and sync normally; only the racing naked overwrite on a living NPC is suppressed.
…hoques#860

Drops the per-Actor 'naked deadline' backstop machinery (RunNakedNPCBugChecks
redress + SetNakedDeadline/nakedDeadline + the now-unused EquipOutfit,
SetOutfit, IsWearingBodyPiece, ShouldWearBodyPiece helpers and BGSOutfit
includes). This is the 'last resort' client band-aid that cannot hold against
server authority: any client re-equip of the body piece is reconciled away by the
server, whose authoritative inventory omits the body piece on NPC reload. The
essential root-cause fix in PR tiltedphoques#860 - blocking equipment/inventory sends while
WaitingForAssignment - is retained.
Root cause of the reload-naked-NPC bug: on ownership transfer (and player spawn)
the client forwards its live GetActorInventory() snapshot to the server, which
becomes the authoritative InventoryComponent::Content and is broadcast to every
client. During the transient window right after a cell reload - before the engine
re-dresses the NPC - that snapshot reports the actor as wearing nothing. The
server stored the naked snapshot (both at entity emplace and at ApplyActorData
overwrite) and propagated it permanently; resetinventory fixed it by forcing a
correct re-sync.

The server cannot derive a dressed inventory, so the owner client - which has
engine access - must send correct data instead. GetActorInventory() now falls
back to the NPC's default outfit (baseForm->outfits[0]->outfitItems) when the
live snapshot is fully naked for a living, non-player actor. The fallback is only
used when it actually yields worn items, so it never invents gear for a
legitimately empty inventory.

This is the real root-cause fix, replacing PR tiltedphoques#860's client-side polling/redress
band-aid (which could not hold against server authority). The server-side guard
in ApplyActorData remains as a safety net against later destructive naked
overwrites of an already-dressed NPC.
- GetActorInventory fallback now resolves outfits[0] and outfits[1] (guards
  often define only outfits[1]), and iterates every entry of a leveled item
  list (count stored 8 bytes before pLeveledListA per TESLeveledList layout)
  instead of just the first.
- SetActorInventory now bails out of SetInventory()/RemoveAllItems() when the
  incoming snapshot has no worn items but the actor is currently dressed: there
  is nothing to unequip, so forcing it through only strips a correctly-dressed
  NPC. Dead NPCs and legitimately empty (pickpocket/stripped) snapshots are
  unaffected.
- Add [NakedFix] diagnostic logging to the fallback path so the next build can
  confirm which outfit slot / leveled entry resolves for each broken NPC.
…eq-class fix)

The naked NPC each run is whichever actor receives a naked server snapshot during an
ownership flip / leveled spawn - it's a race class, not tied to any NPC type. The old
guard only skipped the strip when the actor was ALREADY dressed, so a leveled/temp-base
spawn that was never dressed yet still got stripped/left naked.

- Extract DeriveOutfitInventory() helper (outfits[0]/[1] + leveled list A), shared by
  GetActorInventory (owner-upload side) and SetActorInventory (apply side).
- GetActorInventory: use the helper so a local owner never uploads a naked snapshot.
- SetActorInventory: when an alive non-player NPC receives a fully-naked snapshot,
  substitute the locally-derived outfit (supplemented onto any non-worn inventory the
  server sent) instead of applying the empty one. Dead NPCs and incremental
  pickpocket/loot removals (which send non-empty worn entries) are unaffected.
- Keep [NakedFix] diagnostics for the next repro.
…iningQuestItems

Passing a temporary as the non-const Inventory& arg failed to compile. Capture it
in a local currentInventory, matching the existing normal-path call.
PART 1+2 fixed the networked inventory (members see dressed) and PART 3 fixed remote
clients receiving a snapshot, but the OWNER's locally-spawned NPC stayed naked: the owner
never receives its own snapshot to trigger SetActorInventory, and the owner block in
OnAssignCharacter never applied inventory at all.

When a locally-owned NPC is assigned and currently renders with no worn items, derive its
outfit from the base form and apply it to the owner's own actor. Reuses DeriveOutfitInventory()
and the now-safe SetActorInventory (which applies normally when the incoming snapshot has
worn items). Diagnosed via Aela the Huntress (1A697/1A696): leader rendered naked while the
member (fed by PART 1+2's corrected upload) was dressed.
Apply the derived-outfit dress on the assignment frame, then queue a single deferred
re-check on the next tick. If an engine hiccup overwrote the same-tick apply (actor 3D
not yet settled at assignment), the re-check re-dresses once. Extracted into a file-local
OwnerSelfHealDress() helper (idempotent: dressed NPCs are a no-op, so it never fights
legit gameplay). No periodic scan — single re-check only.
…erwrite

The single deferred re-check at assignment fires too early: the engine applies its own
(naked) visual equipment to a locally-owned NPC several frames later, after the 3D loads
(Olava the Feeble 1A699/13BAE reproved this - assigned dressed, no further events, yet
rendered naked on the leader). Add a ~1s periodic re-check in RunLocalUpdates that
re-dresses any locally-owned NPC still rendering with no worn items. Idempotent (dressed
= no-op, never fights gameplay) and throttled so cost is negligible. Periodic path logs
only actual dresses (aVerbose=false) to avoid spam for NPCs with no base outfit (e.g. Cow).
…ngine 3D-load undress)

The naked-NPC race is the engine's async equip pass: when a locally-owned NPC's 3D
finishes loading, Skyrim calls TESObjectREFR::SetInventory, which does RemoveAllItems()
first. If the engine-side inventory is empty/incomplete at that moment, the NPC is stripped
-- even though it was assigned dressed (Olava the Feeble 1A699/13BAE reproved this: assigned
dressed, no network events, yet rendered naked).

Guard TESObjectREFR::SetInventory: if the target is a living non-player actor and the
incoming inventory has no worn items, skip the RemoveAllItems()+reapply. A fully-naked
overwrite of a living NPC is never legitimate -- dead-NPC loot clears and incremental
pickpocket both send non-empty worn entries, and the player is excluded. This stops the
strip at the source instead of redressing after the fact.

Keep the periodic owner self-heal as a backstop, throttled to ~3s (original band-aid
periodicity).
The periodic re-dress scan was spinning every 3s on guards (temp and non-temp) because the
engine strips living NPCs via EquipManager::UnequipAll (engine native 38899), NOT
SetInventory -- so the SetInventory guard never fired (confirmed: 0 skips in log). The
engine's 3D-load equip pass does UnequipAll then re-equips from the NPC's empty engine-side
equipment, leaving guards naked/partial.

- Guard EquipManager::UnequipAll: skip the engine full-unequip when the target is a living
  non-player NPC that currently has worn items. This blocks the strip at the source and
  kills the re-strip/re-dress loop. Dead (loot) and player unequips still pass through.
- Demote the GetActorInventory fallback diagnostic logs (fallback path / outfit detail /
  no wearable outfit derived) from info to debug -- they flooded the log every 3s per NPC
  via movement-sync and the periodic scan. Actionable logs (substituting, SetInventory
  skip, owner self-heal dress) stay at info.

Periodic owner self-heal retained as a backstop.
Owner self-heal left templated (FF-prefixed leveled-character) NPCs naked
on the owner's own screen: DeriveOutfitInventory read outfits[] off the leaf
base, which is null for template spawns. Fall back to GetTemplateBase() when
the leaf defines no outfit. Owner-only; remote clients already dress from the
network snapshot.
The owner-side naked-NPC render is a desync: the logical inventory container
reports items as worn (ExtraWorn flag set) while the engine never equipped them
onto the actor's biped, so the render is naked. The self-heal was gated on
HasWornItems() (container flags) at both the call sites and inside the helper,
so it saw 'dressed' and never ran -- no NakedFix log appeared for the affected
actor at all.

Fix:
- Add Actor::ForceEquipWornArmor(): re-issues EquipManager::Equip(force=true)
  for every container item flagged worn, applying it to the biped. Reads raw
  GetArmor() (not GetActorInventory, which masks empty state by deriving).
- OwnerSelfHealDress now uses the honest GetWornArmor() signal: if worn armor
  exists, force re-equip; only if none exists, derive+apply a base outfit.
- Remove the faulty HasWornItems() pre-gate at the OnAssignCharacter call site.
- Also run the self-heal on ownership change (TakeOwnership), per repro where
  the Wayfarer flips ownership repeatedly.
- Drop the ~3s periodic RunLocalUpdates naked scan: assignment + ownership
  change now cover it, saving per-frame cost.
…load guard)

The forced re-equip crashed on the deferred re-check for a dynamic FE actor
(summon) whose 3D/process wasn't settled, and spammed OnEquipmentChangeEvent
network sends because the Equip calls ran outside ScopedEquipOverride.

- Wrap the loop in ScopedEquipOverride: matches every other internal engine
  equip in the codebase; suppresses the sync-event trigger and equip-hook
  re-entrancy that caused the crash.
- Bail early unless GetNiNode() && currentProcess are present, so we never
  force-equip onto an actor that isn't fully loaded.
… crash)

The previous ForceEquipWornArmor approach crashed: it called EquipManager::Equip
for every owned NPC on connect, from the network thread at assignment AND again
from a deferred main-thread re-check. Engine equip must run on the main thread
and only the re-check reached a loaded actor -- crashing on the FIRST NPC's
re-check (not the last). It also passed hand equip slots for body armor and
spammed OnEquipmentChangeEvent.

Replace all of it with the same path remotes already use safely:
- OwnerSelfHealDress now just reapplies the actor's own inventory via
  SetActorInventory (GetActorInventory already derives a base outfit for empty
  templated/leveled spawns; SetActorInventory has its own naked-strip guard).
- Runs synchronously in OnAssignCharacter (owner branch) and TakeOwnership,
  on the same thread the remote SetActorInventory runs on. No deferred
  cross-thread Queue, no bespoke engine equip.
- When an owned NPC is genuinely naked with no derivable outfit, log it instead
  of forcing an equip.
- Remove Actor::ForceEquipWornArmor entirely.
…ync)

Perf analysis of build 0c9d20c (5-min playbench): leader ran 106 self-heal
reapplies (69 unique actors), same NPC re-dressed up to 10x due to MP
ownership thrash (CancelServerAssignment -> respawn -> reclaim). Each reapply
called SetActorInventory (RemoveAllItems + full re-add + per-item Equip),
and for a LOCAL actor each per-item Equip fired OnEquipmentChangeEvent to the
server -- the leader broadcast ~1000 equipment-change packets in 5 min, almost
entirely self-inflicted. That is the CPU stutter source.

Fix 1 (network/broadcast storm): EquipHook already suppressed the sync for
REMOTE actors under ScopedEquipOverride; extend the same gate to LOCAL actors
so internal re-dress equips don't broadcast. Wrap the self-heal's
SetActorInventory call in ScopedEquipOverride. The engine equip still runs, so
the NPC is dressed locally; only the network sync is skipped (server already
has the inventory). Player/AI-driven equips stay synced (not under the override).
Vampire Lord cosmetic armor and debug-view equips are unaffected (not under the
override).

Fix 2 (CPU dedup): skip the heavy re-apply when the biped already reports worn
armor (honest GetEquipment() signal), so already-dressed NPCs from an ownership
re-claim no longer run RemoveAllItems + re-add + re-equip. Genuinely-naked NPCs
(biped empty) still go through and get fixed.

Net: self-heal now only re-dresses NPCs that are actually naked, and does so
without flooding the server.
Skyrim's nude body skin is never synced (SaveSkinFar is disabled in Actor::Serialize),
so a remote actor only receives a torso/legs/feet mesh if its inventory snapshot carries
a body-slot (cuirass) ARMO. A player wearing helmet+boots but no torso armor - or a fully
nude player - therefore renders with no body (and no feet, since feet are part of the
body-slot model) on every other client, regardless of which client crosses a cell border.
The bug is in how the remote representation is rebuilt purely from the synced inventory,
which cannot carry the nude body.

Fix: in TESObjectREFR::SetInventory, for remote actors, detect whether any worn entry is a
body-piece ARMO (TESObjectARMO::IsBodyPiece, slotType & 0x4). If none, inject a worn body
ARMO into the local working inventory so the remote client has a body model to render.
The server-side inventory is untouched (only the local working copy is mutated), and the
injection fires on every re-sync including cell transitions.

Stand-in body form is the Vampire Lord armor (02011A85, Dawnguard mod index 0x02) whose
body model renders the actor's nude body; to be replaced with a load-order-independent
nude body once identified.
The body-slot fallback injected the stand-in body ARMO for ANY remote actor, but the
Vampire Lord armor body does not fit NPC bodies and rendered them naked on the leader's
screen during playtest. The fix is meant for players (the reported bug: a player wearing
helmet+boots but no cuirass, or a nude player, shows no body/feet to others).

Gate the injection on IsRemotePlayer() instead of IsRemote() so NPCs are excluded. NPCs
normally carry a body via their base outfit or worn armor, and must not receive the fake
worn item.
@absol89

absol89 commented Jul 11, 2026

Copy link
Copy Markdown
Contributor Author

One-line summary for the merge

This branch fixes the root cause of locally-owned NPCs rendering naked (server applies a transient naked snapshot during ownership/cell-reload; engine strips dressed NPCs on 3D-load) by guarding those two overwrite paths and substituting a derived outfit for garbage-naked snapshots, backed by a quiet, event-driven, network-silent, CPU-deduped owner self-heal. It deletes the old per-frame RunNakedNPCBugChecks backstop.


Reviewer checklist / things to verify

  1. Server guard correctness. ApplyActorData ignores a naked overwrite only when Content.HasWornItems() && !IsDead. Confirm a legitimate pickpocket-to-empty still works (it goes through the incremental path, not here) and that a dead NPC loot still clears.
  2. EquipHook gate. The new && !ScopedEquipOverride::IsOverriden() applies to LOCAL actors. Confirm player/AI equips are not accidentally suppressed (they are not wrapped in the override) and that Vampire Lord cosmetic armor / debug-view equips still sync as before.
  3. DeriveOutfitInventory template walk (5c40ef7c). Only walks to the template base when the leaf has no outfits at all; verify this doesn't double-derive for NPCs that intentionally have empty outfits[0] but a valid outfits[1].
  4. Self-heal is main-thread only. 0c9d20ce removed the deferred cross-thread lambda. Confirm no remaining off-thread SetActorInventory/Equip call in the self-heal path.
  5. No orphaned references. RunNakedNPCBugChecks, EquipOutfit, IsWearingBodyPiece, ShouldWearBodyPiece, SetNakedDeadline, nakedDeadline, nakedLogged, SetOutfit are all removed from this branch's tree (verified: 0 matches). BGSOutfit.h is retained in Actor.cpp because DeriveOutfitInventory legitimately uses BGSOutfit*.
  6. Perf regression check. Confirm the leader no longer broadcasts a self-heal equipment storm (the dedup + ScopedEquipOverride gate). The 122 ownership cancels remain but should no longer cause re-dress churn.

PR Review Details: NakedFix (improved naked-NPC fix)

Branch: fix/Improved-naked-npc-fix (absol89/TiltedEvolutionScriptFixes)
Scope of this document: ONLY the commits/code on fix/Improved-naked-npc-fix. It does NOT cover diag/naked-npc-remote-skip or 1.9-validation; those are separate test branches. The older cosi bare RunNakedNPCBugChecks backstop is removed here (commit 2b6e44aa) and is absent from this branch's tree.
Why it exists: The previous fix (PR #860, commit f9909a23) periodically polled every NPC and re-dressed any that looked naked. That only treated the symptom, ran every frame/second on the whole actor set, and introduced new bugs (crashes, network spam). This branch instead fixes the root cause at the snapshot/authority boundary and keeps a quiet, event-driven safety net.


Problem statement

In Skyrim Together Reborn, NPCs that a player "owns" (nearest-client ownership) can render naked on the owner's own screen while looking dressed on every other player's screen.

Root cause: when ownership transfers or the owner reloads a cell, the NPC's authoritative inventory (InventoryComponent::Content) on the server is wholesale-replaced from the new owner's GetActorInventory() snapshot (CharacterService::ApplyActorData). During the transient undressed window after a cell reload — before the engine re-dresses the NPC — the owner's snapshot has no body piece, so Content becomes body-piece-less and that naked inventory propagates to every client. A second, independent source is the engine itself: when an NPC's 3D finishes loading, the engine issues a fully-naked SetInventory that can strip an already-dressed NPC.

Why the owner's screen specifically: remote clients dress NPCs from the spawn/assignment snapshot, but the owning client renders the NPC from its own (transiently naked) local state and relied on the ineffective periodic re-dress.


The fix (commit by commit, in merge order)

1. 28637c65 — Server: ignore destructive fully-naked NPC inventory overwrites (root cause, server side)

  • In CharacterService::ApplyActorData, the authoritative Content was unconditionally overwritten by the incoming snapshot. Now it detects a destructive fully-naked overwrite: incoming snapshot has no worn entries (IsFullyNaked()) while Content still has worn items, and the NPC is alive. In that case the overwrite is ignored rather than applied.
  • Legitimate fully-empty states are preserved: a dead NPC's naked overwrite is allowed (loot all), and pickpocket removals flow through the incremental AddOrRemoveEntry/UpdateEquipment path (which empties Content first), so they still sync. Only the racing naked overwrite on a living NPC is suppressed.
  • Adds Inventory::IsFullyNaked() / Inventory::HasWornItems() (game-slot-agnostic helpers) in Code/encoding/Structs/Inventory.{h,cpp}.

2. 2b6e44aa — Cleanup: remove unnecessary client naked-NPC backstop from PR #860

  • Deletes RunNakedNPCBugChecks() and its OnUpdate call site in InventoryService, plus the old IsWearingBodyPiece/ShouldWearBodyPiece/EquipOutfit/SetOutfit helpers and SetNakedDeadline/nakedDeadline state. This PR replaces that approach entirely.

3. 0b558f28 — Client: substitute derived outfit for garbage-naked NPC snapshots

  • Actor::SetActorInventory (client): if an incoming snapshot for a living, non-player NPC has no worn items, derive the NPC's default outfit via DeriveOutfitInventory() and substitute it instead of applying the naked snapshot. Non-worn contents sent by the server are kept (supplemented, not replaced). Properties/quest items handled via SetInventoryRetainingQuestItems.

4. b32ee0e2 / 003bc0a9 / d0ff2dbb — Derive default outfit when snapshot is fully naked

  • Actor::DeriveOutfitInventory() builds a dressed Inventory from outfits[0]/outfits[1], resolving LeveledItem list A entries to their ARMO forms. Actor.h declares it. PapyrusFunctions.cpp drops a now-unused include.

5. 5c40ef7c — Derive outfit from template base for FF-templated NPCs

  • DeriveOutfitInventory() walks to the template base when the leaf base has no outfits[0]/outfits[1]. Without this, templated/leveled spawns (FF-prefixed) had no derivable outfit and stayed naked on the owner's screen. (Cosmetic: several [NakedFix] logs demoted from info to debug in d0ff2dbb.)

6. 04f06393 — Root-cause fix: skip fully-naked SetInventory strip of living NPCs (engine 3D-load undress) (root cause, client side)

  • In TESObjectREFR::SetInventory, when a living, non-player NPC receives a fully-naked inventory, the destructive RemoveAllItems strip is skipped. This is the engine's own 3D-load undress that was stripping already-dressed NPCs. Dead NPCs and genuinely changed inventories still apply normally. Wrapped in ScopedInventoryOverride so internal re-dress is consistent.

7. ca0be5b0 / 9ec0526b / 4c8fb0cd / 0257de0b — Owner self-heal safety net (client)

  • CharacterService::OwnerSelfHealDress(): when an NPC becomes locally owned (and on 3D-apply / ownership reclaim), if its biped reports no worn armor, re-dress it from its own GetActorInventory() via SetActorInventory — the same proven REMOTE path, on the main thread, no deferred queue.
  • Throttled (~1s) re-check for the late engine naked-overwrite; dresses immediately and re-checks next frame.

8. 024be397 / bc369dfd / 0c9d20ce — Force re-equip worn armor + crash fixes + SetActorInventory redesign

  • An earlier ForceEquipWornArmor approach caused a cross-thread crash and a broadcast storm (it ran off the network thread and called EquipManager::Equip directly). Redesigned (0c9d20ce) to reuse the existing REMOTE SetActorInventory path instead — no bespoke equip, no deferred cross-thread lambda. bc369dfd added the missing ScopedEquipOverride + load guard that the earlier version lacked.

9. 689ec519 — Kill self-heal CPU/network storms (dedup + suppress equip sync) (perf, critical)

Measured on a 5-min playbench (build 0c9d20ce): the leader ran 106 self-heal reapplies (69 unique actors), re-dressing the same NPC up to 10× due to MP ownership thrash (CancelServerAssignment → respawn → reclaim). Each reapply did RemoveAllItems + full re-add + per-item Equip, and for a LOCAL actor each per-item Equip fired OnEquipmentChangeEvent — the leader broadcast ~1000 equipment-change packets in 5 min, almost entirely self-inflicted. That was the CPU stutter.

  • Fix 1 (broadcast storm): EquipHook already suppressed the sync for REMOTE actors under ScopedEquipOverride; extended the same gate to LOCAL actors (EquipManager.cpp) so internal re-dress equips don't broadcast. Wrapped the self-heal's SetActorInventory in ScopedEquipOverride. Engine equip still runs (NPC dressed locally); only the network sync is skipped (server already has the inventory). Player/AI-driven equips are NOT under the override, so genuine equip stays synced.
  • Fix 2 (CPU dedup): skip the heavy re-apply when GetEquipment() already reports worn armor — already-dressed NPCs from an ownership reclaim no longer run RemoveAllItems + re-add + re-equip. Genuinely-naked NPCs (biped empty) still go through and get fixed.

Net effect (verified in playbench logs)

Leader-client metrics before → after 689ec519 (same 10-min session, form-ID-agnostic assessment per project rules):

  • Self-heal reapplies: 106 → 0
  • Self-initiated sending equipment change packets: ~1047 → 0
  • Ownership cancels (CancelServerAssignment) unchanged at 122 — that is vanilla MP assignment thrash and is not something this branch removes; the consequence (nakedness) is now guarded at both server and client.
  • SkipNakedStrip guard (the 04f06393 3D-load undress skip) fired 59× protecting 13 distinct NPC bases from being stripped.
  • No code-level crashes attributable to this branch. (A separate SKSE crash observed was an MO2/usvfs environment fault, not this code.)

The naked-NPC symptom — local-owned NPC naked on owner screen, dressed elsewhere — is addressed by: server ignores the destructive naked overwrite (28637c65), client skips the engine 3D-load strip (04f06393), client substitutes a derived outfit for garbage-naked snapshots (0b558f28), and the quiet owner self-heal (ca0be5b0+) fixes any remaining local undress without spamming the network or CPU.


Files touched (vs origin/dev)

Code/client/Games/PapyrusFunctions.cpp            |   2 -
Code/client/Games/Skyrim/Actor.cpp                | 228 +++---
Code/client/Games/Skyrim/Actor.h                  |   8 +-
Code/client/Games/Skyrim/EquipManager.cpp         |  23 +-
Code/client/Games/Skyrim/TESObjectREFR.cpp        |  16 +
Code/client/Services/Generic/CharacterService.cpp |  96 +++-
Code/client/Services/Generic/InventoryService.cpp |  85 +---
Code/client/Services/InventoryService.h           |   1 -
Code/encoding/Structs/Inventory.cpp               |  24 +-
Code/encoding/Structs/Inventory.h                 |   6 +
Code/server/Services/CharacterService.cpp         |  14 +-
11 files changed, 382 insertions(+), 121 deletions(-)

absol89 added 4 commits July 12, 2026 00:18
The IsRemotePlayer() guard only wrapped the body-detection loop, not the injection. Every NPC
(wayfarers, laborers, etc.) skipped the detection loop, leaving hasBody false, so the ungated
'if (!hasBody)' injected the Vampire Lord body ARMO onto them. Move the injection inside the
IsRemotePlayer() guard so only remote players receive it.
Leveled/templated NPCs (road encounters like Knight/Dawnguard Sergeant) render naked on the
OWNER's screen: at ownership-claim time the biped/3D is not yet loaded, so the immediate
OwnerSelfHealDress re-equip no-ops. Remotes are unaffected (they dress from the snapshot after
3D is up).

Add a deferred retry mirroring the existing weapon-draw mechanism: enqueue the local actor into
m_selfHealRetries at claim/assign, then re-run OwnerSelfHealDress on the update tick in 2 passes
(~0.5s, ~1.5s). OwnerSelfHealDress already self-dedups on HasWornItems, so retries on an
already-dressed actor are cheap no-ops. Logs wornBefore/wornAfter per pass for verification.
New logs show static NPCs (Gerdur 13489, Frodnar 1348B, +others) render naked on the OWNER after
an ownership TRANSFER, not just at spawn. The transfer strips the biped but leaves the container's
worn flags set, so re-dressing is a diff no-op: SetInventory sees the items already flagged worn and
equips nothing. Retry log confirmed wornBefore=false wornAfter=false on both gentle passes despite
GetActorInventory().HasWornItems()==true.

Extend the deferred retry into a 3-stage machine: two gentle re-dress passes (covers the biped-not-
ready case), then if the biped is still naked while the container claims worn items, escalate to a
DisableImpl()/EnableImpl() 3D rebuild - the same render-rebuild primitive the leveled-conform path
uses. EnableImpl(false) keeps inventory intact and re-attaches worn armor to the fresh biped. Guarded
on loadedState so distant/unloaded actors aren't touched.
New log: a Courier (19015C71, base FF00152D) rendered naked on the OWNER while the self-heal reported
wornBefore=true wornAfter=true - so the guard short-circuited and never re-dressed or escalated. Cause:
HasWornItems() returns true for ANY worn item (satchel/boots/gloves), but 'naked' means the BODY/torso
slot is empty. An NPC wearing only accessories passes the generic check yet looks naked.

Add HasWornBodyPiece() (worn ARMO with slotType & 0x4, mirroring the remote body-ARMO injection path)
and use it for every self-heal guard: the OwnerSelfHealDress dedup, the retry wornBefore/After signals,
the 3D-rebuild re-enable check, and the escalation container check. Now partial-equip NPCs are treated
as naked and re-dressed / rebuilt.
@absol89

absol89 commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

client1leadertruedressed.txt

Adding a log here from the latest commit test 41e3954, as previously couriers that only had a backpack slot could evade the checks. Checking for a body slot armor garment now seems to keep all npc from riverwood to whiterun dressed.

absol89 added 2 commits July 12, 2026 16:28
…ave/anim OOB)

The self-heal 3D-rebuild escalation calls EnableImpl() to rebuild the biped but left the actor's stale
GraphDescriptorHash intact. Re-enabling rebuilds the animation graph, and the old hash can index the
new graph's variable set out of bounds - the same OOB variable-index crash the leveled-conform re-enable
(GraphDescriptorHash=0) and the werewolf/VL transforms already guard against. Symptom: freeze/hang when
saving near dense static-NPC clusters (Riverwood Gerdur/Frodnar) where the escalation fires often.

Zero GraphDescriptorHash right after EnableImpl() so the next sync tick recomputes it from the fresh
graph (engine treats hash==0 as 'recompute', per TESObjectREFR). Mirrors the existing conform path.
Two fixes for NPCs (esp. horses/persistent owned actors) accumulating multiple copies of their
outfit near the spawn area:

1. SetActorInventory supplement now merges derived worn entries by BaseId instead of blind
   push_back. The owner self-heal re-runs SetActorInventory on every ownership flip; without
   the merge each flip appended another full derived outfit, stacking items (observed 5x on
   horses). Replacing on BaseId makes a re-apply a no-op for already-present items.

2. TakeOwnership / OnAssignCharacter no longer re-arm a self-heal retry that is already tracked
   for the actor. Ownership flips fire repeatedly for persistent owned NPCs; each re-arm reset
   the retry timer and re-ran SetInventory, churning the inventory. One active self-heal per
   actor; a fresh TakeOwnership after the entry is removed starts a new one.

Cross-log evidence: stacked actors (e.g. horse 9B7A9, 12x re-dress) appear only in the OWNER's
log, never in other clients' logs, confirming the duplication is generated locally by the
self-heal, not server-authoritative.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants