Skip to content

NPC armor, idle fidgets, fences that block, anim sound notes, co-op disconnect crash - #36

Closed
qwerin wants to merge 6 commits into
codingncaffeine:mainfrom
qwerin:fix/npc-gear-idle-nav
Closed

qwerin wants to merge 6 commits into
codingncaffeine:mainfrom
qwerin:fix/npc-gear-idle-nav

Conversation

@qwerin

@qwerin qwerin commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

Fixes found while play-testing co-op on multiplayer_world (Elddim). None of them is co-op-only except the last one; the same NPCs, fences and sounds were wrong in the single-player campaign.

NPCs wear their armor. ResolveNpcEquippedGear only handled es_weapon_hand / es_shield_hand, so a guard's [inventory][equipment] chest, helm, boots and gauntlets never showed and Legionnaire guards looked like townsfolk.

  • NPC slots (template merged with the map instance) now go through the same EquipmentResolver.Resolve as the hero: chest armor swaps body texture slot 1, helm/boots/gauntlets are shared skinned layers posed with the body's clip.
  • As in DS1, the chest item's armor_type also picks the body mesh: plate (a3) spawns on m_c_gah_fb_pos_a3 with b_c_pos_a3_*, instead of drawing the plate texture on the thin pos_a1 body where it reads as a shirt. The hero keeps his live texture swap (DressArmoredBodies off for him).
  • Companions refresh their armor visuals when their paperdoll changes.

Idle actors fidget. Almost every shipped chore_default is a one-frame pose; in DS1 it never shows because actor_auto_fidgets re-queues chore_fidget after every anim. SiegeFX idled on clip 0, so e.g. the blacksmith stood rigid at his anvil.

  • New ActorIdle.PickIdleChore: an authored initial_chore wins; otherwise an actor whose chore_default is static and whose fidget moves idles in chore_fidget (unless actor_auto_fidgets = false).
  • Actor.CurrentClipIndex (now the testable ResolveClipIndex) keeps the idle clip when the default skrit asks for index 0. This affects most NPCs and monsters; it is also what makes the existing initial_chore idles visible.
  • Only the first fidget variant loops (DS1 picks a random one each time).

Fences and walls block walking. StitchSnoDoorSeams's fallback linked any nearby unconnected walkable patches by nearest centroid with only a height gate, so the two floor strips of a t_grs01_fence_generic_wood-* tile (and house walls) got linked straight through the boards.

  • Candidate links are checked best-first against the non-walkable edges of both door tiles and their neighbours; an edge standing 0.25–2.0 u over the crossing is a barrier.
  • If every candidate crosses a barrier the link is held back, and restored only when dropping it would cut the mesh apart.
  • Component counts and largest-component sizes are unchanged on all 81 World.dsmap and 70 MpWorld.dsmap regions; ~15.5k (SP) / ~16.5k (MP) wall- and fence-crossing links drop; Elddim's pig-pen fence goes from 30 crossings to 0.

Animation sound notes. Nothing posted DS1's we_anim_sfx when a clip crossed a sound note, so the blacksmith's blacksmith_hammer trigger (sparks + s_e_env_hammer_anvil) never ran.

  • Non-hero actors dispatch each note once per loop; trigger rows match the note number in receive_world_message("we_anim_sfx",n) and only queue messages they listen for, so a looping clip can't flood a row's 4-slot inbox.
  • An effect offset without its own target uses the owner's facing and live position (it was world +Z, which put the sparks 0.8 m behind him).
  • prs info lists each clip's notes.

Co-op: a dropped peer no longer crashes the host. The LAN keepalive raises PeerDisconnected on a pool thread; OnPlayerLeft then removed the remote avatar from _actors while the render loop enumerated it (Collection was modified). MpSession now queues leaves and drains them at the start of Tick; NetSelfTest ticks the host after its disconnect.

Checks

  • dotnet build SiegeFX.sln -c Release -warnaserror: clean. dotnet test: 158/158, including new NpcArmorTests, ActorIdleTests, NavFenceSeamTests, AnimSfxTests, SfxOwnerFacingTests, MpDisconnectTests (all data-free).
  • tools/receipts.sh: all PASS; parity ledger better (template-fields 397 -> 401, animation-skrit-api 17 -> 18), no regressions.
  • Play-tested host + client on multiplayer_world town_center in a co-op development branch carrying these commits: guards in full plate, blacksmith hammering with sound and sparks on the anvil, the pig-pen fence blocks, a client timing out no longer takes the host down.

🤖 Generated with Claude Code

