Feat/leveled npc sync v2 - #882
Open
absol89 wants to merge 21 commits into
Open
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Guard the pick map with a lock (resolution can run on a loader thread), consume the forced pick one-shot so neighbor resolutions in the same enable window cannot inherit it, resolve the pick GameId once, and note the client-authoritative trust model server-side. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The main co-op scenario (second client discovers an actor the host already owns) resolves through AssignCharacterResponse, not CharacterSpawnRequest, so the pick never reached remote clients. Carry the pick on the response and apply it in the not-owner branch. Every capture/apply path now logs at info so mismatches are diagnosable from tp_client.log and the server log. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed it Live cell attach can resolve leveled actors without passing through the hooked fn 14375 (only observed firing during save load), leaving the pick map empty for freshly spawned camp NPCs. Fall back to the first static NPC in the temp base template chain, declining when that entry is the placed shell itself (recognizable by templating off a leveled list). Also log every hook fire so the actual resolution paths are visible in tp_client.log. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
In-game verification showed EnableImpl never re-runs leveled resolution, so the forced-pick substitution could not work and the synchronous disable/enable pair left actors invisible (the queued 3D teardown ate the rebuild). Point the reference at the picked NPC directly, disable, and re-enable one frame later via the runner queue. Drop the now-dead forced-pick machinery and demote the resolver trace to debug. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mutating the reference while cell attach is still streaming its 3D crashed the joining client. Poll loadedState through the runner queue (bounded retries), then disable, swap the base to the pick, and re-enable one frame later. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The loadedState gate alone still allowed the conform to run mid cell attach, crashing the joining client during the loading screen. Poll the Loading Menu state and only mutate the reference once the world is interactive; waiting does not consume the retry budget. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause of the three joining-client crashes: message handlers queueing to the runner executes TaskQueue::Add from inside TaskQueue::Drain, which re-locks the drain mutex on the same thread (UB, silent death, no dump); the first build additionally mutated references mid cell attach. Replace the runner round-trips with a pending-conform map drained from CharacterService::OnUpdate (the m_weaponDrawUpdates pattern), gated on the loading screen and per-actor 3D state, with disable and re-enable split across ticks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The resolver map is keyed by temp form ids the engine recycles, and cell attach resolves leveled actors without the hook, so a map hit can describe a previous occupant of the id - which shipped a stale Dawnguard thrall pick for a Forsworn and rendered it on the remote client. The chain is derived from the live actor and cannot go stale; the map is now a fallback for named leveled NPCs only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When the party leader steals ownership of an already-managed actor, the server kept the old owner pick and told nobody to conform, leaving each client with its own roll. Adopt the new owner pick from the transferring assignment request, carry it on NotifySpawnData, and conform the demoted owner like any other remote client. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The anim-var sync caches GraphDescriptorHash once per actor. A conform that crosses animation projects (rabbit -> fox) kept the old project's variable indices, so every remote update wrote the owner's values through stale indices into the new graph's variable set; float bits landed in words the new project reads as quad-variable indices, crashing on an OOB load in hkbBehaviorGraph::update within seconds. Zero the hash after the base swap so the next sync tick recomputes it from the rebuilt graph, mirroring what the werewolf and vampire lord transforms already do. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The boolean apply loop validated indices against the variable set size but the float/integer loops and all three serialize loops did not. Any descriptor/graph mismatch (stale descriptor, modded behaviors) could read or write out of bounds. Guard every loop so a mismatch degrades to wrong animation values instead of memory corruption. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ists Two playtest-proven gaps: the 300-frame retry budget melts at high fps and can never cover distant actors, abandoning conforms (Embershard door bandit); and picks arriving while the local actor still carries the unresolved placed shell were mistaken for already-conformed, leaving shells invisible or headless (OBIS bosses). Pending conforms now persist until the actor streams in (cleared on disconnect), and a static base templating onto a leveled list conforms instead of skipping, bypassing the 3D gate shells can never satisfy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Grid-edge critter churn re-requests the same actor every half second, each printing a relay line at info; the pipeline is proven, so keep the diagnostics behind the debug level. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix: leveled NPC sync — shell conforms, durable pending, crash guards
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #639 and puts the base form and approximate max HP near equal on all clients in the cell. Leading to less combat desync. Reviewer note: These files are touched by naked NPC fixes as well, and can additively merge.
PR:
feat/leveled-npc-sync-v2vsupstream/devBranch:
absol89/TiltedEvolutionScriptFixes@feat/leveled-npc-sync-v2→tiltedphoques/TiltedEvolution@devMerge base:
9d81ef07(currentupstream/devHEAD, so the branch is rebased on upstream dev)Scope: 16 files changed, +325 / −19
Goal
Make every client render the same leveled-NPC identity that the actor's owner rolled, instead of each client independently rolling its own pick from the leveled list. Without this, two players standing near the same leveled bandit see different NPCs (different faces/races/equipment), and ownership transfers can leave remote clients stuck on the wrong pick.
How it works
The owner captures the resolved pick at assignment time and the server stores/relays it. Every other client conforms its locally-rolled actor to the owner's pick by swapping the base form (disable → re-enable 3D → swap base).
Wire protocol — new
LeveledNpcPickIdfieldAdded to four messages (client ↔ server):
AssignCharacterRequest(.h/.cpp)AssignCharacterResponse(.h/.cpp)CharacterSpawnRequest(.h/.cpp)NotifySpawnData(.h/.cpp)Each gets the new
GameId LeveledNpcPickId{}field plus serialize/deserialize andoperator==updates.Server side (
Code/server/)CharacterComponent.h: newFormIdComponent LeveledNpcPickId{}stored on the character.CharacterService.cpp:CreateCharacter: storesmessage.LeveledNpcPickIdas received (client-authoritative, same trust model asBaseId).Serialize(spawn): copies the stored pick intoCharacterSpawnRequest::LeveledNpcPickId.OnAssignCharacterRequest: on ownership transfer within a party, the new owner's pick becomes authoritative (LeveledNpcPickId = message.LeveledNpcPickId); the assignment response relays the stored pick back to the requesting client.BroadcastActorData: includes the pick inNotifySpawnData(ownership transfers / updates).Client side — capture (
Code/client/)TESNPC.cpp/TESNPC.h:SetLeveledNpc(fn 14375) is enabled. It records(tempBaseFormId → pickedNpcFormId)in a mutex-guardeds_leveledPicksmap for recovery.TESNPC::GetLeveledPickFormId()reads that map (last resort).TESNPC::GetLeveledPick()walks the template chain and returns the first static NPC — the robust path, since the chain can't go stale. Guarded byformTypechecks because chain entries may beTESLevCharactermasquerading asTESNPC*.CharacterService.cppRequestServerAssignment: when the base is temporary, preferGetLeveledPick()(chain) and only fall back to the resolver map for named leveled NPCs (whose chain hides the pick). The captured pick is sent asmessage.LeveledNpcPickIdand the actor is assigned via its template base.Client side — apply / conform
CharacterService.cpp:OnCharacterSpawn/CreateCharacterForEntity: preferLeveledNpcPickIdover the lossy templateBaseIdwhen spawning a remote actor.OnAssignCharacter/OnRemoteSpawnDataReceived: callApplyLeveledNpcPick(...)so assignment responses and ownership transfers conform the local actor to the owner's pick.ApplyLeveledNpcPick: skips non-shell static bases (already-conformed actors); only conforms actors whose base is an unresolved leveled shell (IsUnresolvedLeveledShell— a static base still templating onto aLeveledCharacterlist) or a temporary base whose pick differs from the owner's. It never mutates the reference directly (avoids re-locking the drained-task mutex) — it queues aLeveledConformDataentry inm_pendingLeveledConforms.ProcessLeveledConforms(runs on the service update tick):DisableImpl()then setDisabled=true; next tick rebuilds the 3D withbaseForm = pickviaEnableImpl(), and zeroesGraphDescriptorHashso the animation-graph descriptor is recomputed — this prevents the OOB variable-index crash when a pick crosses animation projects (e.g. rabbit → fox).m_pendingLeveledConformsis cleared on disconnect.Other fixes bundled in
TESObjectREFR.cpp(SaveAnimationVariables/LoadAnimationVariables): bounds-checks the variable-set index againstpVariableSet->sizefor booleans/floats/integers — prevents OOB reads/writes during animation variable serialization (a crash fix independent of the leveled feature but needed for stable conform)..gitignore: ignores local tooling (vsxmake2026,.claude/,CLAUDE.md,docs/superpowers/) — local-only, not gameplay-relevant.Risk notes
s_leveledPicks) is keyed by recycled temp form ids and is bypassed by some spawn paths (live cell attach). It is used strictly as a last resort; the template chain is authoritative. A map hit may describe a previous occupant of the id — acceptable because chain-derived picks win.LeveledNpcPickIdis client-authoritative on the server (same asBaseId); a forged id can at worst change which NPC identity renders. No trust/safety boundary crossed.Testing hooks
Each capture/apply/conform step logs via
spdlog(most demoted todebugin the final commit,4fb53dde"leveled NPC resolution probe" and200c5d41"demote logging" removed the temporary probe / quieted routine logs). Watch for:Captured leveled NPC pick ... for actor ...Conforming leveled actor ... to owner's pick ...Re-enabled conformed leveled actor ...