fix: leveled NPC sync — shell conforms, durable pending, crash guards - #4
Merged
absol89 merged 23 commits intoJul 6, 2026
Merged
Conversation
`windows-latest` and `windows-2025` are now defaulting to VS2026. VS2026 and the newer SDKs/platform toolsets currently can cause issues (like certain DLLs not loading for some reason); we'll migrate later, maybe Also undo `MemoryLayout.cpp` change
…vers (tiltedphoques#877) * fix: IsPlayerLeader const-qualified for party-leader /setTime check * fix: add const Player::GetParty() overload and use it in IsPlayerLeader * fix: avoid sol2 overload ambiguity in Player_Bindings after adding const GetParty * fix: use const-safe GetById() in IsPlayerLeader * feat: /setTime party-leader allowed when bAnnounceServer is false; clean IsPlayerLeader conflict * feat: allow party leaders to use /setTime on private servers
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>
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.
Summary
Post-playtest fixes from two absol+DJ sessions (2026-07-05). All changes are client-only except the server log demotion.
LeveledCharacter(unresolved shells) were being skipped by the!IsTemporary()guard instead of conformed, leaving bosses invisible/headless. NewIsUnresolvedLeveledShell()detection conforms them immediately and bypasses the 3D-existence gate (shells can never load 3D on their own).559f0fbd,f609305e) — conforming an actor to a pick from a different behavior project (e.g. humanoid→canine) left a staleGraphDescriptorHash, corrupting the animation variable set on next sync. Fixed by zeroing the hash on conform + bounds-checking variable serialization loops.Stored/Relayingleveled pick lines demoted frominfotodebugto stop grid-edge critter churn from flooding the server log.Known remaining items (NOT in this PR — need protocol bump, batch together)
RequestOwnershipClaimnever carriesLeveledNpcPickId; party-leader handoff via this path loses the pick.PC-Level-Multactors evaluate against the LOCAL player level; fix requires syncing the owner's resolved level and conforming via per-actor temp NPC minted with fn 14375.Test plan
🤖 Generated with Claude Code