qwerin and others added 6 commits November 4, 2026 18:16
NPCs only ever resolved es_weapon_hand/es_shield_hand; es_chest, es_head,
es_feet and es_forearms were ignored, so Elddim's Legionnaire guards (whose
[aspect][textures] authors just the skin) drew in the bare citizen body.
Feed the template + instance [inventory][equipment] slots through the
player's EquipmentResolver: chest armor swaps body slot 1 to b_c_pos_*,
helm/boots/gauntlets draw as shared skinned layers posed with the body's
clip. Companion paperdoll swaps refresh the armor too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 805ae51984439dfc24f72f6586eab95872f65fd6)
The first pass drew helm/boot/gauntlet layers and swapped body slot 1 to
b_c_pos_a1_<style>, but the chest texture was drawn on the template's thin
pos_a1 body, where Legionnaire plate (armor_type a3) reads as a plain
shirt - the guards still looked like townsfolk. DS1 picks the body mesh
from the chest item's armor_type: plate is drawn on m_c_gah_fb_pos_a3
with b_c_pos_a3_<style>.

ActorSpawner now spawns plain (non-hero) actors on the armored body when
their worn es_chest names another aN body type and that mesh ships;
the chest texture and boot/gauntlet subtypes resolve against the body
actually drawn. The hero spawn opts out (his armor stays a live texture
swap on the picked body).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 8a064a49bb0bfb15cd1a18b9be24f71fab02f53b)
Zabar Treseck (Elddim, multiplayer_world) stood rigid at the anvil. His
chore_default (a_c_gbn_bs_fs2_ds) is a single 0.08 s frame; the hammering
is his chore_fidget (fs2_dsf, whose note drives the blacksmith_hammer
sfx trigger). DS1 never shows the frozen frame: with actor_auto_fidgets
the fidget job re-queues chore_fidget on every WE_ANIM_DONE, so the
fidget loop is the idle. Nearly every shipped chore_default is such a
one-frame stance.

Two things kept him on it. The spawner only ever picked an idle other
than chore_default from an authored initial_chore. And even that idle
lost: infinite_loop asks for index 0 the moment chore_default starts,
and CurrentClipIndex only fell back to IdleClipIndex before that first
dispatch (or for an awakened burrower).

Now an actor with no initial_chore idles in chore_fidget when its
default is static, its fidget moves, and its mind doesn't turn
auto-fidgeting off; and the idle clip wins over a skrit-driven 0 (an
explicit override of clip 0 still shows). The hero keeps slot 0 as
before. Host and client spawn through the same path, so both show it.
The SP Stonebridge and Glacern blacksmiths share the template base and
were frozen the same way.

region spawn prints which actors idle in something other than
chore_default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit e6492a9c253f300ad9abf2a48b9922c40088a18f)
The door stitcher's slot-starved fallback wires an ExtraLinks hop between
every pair of near-door local components that are not already adjacent,
picking the nearest centroid pair with only a 1.0u Y gate. A wooden fence
piece (t_grs01_fence_generic_wood-*) has a floor strip on each side of its
Ignored boards; its strips weld to the neighbouring tiles, so every door
off it fell through to the fallback and tied one side of the fence to the
other. In multiplayer_world town_center (the co-op start in Elddim) that
was 30 of 31 fence floor-pair crossings — players walked straight through
the pig-pen fence, and through blacksmith/house walls (4-6u hops).

Each candidate hop is now tested, best-first, against the Ignored nav
edges of both door snodes and their door neighbours; an edge standing
0.25..2.0u above the hop at the crossing is a barrier. A hop with no
barrier-free pair is deferred and only restored when dropping it would
split the mesh, so connectivity is unchanged: raw component counts and
biggest-component sizes are identical before/after on all 81 World.dsmap
and 70 MpWorld.dsmap regions, while ~15.5k / ~16.5k redundant
barrier-crossing hops are dropped. Host and client build the same mesh,
so both are fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 048d7dc61819877a29d9dfdfa9c8e7275da8ac86)
…facing

The blacksmith hammered in silence: nothing posted DS1's we_anim_sfx when a
clip crossed a sound note, so his blacksmith_hammer trigger (sparks plus
s_e_env_hammer_anvil) never ran. Non-hero actors now dispatch each note
once per loop. Trigger rows match the note number in
receive_world_message("we_anim_sfx",n) and only queue messages they listen
for, so a looping clip no longer floods a row's inbox. An effect offset
without its own target now uses the owner's facing and live position: the
sparks land on the anvil instead of 0.8 m behind him (world +Z).
`prs info` lists each clip's notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit a6f0871992242fb3e7436f4673edb57c15adb5fc)
…hread

The LAN keepalive (and EOS callbacks) raise PeerDisconnected from a pool
thread. MpSession ran OnPeerGone there, so OnPlayerLeft removed the remote
avatar from RenderHost._actors while the render loop enumerated it: the host
crashed with "Collection was modified" when a client timed out. The session
now queues the leave and drains it at the start of Tick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dafioram

dafioram commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

I recommend smaller PRs. This PR looks like it could be 5 PRs.

@qwerin

qwerin commented Nov 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review — split into one PR per fix, rebased on current main (v0.5.2):

Closing this one in their favour.

@qwerin qwerin closed this Nov 8, 2026
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